Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75d9ec05e9
|
||
|
|
45a27629de
|
||
|
|
d6bea2aeba
|
||
|
|
70fb355250
|
||
|
|
c9494b27ec
|
||
|
|
a77ea36fe5
|
||
|
|
e9a3f13b9a
|
||
|
|
e2dab6ef14
|
||
|
|
7dbaa4914f
|
||
|
|
1dbe97d9b7
|
||
|
|
84450ffc19
|
||
|
|
b4de1c0d34
|
||
|
|
c90afe24dc
|
||
|
|
f5ab724a1c
|
||
|
|
096982fe69
|
||
|
|
96e2effbe1
|
||
|
|
846f7d491f
|
||
|
|
a9b39710a2
|
||
|
|
defbf90e89 | ||
|
|
16faf06fcf
|
||
|
|
466c2ca14a
|
||
|
|
f3b236ae82
|
||
|
|
ec4579a461 | ||
|
|
d9a781e3c9 | ||
|
|
a6c3480ff8 | ||
|
|
545190596d | ||
|
|
b381093e32 | ||
|
|
9d74c813f1 | ||
|
|
fce85abf48 | ||
|
|
3c68a1bb04 | ||
|
|
b89def6c7f | ||
|
|
0de49b231a | ||
|
|
3e00a48c00 | ||
|
|
a1bdc46119 | ||
|
|
7a60c8dea4 | ||
|
|
6be88d14a5 | ||
|
|
50a26be6a7 | ||
|
|
d5ef1c1d15 | ||
|
|
d024c82a9d | ||
|
|
d7f17a0424 | ||
|
|
87a2931209 | ||
|
|
647979bde7 | ||
|
|
123535ea14 | ||
|
|
645cfc2a36 | ||
|
|
e989960895 | ||
|
|
48f7c411c0 | ||
|
|
7727d40c92 |
No files matched your search
@@ -130,6 +130,13 @@ Exceptions are allowed for migration and package-helper scripts where the helper
|
|||||||
- Chinese menu labels live in `default/omarchy/omarchy-menu.zh-cn.jsonc`, merged when `LANG` starts with `zh_CN`; give a new menu row with translatable words a label there.
|
- Chinese menu labels live in `default/omarchy/omarchy-menu.zh-cn.jsonc`, merged when `LANG` starts with `zh_CN`; give a new menu row with translatable words a label there.
|
||||||
- Shell text goes through `I18n.tr("English %1", arg)` from `qs.Commons`, with its Chinese in `shell/Commons/I18nZh.js` keyed by the English. Notifications and OSD messages are worded by their sender with `omarchy-text "English %1" "$arg"` (Python: `text()`, QML: `I18n.tr`), which reads the same table; the shell shows them as given, so user text is never translated. `test/shell.d/shell-i18n-test.sh` fails on a missing entry.
|
- Shell text goes through `I18n.tr("English %1", arg)` from `qs.Commons`, with its Chinese in `shell/Commons/I18nZh.js` keyed by the English. Notifications and OSD messages are worded by their sender with `omarchy-text "English %1" "$arg"` (Python: `text()`, QML: `I18n.tr`), which reads the same table; the shell shows them as given, so user text is never translated. `test/shell.d/shell-i18n-test.sh` fails on a missing entry.
|
||||||
|
|
||||||
|
# Region
|
||||||
|
|
||||||
|
- `/etc/omarchy/region` holds the region the installer derived from the timezone (`cn`); no file means Global. The profile lives in `default/regions/cn/`, its contract in `docs/regions.md`.
|
||||||
|
- `install/config/region.sh` links the `cn` developer mirrors into `/etc` and `/.cargo` and refuses to replace a regular file it did not create. It serves both the install (the marker) and `omarchy region cn|global` (`OMARCHY_REGION`), which also records or removes the marker; Global removes only the links it made.
|
||||||
|
- `omarchy-region` prints the region (`Global` or `China`) and switches it. Nothing in `omarchy update` or migrations may change the region.
|
||||||
|
- China-only menu rows carry `"when":"[[ \"$(omarchy-region)\" == \"China\" ]]"` on every row, not only the submenu, because search checks each row's own guard. User-facing docs: `manual/52-china.md`.
|
||||||
|
|
||||||
# Config Structure
|
# Config Structure
|
||||||
|
|
||||||
- `config/` - default configs copied to `~/.config/`
|
- `config/` - default configs copied to `~/.config/`
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ The manual lives in [`manual/`](manual/), which is its authoritative source.
|
|||||||
- [Omarchy on...](manual/49-omarchy-on.md)
|
- [Omarchy on...](manual/49-omarchy-on.md)
|
||||||
- [Dual Boot Install](manual/50-dual-boot-install.md)
|
- [Dual Boot Install](manual/50-dual-boot-install.md)
|
||||||
- [Unattended Installs](manual/51-unattended-installs.md)
|
- [Unattended Installs](manual/51-unattended-installs.md)
|
||||||
|
- [China](manual/52-china.md)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ GROUP_DESCRIPTIONS[plymouth]="Plymouth boot theme management"
|
|||||||
GROUP_DESCRIPTIONS[power]="Power supply detection"
|
GROUP_DESCRIPTIONS[power]="Power supply detection"
|
||||||
GROUP_DESCRIPTIONS[powerprofiles]="Power profile management"
|
GROUP_DESCRIPTIONS[powerprofiles]="Power profile management"
|
||||||
GROUP_DESCRIPTIONS[refresh]="Reset config to defaults"
|
GROUP_DESCRIPTIONS[refresh]="Reset config to defaults"
|
||||||
|
GROUP_DESCRIPTIONS[region]="Region-specific package sources and setup"
|
||||||
GROUP_DESCRIPTIONS[reinstall]="Reinstall and reset workflows"
|
GROUP_DESCRIPTIONS[reinstall]="Reinstall and reset workflows"
|
||||||
GROUP_DESCRIPTIONS[reminder]="Desktop notification reminders"
|
GROUP_DESCRIPTIONS[reminder]="Desktop notification reminders"
|
||||||
GROUP_DESCRIPTIONS[remove]="Removal workflows"
|
GROUP_DESCRIPTIONS[remove]="Removal workflows"
|
||||||
|
|||||||
Executable
+77
@@ -0,0 +1,77 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# omarchy:summary=Restore channel and regional pacman defaults in an installed target
|
||||||
|
# omarchy:args=<stable|rc|edge> [target-root] | --render <stable|rc|edge> <output-dir> [target-root]
|
||||||
|
# omarchy:requires-sudo=true
|
||||||
|
# omarchy:hidden=true
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: omarchy-apply-pacman <stable|rc|edge> [target-root]" >&2
|
||||||
|
echo " omarchy-apply-pacman --render <stable|rc|edge> <output-dir> [target-root]" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --render only builds the two files, so an unprivileged caller can hand root
|
||||||
|
# nothing but plain copies of the finished result.
|
||||||
|
render_dir=""
|
||||||
|
if [[ ${1:-} == "--render" ]]; then
|
||||||
|
(( $# == 3 || $# == 4 )) || usage
|
||||||
|
channel="$2"
|
||||||
|
render_dir="$3"
|
||||||
|
[[ -d $render_dir ]] || { echo "Render directory not found: $render_dir" >&2; exit 1; }
|
||||||
|
etc="${4:-/}/etc"
|
||||||
|
else
|
||||||
|
(( $# >= 1 && $# <= 2 )) || usage
|
||||||
|
channel="$1"
|
||||||
|
etc="${2:-/}/etc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$channel" in
|
||||||
|
stable|rc|edge) ;;
|
||||||
|
*) echo "Invalid pacman channel: $channel" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The ISO writes this plain-text marker before target finalization. Missing
|
||||||
|
# markers keep existing installations global; never source it as shell code.
|
||||||
|
region=global
|
||||||
|
if [[ -e $etc/omarchy/region ]]; then
|
||||||
|
region=$(<"$etc/omarchy/region")
|
||||||
|
fi
|
||||||
|
case "$region" in
|
||||||
|
global|cn) ;;
|
||||||
|
*) echo "Unsupported region: $region (expected global or cn)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Build both files before replacing either. Explicit refreshes retain backups;
|
||||||
|
# ordinary updates do not call this command or enforce regional defaults.
|
||||||
|
if [[ -n $render_dir ]]; then
|
||||||
|
staging="$render_dir"
|
||||||
|
else
|
||||||
|
staging=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$staging"' EXIT
|
||||||
|
fi
|
||||||
|
cp "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" "$staging/pacman.conf"
|
||||||
|
if [[ $region == "global" ]]; then
|
||||||
|
cp "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" "$staging/mirrorlist"
|
||||||
|
else
|
||||||
|
profile="$OMARCHY_PATH/default/regions/$region/pacman"
|
||||||
|
printf '\n' >> "$staging/pacman.conf"
|
||||||
|
cat "$profile/pacman.conf.append" >> "$staging/pacman.conf"
|
||||||
|
# pacman tries servers in order, so the regional mirror leads and the
|
||||||
|
# channel's own mirror stays as the fallback.
|
||||||
|
cat "$profile/mirrorlist-$channel.prepend" "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" > "$staging/mirrorlist"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z $render_dir ]]; then
|
||||||
|
mkdir -p "$etc/pacman.d"
|
||||||
|
for file in pacman.conf pacman.d/mirrorlist; do
|
||||||
|
if [[ -e $etc/$file ]]; then
|
||||||
|
cp -f "$etc/$file" "$etc/$file.bak"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# Copying over an existing file keeps its owner and mode, as refresh always has.
|
||||||
|
cp -f "$staging/pacman.conf" "$etc/pacman.conf"
|
||||||
|
cp -f "$staging/mirrorlist" "$etc/pacman.d/mirrorlist"
|
||||||
|
fi
|
||||||
@@ -47,9 +47,31 @@ validate_dev_checkout() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The dev channel refreshes pacman with the checkout's own tools. A checkout
|
||||||
|
# without this machine's region profile would silently drop its repositories.
|
||||||
|
validate_dev_region() {
|
||||||
|
local checkout="$1" region_file="${OMARCHY_REGION_FILE:-/etc/omarchy/region}" region=global
|
||||||
|
|
||||||
|
[[ -f $region_file ]] && region=$(<"$region_file")
|
||||||
|
[[ $region == "global" ]] && return 0
|
||||||
|
|
||||||
|
# The refresh after linking needs a runnable helper and every fragment, not
|
||||||
|
# just a directory with the region's name: dev refreshes edge, and leaving
|
||||||
|
# dev later refreshes stable or rc with this checkout's tools.
|
||||||
|
local profile="$checkout/default/regions/$region/pacman" fragment supported=true
|
||||||
|
[[ -x $checkout/bin/omarchy-apply-pacman ]] || supported=false
|
||||||
|
for fragment in pacman.conf.append mirrorlist-stable.prepend mirrorlist-rc.prepend mirrorlist-edge.prepend; do
|
||||||
|
[[ -r $profile/$fragment ]] || supported=false
|
||||||
|
done
|
||||||
|
if ! $supported; then
|
||||||
|
fail "$checkout does not support the '$region' region, so switching would drop its package repositories. Check out a branch with default/regions/$region, or remove $region_file to use global defaults."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
link_dev_checkout() {
|
link_dev_checkout() {
|
||||||
local checkout="$1" required
|
local checkout="$1" required
|
||||||
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout"
|
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout"
|
||||||
|
validate_dev_region "$checkout"
|
||||||
|
|
||||||
# Check the destination before changing /etc/omarchy.conf or sudo's path.
|
# Check the destination before changing /etc/omarchy.conf or sudo's path.
|
||||||
# An existing checkout is not pulled automatically and may predate this policy.
|
# An existing checkout is not pulled automatically and may predate this policy.
|
||||||
|
|||||||
@@ -1174,15 +1174,6 @@ run_provisioning() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if omarchy-pkg-present usbguard; then
|
|
||||||
log_step "enrolling the owner's USB devices"
|
|
||||||
source "$OMARCHY_PATH/install/helpers/usb-authorization.sh"
|
|
||||||
usb_authorization_provision_owner "$username"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log_step "enrolling the owner's Thunderbolt accessories"
|
|
||||||
/usr/bin/omarchy-thunderbolt-authorization-admin owner
|
|
||||||
|
|
||||||
log_step "cleaning up provisioning state"
|
log_step "cleaning up provisioning state"
|
||||||
cleanup_oem_state
|
cleanup_oem_state
|
||||||
log_step "first-boot setup complete"
|
log_step "first-boot setup complete"
|
||||||
|
|||||||
@@ -42,11 +42,29 @@ if [[ ! -f $templates/pacman-$channel.conf || ! -f $templates/mirrorlist-$channe
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
conf=$templates/pacman-$channel.conf mirrorlist=$templates/mirrorlist-$channel
|
||||||
|
# Regions render x86 pacman only (omarchy #13690).
|
||||||
|
if [[ $platform == x86 ]]; then
|
||||||
|
# Render the channel templates plus this machine's regional additions as the
|
||||||
|
# caller, so root only copies finished files. The scratch directory goes on
|
||||||
|
# every exit, including a failed render, a refused sudo prompt, or a signal;
|
||||||
|
# revoking the sudo timestamp stays the last thing the exit path does.
|
||||||
|
rendered=$(mktemp -d)
|
||||||
|
cleanup_refresh() {
|
||||||
|
local status=$?
|
||||||
|
rm -rf "$rendered"
|
||||||
|
omarchy_security_exit_with_revoked_sudo "$status"
|
||||||
|
}
|
||||||
|
trap cleanup_refresh EXIT
|
||||||
|
"$OMARCHY_PATH/bin/omarchy-apply-pacman" --render "$channel" "$rendered"
|
||||||
|
conf=$rendered/pacman.conf mirrorlist=$rendered/mirrorlist
|
||||||
|
fi
|
||||||
|
|
||||||
sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak
|
sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak
|
||||||
sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak
|
sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak
|
||||||
echo "Setting channel to $channel"
|
echo "Setting channel to $channel"
|
||||||
sudo cp -f "$templates/pacman-$channel.conf" /etc/pacman.conf
|
sudo cp -f "$conf" /etc/pacman.conf
|
||||||
sudo cp -f "$templates/mirrorlist-$channel" /etc/pacman.d/mirrorlist
|
sudo cp -f "$mirrorlist" /etc/pacman.d/mirrorlist
|
||||||
|
|
||||||
# Allow user customization of /etc/pacman.conf before the upgrade runs, so
|
# Allow user customization of /etc/pacman.conf before the upgrade runs, so
|
||||||
# custom repositories and IgnorePkg entries shape the downgrade-capable
|
# custom repositories and IgnorePkg entries shape the downgrade-capable
|
||||||
|
|||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# omarchy:summary=Show or switch the region this machine is set up for
|
||||||
|
# omarchy:args=[global|cn]
|
||||||
|
# omarchy:examples=omarchy region | omarchy region cn
|
||||||
|
# omarchy:requires-sudo=true
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
region_file=/etc/omarchy/region
|
||||||
|
|
||||||
|
if (($# == 0)); then
|
||||||
|
if [[ ! -e $region_file ]]; then
|
||||||
|
echo "Global"
|
||||||
|
elif [[ $(<"$region_file") == "cn" ]]; then
|
||||||
|
echo "China"
|
||||||
|
else
|
||||||
|
echo "Unknown region in $region_file: $(<"$region_file")" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $1 != "global" && $1 != "cn" ]]; then
|
||||||
|
echo "Usage: omarchy-region [global|cn]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
channel=$(omarchy-version-channel)
|
||||||
|
if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
|
||||||
|
echo "Cannot tell this machine's package channel ($channel); run omarchy channel set first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The cn profile renders x86 pacman only (omarchy #13690).
|
||||||
|
if [[ $1 == "cn" && $(omarchy-hw-platform) != "x86" ]]; then
|
||||||
|
echo "The China region needs an x86 machine; $(omarchy-hw-platform) has no China profile yet" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $1 == "cn" ]]; then
|
||||||
|
# Upgrade first: the keyring install below syncs new databases.
|
||||||
|
omarchy-update-pacman -Syu --noconfirm
|
||||||
|
sudo env OMARCHY_REGION=cn OMARCHY_PATH="$OMARCHY_PATH" bash -e "$OMARCHY_PATH/install/config/region.sh"
|
||||||
|
# Trust ArchLinuxCN before the refresh upgrades through it.
|
||||||
|
rendered=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$rendered"' EXIT
|
||||||
|
omarchy-apply-pacman --render "$channel" "$rendered"
|
||||||
|
mapfile -t profile_packages < <(grep -Ev '^[[:space:]]*(#|$)' "$OMARCHY_PATH/default/regions/cn/packages")
|
||||||
|
sudo pacman --config "$rendered/pacman.conf" -Sy --noconfirm --needed "${profile_packages[@]}"
|
||||||
|
sudo pacman-key --populate archlinuxcn
|
||||||
|
omarchy-refresh-pacman "$channel"
|
||||||
|
else
|
||||||
|
sudo env OMARCHY_REGION=global OMARCHY_PATH="$OMARCHY_PATH" bash -e "$OMARCHY_PATH/install/config/region.sh"
|
||||||
|
omarchy-refresh-pacman "$channel"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The developer mirrors reach only a new session, so offer one.
|
||||||
|
if [[ -t 0 && -t 1 ]] && gum confirm --affirmative "$(omarchy-text "Log out now")" --negative "$(omarchy-text "Later")" "$(omarchy-text "Log out now to switch the developer mirrors?")"; then
|
||||||
|
omarchy-system-logout
|
||||||
|
else
|
||||||
|
omarchy-text "Log back in to switch the developer mirrors."
|
||||||
|
fi
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Restore Bolt's default authorization behavior
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
gum confirm "Restore Bolt's previous policy? New accessories may be authorized automatically when IOMMU protection is active." || exit 0
|
|
||||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin disable
|
|
||||||
systemctl --user disable --now omarchy-thunderbolt-authorization.service
|
|
||||||
echo "Previous Bolt policy restored. Saved Omarchy trust is retained for re-enabling."
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Restore saved Thunderbolt firmware boot permissions
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
gum confirm "Restore the previous Thunderbolt firmware boot allowlists? Device approval will remain enabled." || exit 0
|
|
||||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-disable
|
|
||||||
echo "Previous firmware boot permissions restored and verified."
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Stop asking before new USB devices can access the system
|
|
||||||
# omarchy:args=[--boot-only] [--yes]
|
|
||||||
# omarchy:requires-sudo=true
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
assume_yes=0
|
|
||||||
boot_only=0
|
|
||||||
|
|
||||||
while (( $# > 0 )); do
|
|
||||||
case "$1" in
|
|
||||||
--yes) assume_yes=1 ;;
|
|
||||||
--boot-only) boot_only=1 ;;
|
|
||||||
-h | --help)
|
|
||||||
echo "Usage: omarchy-remove-security-usb-authorization [--boot-only] [--yes]"
|
|
||||||
echo
|
|
||||||
echo "Disables Omarchy's USB approval prompts and restores the kernel's"
|
|
||||||
echo "normal default-allow behavior."
|
|
||||||
echo "Use --boot-only to keep USBGuard enabled after the system starts."
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "omarchy-remove-security-usb-authorization: unknown option '$1'. Try --help." >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
if (( boot_only )); then
|
|
||||||
if (( ! assume_yes )); then
|
|
||||||
gum style --foreground 212 --bold "Remove USB authorization from boot"
|
|
||||||
echo
|
|
||||||
echo "USBGuard will continue blocking unknown devices after its service starts."
|
|
||||||
echo "Devices can reach kernel drivers earlier during boot."
|
|
||||||
echo
|
|
||||||
gum confirm "Disable USB authorization from kernel boot?" || exit 130
|
|
||||||
fi
|
|
||||||
|
|
||||||
sudo omarchy-usb-authorization-boot disable
|
|
||||||
echo
|
|
||||||
echo "Boot-time USB authorization is disabled and takes effect after reboot."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
state_dir="$HOME/.local/state/omarchy/usb-authorization"
|
|
||||||
unit="$HOME/.config/systemd/user/omarchy-usb-authorization.service"
|
|
||||||
|
|
||||||
if (( ! assume_yes )); then
|
|
||||||
gum style --foreground 212 --bold "Remove USB device authorization"
|
|
||||||
echo
|
|
||||||
echo "New USB devices will be allowed to bind to kernel drivers without approval."
|
|
||||||
echo
|
|
||||||
gum confirm "Disable USB device authorization?" || exit 130
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Rebuild a permissive boot image before stopping USBGuard. If rebuilding
|
|
||||||
# fails, the command stops here and leaves the complete policy enabled.
|
|
||||||
sudo omarchy-usb-authorization-boot disable
|
|
||||||
|
|
||||||
if omarchy-pkg-present usbguard; then
|
|
||||||
# Keep the watcher and its IPC access until blocking has actually stopped.
|
|
||||||
# In particular, a failed stop must not strand a running daemon without UI.
|
|
||||||
sudo systemctl disable --now usbguard.service
|
|
||||||
if systemctl is-active --quiet usbguard.service || systemctl is-enabled --quiet usbguard.service; then
|
|
||||||
echo "USBGuard is still active or enabled; approval prompts have been preserved." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sudo omarchy-usb-authorization-restore-default
|
|
||||||
sudo usbguard remove-user "$USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
systemctl --user disable --now omarchy-usb-authorization.service 2>/dev/null || true
|
|
||||||
rm -f "$unit"
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
|
|
||||||
if [[ -d $state_dir/requests ]]; then
|
|
||||||
find "$state_dir/requests" -mindepth 1 -maxdepth 1 -type f -delete
|
|
||||||
rmdir "$state_dir/requests" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "$state_dir/package-installed-by-omarchy" \
|
|
||||||
"$state_dir/policy-generated-by-omarchy" \
|
|
||||||
"$state_dir/service-enabled-by-omarchy"
|
|
||||||
rmdir "$state_dir" 2>/dev/null || true
|
|
||||||
|
|
||||||
echo "USB device authorization is disabled."
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Require approval for new Thunderbolt PCIe accessories
|
|
||||||
# omarchy:args=[--quiet|status|recover]
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh"
|
|
||||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh"
|
|
||||||
|
|
||||||
if [[ ${1:-} == "status" ]]; then
|
|
||||||
tb_status
|
|
||||||
elif [[ ${1:-} == "recover" && $# == 1 ]]; then
|
|
||||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin recover
|
|
||||||
elif [[ $# == 0 || ( $# == 1 && $1 == "--quiet" ) ]]; then
|
|
||||||
if [[ ${1:-} != "--quiet" ]]; then
|
|
||||||
if [[ -f $TB_PENDING ]]; then tb_status; fi
|
|
||||||
echo "First setup, including a deferred setup, trusts connected accessories. Re-enabling a completed setup preserves saved trust."
|
|
||||||
gum confirm "Require approval for new Thunderbolt accessories?" || exit 0
|
|
||||||
fi
|
|
||||||
# System units and rules are shipped by omarchy-settings.
|
|
||||||
sudo install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.thunderbolt.policy" /usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
action=enable
|
|
||||||
if [[ ${1:-} == "--quiet" ]]; then
|
|
||||||
action=migrate
|
|
||||||
fi
|
|
||||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin "$action"
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
systemctl --user enable --now omarchy-thunderbolt-authorization.service
|
|
||||||
if [[ -f /etc/omarchy/thunderbolt-authorization.enabled ]]; then
|
|
||||||
for ((attempt=0; attempt<10; attempt++)); do
|
|
||||||
if tb_public_snapshot >/dev/null 2>&1; then break; fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
tb_status
|
|
||||||
elif [[ -f $TB_PENDING ]]; then
|
|
||||||
tb_status
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Usage: omarchy-setup-security-thunderbolt-authorization [--quiet|status|recover]" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Clear supported Thunderbolt firmware boot allowlists
|
|
||||||
# omarchy:args=[recover]
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [[ ${1:-} == "recover" && $# == 1 ]]; then
|
|
||||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-recover
|
|
||||||
elif [[ $# == 0 ]]; then
|
|
||||||
gum style --bold "Thunderbolt at Boot"
|
|
||||||
echo "This trusts connected accessories for use after Linux starts, then clears the firmware boot allowlist."
|
|
||||||
echo "Trusted Thunderbolt keyboards, storage, and network adapters may be unavailable at disk unlock."
|
|
||||||
echo "You must be able to unlock and recover without Thunderbolt accessories."
|
|
||||||
echo "All controllers must be connected and support a boot allowlist in user or secure mode."
|
|
||||||
echo "Older snapshots or another operating system may restore firmware boot permissions."
|
|
||||||
gum confirm "Clear firmware boot access with these recovery limits?" || exit 0
|
|
||||||
sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-enable
|
|
||||||
echo "Firmware boot allowlists cleared and verified. Current connections were not disconnected."
|
|
||||||
else
|
|
||||||
echo "Usage: omarchy-setup-security-thunderbolt-boot [recover]" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Ask before new USB devices can access the system
|
|
||||||
# omarchy:args=[--boot] [--yes] [--review-existing]
|
|
||||||
# omarchy:requires-sudo=true
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
assume_yes=0
|
|
||||||
from_boot=0
|
|
||||||
review_existing=0
|
|
||||||
|
|
||||||
while (( $# > 0 )); do
|
|
||||||
case "$1" in
|
|
||||||
--yes) assume_yes=1 ;;
|
|
||||||
--boot) from_boot=1 ;;
|
|
||||||
--review-existing) review_existing=1 ;;
|
|
||||||
-h | --help)
|
|
||||||
echo "Usage: omarchy-setup-security-usb-authorization [--boot] [--yes] [--review-existing]"
|
|
||||||
echo
|
|
||||||
echo "Trusts USB devices that are connected now, then blocks new devices"
|
|
||||||
echo "until you approve them from an Omarchy notification."
|
|
||||||
echo
|
|
||||||
echo "--boot also blocks USB devices from the start of kernel boot. Connected"
|
|
||||||
echo "devices are added to USBGuard's policy, but remain unavailable before"
|
|
||||||
echo "USBGuard starts, including at an encrypted-disk password prompt."
|
|
||||||
echo "Older snapshots without USBGuard keep all USB disabled after startup,"
|
|
||||||
echo "including keyboards, network adapters, and storage. Use --boot only if"
|
|
||||||
echo "disk unlock and recovery work without any USB devices."
|
|
||||||
echo
|
|
||||||
echo "--review-existing reviews connected devices with older port-bound approvals."
|
|
||||||
echo "Each portable approval requires confirmation; existing rules are preserved."
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "omarchy-setup-security-usb-authorization: unknown option '$1'. Try --help." >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
# Boot-time blocking goes into Limine's own configuration, which a Mac's boot
|
|
||||||
# package owns, so it is not offered there.
|
|
||||||
if (( from_boot )) && omarchy-hw-aarch64-apple; then
|
|
||||||
echo "USB authorization from boot is not available on Apple Silicon yet; USB device authorization after login still works." >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
state_dir="$HOME/.local/state/omarchy/usb-authorization"
|
|
||||||
unit_source="$OMARCHY_PATH/default/systemd/user/omarchy-usb-authorization.service"
|
|
||||||
unit="$HOME/.config/systemd/user/omarchy-usb-authorization.service"
|
|
||||||
rules_file=/etc/usbguard/rules.conf
|
|
||||||
daemon_config=/etc/usbguard/usbguard-daemon.conf
|
|
||||||
|
|
||||||
source "$OMARCHY_PATH/install/helpers/usb-authorization.sh"
|
|
||||||
|
|
||||||
if (( review_existing )); then
|
|
||||||
# Older Omarchy rules have no per-rule ownership marker. They cannot safely
|
|
||||||
# be distinguished from intentionally restricted administrator rules.
|
|
||||||
(( ! assume_yes && ! from_boot )) || { echo "Review requires individual confirmation; do not combine it with --yes or --boot." >&2; exit 2; }
|
|
||||||
policy=$(usbguard list-rules)
|
|
||||||
declare -A reviewed=()
|
|
||||||
# Keep stdin attached to the terminal for gum (and any sudo prompt).
|
|
||||||
mapfile -t policy_lines <<<"$policy"
|
|
||||||
for line in "${policy_lines[@]}"; do
|
|
||||||
if [[ $line =~ ^[0-9]+:[[:space:]] ]]; then
|
|
||||||
rule=${line#*: }
|
|
||||||
[[ $rule == allow\ * ]] || continue
|
|
||||||
portable=$(usb_authorization_portable_rule "$rule") || continue
|
|
||||||
[[ ${portable% label *} != "$rule" ]] || continue
|
|
||||||
devices=$(usbguard list-devices "$rule")
|
|
||||||
mapfile -t candidates <<<"$devices"
|
|
||||||
for device in "${candidates[@]}"; do
|
|
||||||
[[ $device =~ ^([0-9]+):[[:space:]](allow[[:space:]].*)$ ]] || continue
|
|
||||||
id=${BASH_REMATCH[1]} current=${BASH_REMATCH[2]}
|
|
||||||
[[ ! -v reviewed[$id] ]] || continue
|
|
||||||
reviewed[$id]=1
|
|
||||||
echo
|
|
||||||
printf '%s\n' "$current" | LC_ALL=C tr -c '\11\12\15\40-\176' '?'
|
|
||||||
echo "This device has a location-bound approval. Details are supplied by the device."
|
|
||||||
if gum confirm "Trust this device on any USB port or hub?"; then
|
|
||||||
sudo /usr/bin/omarchy-usb-authorization-approve "$id" "$current"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "Review complete. Disconnected devices can be approved when reconnected."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The watcher runs in the session's environment, which may differ from a
|
|
||||||
# command invoked in a checkout. Fail before enabling blocking on mismatched
|
|
||||||
# or incomplete installations, including dangling dev-link unit symlinks.
|
|
||||||
session_environment=$(systemctl --user show-environment)
|
|
||||||
grep -Fqx -- "OMARCHY_PATH=$OMARCHY_PATH" <<<"$session_environment" || {
|
|
||||||
echo "The session and this command use different Omarchy installations. Update the session before enabling USB protection." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
for required in "$unit_source" /usr/share/omarchy/install/helpers/usb-authorization-policy.sh; do
|
|
||||||
[[ -r $required ]] || { echo "USB authorization installation is incomplete: $required" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
for required in "$OMARCHY_PATH/bin/omarchy-usb-authorization-watch" \
|
|
||||||
"$OMARCHY_PATH/bin/omarchy-usb-authorization-event" \
|
|
||||||
"$OMARCHY_PATH/bin/omarchy-usb-authorization-review" \
|
|
||||||
/usr/bin/omarchy-usb-authorization-approve; do
|
|
||||||
[[ -r $required && -x $required ]] || { echo "USB authorization command is unavailable: $required" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
if (( ! assume_yes )); then
|
|
||||||
if (( from_boot )); then
|
|
||||||
gum style --foreground 212 --bold "USB authorization from boot"
|
|
||||||
else
|
|
||||||
gum style --foreground 212 --bold "USB device authorization"
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
echo "Devices connected now will be trusted. Disconnect anything you do not trust."
|
|
||||||
echo "New USB devices will remain blocked until you approve them."
|
|
||||||
if (( from_boot )); then
|
|
||||||
echo
|
|
||||||
echo "Boot-time protection blocks all USB devices until USBGuard starts."
|
|
||||||
echo "A USB keyboard will not work at an encrypted-disk password prompt, even"
|
|
||||||
echo "when it is connected now."
|
|
||||||
echo
|
|
||||||
echo "Older snapshots without USBGuard keep ALL USB disabled after startup,"
|
|
||||||
echo "including keyboards, network adapters, and storage."
|
|
||||||
echo "Continue only if disk unlock and recovery work without any USB devices."
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
if (( from_boot )); then
|
|
||||||
gum confirm "Enable USB authorization from kernel boot?" || exit 130
|
|
||||||
else
|
|
||||||
gum confirm "Enable USB device authorization?" || exit 130
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
omarchy-pkg-add usbguard
|
|
||||||
|
|
||||||
usb_authorization_require_secure_settings "$daemon_config"
|
|
||||||
|
|
||||||
mkdir -p "$state_dir" "${unit%/*}"
|
|
||||||
chmod 700 "$state_dir"
|
|
||||||
|
|
||||||
if ! usb_authorization_rules_present "$rules_file"; then
|
|
||||||
policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX")
|
|
||||||
trap 'rm -f "$policy"' EXIT
|
|
||||||
chmod 600 "$policy"
|
|
||||||
usb_authorization_generate_policy "$policy"
|
|
||||||
sudo install -Dm600 -o root -g root "$policy" "$rules_file"
|
|
||||||
touch "$state_dir/policy-generated-by-omarchy"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# This user can inspect USBGuard events and approve the device named by an
|
|
||||||
# event. Policy and daemon parameters remain unavailable through the IPC ACL.
|
|
||||||
sudo usbguard add-user "$USER" \
|
|
||||||
--devices=list,listen,modify \
|
|
||||||
--policy=list \
|
|
||||||
--exceptions=listen
|
|
||||||
|
|
||||||
sudo install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.usb.policy" /usr/share/polkit-1/actions/org.omarchy.usb.policy
|
|
||||||
|
|
||||||
# Install a real file: a link into a checkout can become dangling on a branch
|
|
||||||
# switch. Start the waiting watcher before USBGuard can block new devices.
|
|
||||||
unit_temporary=$(mktemp "${unit}.XXXXXXXXXX")
|
|
||||||
install -m644 "$unit_source" "$unit_temporary"
|
|
||||||
mv -fT "$unit_temporary" "$unit"
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
systemctl --user enable omarchy-usb-authorization.service
|
|
||||||
systemctl --user restart omarchy-usb-authorization.service
|
|
||||||
systemctl --user is-active --quiet omarchy-usb-authorization.service
|
|
||||||
|
|
||||||
sudo systemctl enable usbguard.service
|
|
||||||
# add-user ACL changes are loaded only when the daemon starts, so restart an
|
|
||||||
# existing service as well as starting a fresh one.
|
|
||||||
sudo systemctl restart usbguard.service
|
|
||||||
systemctl is-active --quiet usbguard.service
|
|
||||||
systemctl --user is-active --quiet omarchy-usb-authorization.service
|
|
||||||
|
|
||||||
if (( from_boot )); then
|
|
||||||
# The fixed helper independently verifies each full live device snapshot.
|
|
||||||
usb_authorization_trust_present_devices
|
|
||||||
sudo omarchy-usb-authorization-boot enable
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "USB device authorization is enabled."
|
|
||||||
echo "Devices connected during setup remain trusted; new devices require approval."
|
|
||||||
echo "To review older port-bound approvals, run: omarchy-setup-security-usb-authorization --review-existing"
|
|
||||||
if (( from_boot )); then
|
|
||||||
echo "Boot-time authorization is enabled and takes effect after reboot."
|
|
||||||
fi
|
|
||||||
@@ -462,12 +462,6 @@ scrub_factory_accounts() {
|
|||||||
sanitize_factory_baseline() {
|
sanitize_factory_baseline() {
|
||||||
local factory="$1"
|
local factory="$1"
|
||||||
btrfs property set -ts "$factory" ro false
|
btrfs property set -ts "$factory" ro false
|
||||||
if [[ -d $factory/var/lib/omarchy/thunderbolt-authorization || -f $factory/etc/omarchy/thunderbolt-authorization.enabled || -f $factory/etc/omarchy/thunderbolt-authorization.pending ]]; then
|
|
||||||
if ! /usr/bin/omarchy-thunderbolt-authorization-admin reset-root "$factory"; then
|
|
||||||
btrfs property set -ts "$factory" ro true
|
|
||||||
fail "could not reset Thunderbolt authorization in the factory baseline"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! scrub_factory_accounts "$factory"; then
|
if ! scrub_factory_accounts "$factory"; then
|
||||||
btrfs property set -ts "$factory" ro true
|
btrfs property set -ts "$factory" ro true
|
||||||
@@ -493,14 +487,6 @@ stage_full_reset() {
|
|||||||
[[ -f $unit_src/omarchy-provision-owner.service && -x $next/usr/bin/omarchy-provision-owner ]] ||
|
[[ -f $unit_src/omarchy-provision-owner.service && -x $next/usr/bin/omarchy-provision-owner ]] ||
|
||||||
fail "the factory snapshot predates provisioning support; cannot reset from it"
|
fail "the factory snapshot predates provisioning support; cannot reset from it"
|
||||||
|
|
||||||
# A normal install's factory snapshot includes the original owner's USB
|
|
||||||
# policy and enabled daemon. Keep input usable during the new owner's setup;
|
|
||||||
# provisioning enrolls their devices before enabling USBGuard again.
|
|
||||||
if [[ -f $next/usr/lib/systemd/system/usbguard.service ]]; then
|
|
||||||
systemctl --root="$next" disable usbguard.service >>"$LOG_FILE" 2>&1 ||
|
|
||||||
fail "could not disable USBGuard in the factory system (see $LOG_FILE)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Scrubbing machine identity from the factory system"
|
log "Scrubbing machine identity from the factory system"
|
||||||
systemd-id128 new >"$next/etc/machine-id"
|
systemd-id128 new >"$next/etc/machine-id"
|
||||||
rm -f "$next"/etc/ssh/ssh_host_*
|
rm -f "$next"/etc/ssh/ssh_host_*
|
||||||
@@ -516,13 +502,6 @@ stage_full_reset() {
|
|||||||
scrub_factory_accounts "$next" ||
|
scrub_factory_accounts "$next" ||
|
||||||
fail "could not remove account credentials from the factory system (see $LOG_FILE)"
|
fail "could not remove account credentials from the factory system (see $LOG_FILE)"
|
||||||
|
|
||||||
# Leave the new owner's input available before interactive enrollment. Clear
|
|
||||||
# inherited accessory identities in both roots; never change the live daemon.
|
|
||||||
if [[ -d $next/var/lib/omarchy/thunderbolt-authorization || -f $next/etc/omarchy/thunderbolt-authorization.enabled || -f $next/etc/omarchy/thunderbolt-authorization.pending ]]; then
|
|
||||||
/usr/bin/omarchy-thunderbolt-authorization-admin reset-root "$next" ||
|
|
||||||
fail "could not reset Thunderbolt authorization in the staged root"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# @factory itself survives the wipe as the baseline for future resets. If it
|
# @factory itself survives the wipe as the baseline for future resets. If it
|
||||||
# came from a normal install it still holds the seller's account and
|
# came from a normal install it still holds the seller's account and
|
||||||
# /etc/shadow, which the new wheel user could mount and read — and a second
|
# /etc/shadow, which the new wheel user could mount and read — and a second
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/bash -p
|
|
||||||
|
|
||||||
# omarchy:summary=Manage system Thunderbolt authorization settings
|
|
||||||
# omarchy:hidden=true
|
|
||||||
|
|
||||||
# This root entrypoint uses installed code and rejects unsafe Bash startup.
|
|
||||||
if [[ $- != *p* ]]; then exit 126; fi
|
|
||||||
source /usr/bin/omarchy-security-functions || exit 126
|
|
||||||
omarchy_security_require_privileged_bash_startup || exit 126
|
|
||||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
|
||||||
set -euo pipefail
|
|
||||||
(( EUID == 0 )) || exit 126
|
|
||||||
export PATH=/usr/bin:/bin LC_ALL=C
|
|
||||||
unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS
|
|
||||||
source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh
|
|
||||||
source /usr/share/omarchy/install/helpers/thunderbolt-setup.sh
|
|
||||||
tb_admin "$@"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/bin/bash -p
|
|
||||||
|
|
||||||
# omarchy:summary=Approve one verified Thunderbolt connection
|
|
||||||
# omarchy:hidden=true
|
|
||||||
|
|
||||||
# pkexec permits this exact program for an active local wheel user. Suppress
|
|
||||||
# startup injection before reading input; it cannot dispatch general root work.
|
|
||||||
if [[ $- != *p* ]]; then exit 126; fi
|
|
||||||
source /usr/bin/omarchy-security-functions || exit 126
|
|
||||||
omarchy_security_require_privileged_bash_startup || exit 126
|
|
||||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
|
||||||
set -euo pipefail
|
|
||||||
(( EUID == 0 )) || exit 126
|
|
||||||
export PATH=/usr/bin:/bin LC_ALL=C
|
|
||||||
unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS
|
|
||||||
source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh
|
|
||||||
|
|
||||||
[[ $# == 2 && ( $1 == "true" || $1 == "false" ) ]] || exit 64
|
|
||||||
(( ${#2} <= 16384 )) || exit 64
|
|
||||||
jq -e 'type == "object"' <<< "$2" >/dev/null
|
|
||||||
exec 9> "$TB_LOCK"
|
|
||||||
flock -x 9
|
|
||||||
result=$(tb_approve "$2" "$1" true)
|
|
||||||
tb_json_write "$TB_RUNTIME/snapshot.json" 644 <<< "$result"
|
|
||||||
printf '%s\n' "$result"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:hidden=true
|
|
||||||
# omarchy:summary=Check whether Thunderbolt firmware boot access is restricted
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh"
|
|
||||||
tb_public_snapshot | jq -e .boot_protection >/dev/null
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
#!/bin/bash -p
|
|
||||||
|
|
||||||
# omarchy:summary=Restore trusted Thunderbolt devices and publish their status
|
|
||||||
# omarchy:hidden=true
|
|
||||||
|
|
||||||
# Keep root startup independent of a caller's environment or checkout.
|
|
||||||
if [[ $- != *p* ]]; then exit 126; fi
|
|
||||||
source /usr/bin/omarchy-security-functions || exit 126
|
|
||||||
omarchy_security_require_privileged_bash_startup || exit 126
|
|
||||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
|
||||||
set -euo pipefail
|
|
||||||
(( EUID == 0 )) || exit 126
|
|
||||||
export PATH=/usr/bin:/bin LC_ALL=C
|
|
||||||
unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS
|
|
||||||
source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh
|
|
||||||
tb_daemon
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:hidden=true
|
|
||||||
# omarchy:summary=Handle Thunderbolt device approval review
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh"
|
|
||||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh"
|
|
||||||
[[ $# == 1 ]] || exit 64
|
|
||||||
tb_review "$1"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:hidden=true
|
|
||||||
# omarchy:summary=Handle Thunderbolt device approval watch
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh"
|
|
||||||
source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh"
|
|
||||||
tb_watch
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/bin/bash -p
|
|
||||||
|
|
||||||
# omarchy:hidden=true
|
|
||||||
# omarchy:summary=Save portable trust for one verified USB device
|
|
||||||
|
|
||||||
# This narrow Polkit boundary accepts a live device snapshot, never a policy or
|
|
||||||
# file path. Privileged Bash suppresses startup code from the caller's environment.
|
|
||||||
if [[ $- != *p* ]]; then exit 126; fi
|
|
||||||
source /usr/bin/omarchy-security-functions || exit 126
|
|
||||||
omarchy_security_require_privileged_bash_startup || exit 126
|
|
||||||
omarchy_security_sanitize_bash_environment "$0" "$@"
|
|
||||||
set -euo pipefail
|
|
||||||
(( EUID == 0 )) || exit 126
|
|
||||||
export PATH=/usr/bin:/bin LC_ALL=C
|
|
||||||
unset USBGUARD_DAEMON_CONF USBGUARD_DEBUG
|
|
||||||
source /usr/share/omarchy/install/helpers/usb-authorization-policy.sh
|
|
||||||
|
|
||||||
(( $# == 2 )) || exit 64
|
|
||||||
# Omarchy persists to this file. Refuse custom daemon storage rather than
|
|
||||||
# mistaking a stale copy of a rule for a successful persistent decision.
|
|
||||||
rules_file=$(awk -F= '/^[[:space:]]*RuleFile[[:space:]]*=/ { value=$2; gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); print value; exit }' /etc/usbguard/usbguard-daemon.conf)
|
|
||||||
[[ $rules_file == "/etc/usbguard/rules.conf" ]] || exit 1
|
|
||||||
exec 8>/run/omarchy-usb-authorization.lock
|
|
||||||
flock -x 8
|
|
||||||
usb_authorization_permanent "$1" "$2"
|
|
||||||
@@ -1,827 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Manage USB authorization from the start of kernel boot
|
|
||||||
# omarchy:args=<enable|disable>
|
|
||||||
# omarchy:requires-sudo=true
|
|
||||||
# omarchy:hidden=true
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
drop_in=/etc/limine-entry-tool.d/usb-authorization.conf
|
|
||||||
setting='KERNEL_CMDLINE[default]+=" usbcore.authorized_default=0"'
|
|
||||||
limine_conf=/boot/limine.conf
|
|
||||||
limine_defaults=/etc/default/limine
|
|
||||||
boot_path=/boot
|
|
||||||
kernel_parameter=usbcore.authorized_default=0
|
|
||||||
snapshot_begin='# Omarchy USB authorization begin'
|
|
||||||
snapshot_setting='SNAPSHOT_KERNEL_PARAMETERS+=usbcore.authorized_default=0'
|
|
||||||
snapshot_end='# Omarchy USB authorization end'
|
|
||||||
|
|
||||||
usb_authorization_cmdline_has_parameter() {
|
|
||||||
local cmdline="$1" parameter="$2" word
|
|
||||||
|
|
||||||
for word in $cmdline; do
|
|
||||||
[[ $word == "$parameter" ]] && return 0
|
|
||||||
done
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_secure_boot_enabled() {
|
|
||||||
local value variable
|
|
||||||
|
|
||||||
for variable in /sys/firmware/efi/efivars/SecureBoot-*; do
|
|
||||||
[[ -r $variable ]] || continue
|
|
||||||
value=$(od -An -t u1 -j 4 -N 1 "$variable")
|
|
||||||
value=${value//[[:space:]]/}
|
|
||||||
[[ $value == "1" ]] && return 0
|
|
||||||
done
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_verify_boot_entries() {
|
|
||||||
local expected="$1" config="$2" esp="$3"
|
|
||||||
local machine_id="${4:-$(</etc/machine-id)}"
|
|
||||||
local secure_boot="${5:-auto}"
|
|
||||||
local actual_hash embedded_cmdline expected_hash image mode path paths section
|
|
||||||
|
|
||||||
if [[ $secure_boot == "auto" ]]; then
|
|
||||||
if usb_authorization_secure_boot_enabled; then
|
|
||||||
secure_boot=enabled
|
|
||||||
else
|
|
||||||
secure_boot=disabled
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -r $config ]]; then
|
|
||||||
echo "Cannot verify the generated Limine boot entries in $config." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
paths=$(mktemp /tmp/omarchy-usb-boot-paths.XXXXXXXXXX)
|
|
||||||
section=$(mktemp /tmp/omarchy-usb-boot-cmdline.XXXXXXXXXX)
|
|
||||||
|
|
||||||
if ! awk -v expected="$expected" -v machine_id="$machine_id" -v parameter="$kernel_parameter" '
|
|
||||||
function contains_parameter(value, count, fields, field_index) {
|
|
||||||
count = split(value, fields, /[[:space:]]+/)
|
|
||||||
for (field_index = 1; field_index <= count; field_index++) {
|
|
||||||
if (fields[field_index] == parameter) return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function finish_entry(found) {
|
|
||||||
if (!target_os || !is_kernel) return
|
|
||||||
entries++
|
|
||||||
|
|
||||||
if (has_cmdline) {
|
|
||||||
found = contains_parameter(cmdline)
|
|
||||||
if ((expected == "enabled" && !found) || (expected == "disabled" && found)) bad = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path == "") {
|
|
||||||
bad = 1
|
|
||||||
} else {
|
|
||||||
print (has_cmdline ? "external" : "embedded") "\t" path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/^\/[^/]/ {
|
|
||||||
finish_entry()
|
|
||||||
target_os = 0
|
|
||||||
is_kernel = 0
|
|
||||||
has_cmdline = 0
|
|
||||||
cmdline = ""
|
|
||||||
path = ""
|
|
||||||
next
|
|
||||||
}
|
|
||||||
|
|
||||||
/^[[:space:]]*comment:[[:space:]]*machine-id=/ {
|
|
||||||
target_os = index($0, "machine-id=" machine_id) > 0
|
|
||||||
next
|
|
||||||
}
|
|
||||||
|
|
||||||
/^[[:space:]]*\/\// {
|
|
||||||
finish_entry()
|
|
||||||
is_kernel = 0
|
|
||||||
has_cmdline = 0
|
|
||||||
cmdline = ""
|
|
||||||
path = ""
|
|
||||||
next
|
|
||||||
}
|
|
||||||
|
|
||||||
/^[[:space:]]*comment:[[:space:]]*kernel-id=/ {
|
|
||||||
is_kernel = target_os
|
|
||||||
next
|
|
||||||
}
|
|
||||||
|
|
||||||
is_kernel && /^[[:space:]]*path:[[:space:]]*/ {
|
|
||||||
path = $0
|
|
||||||
sub(/^[[:space:]]*path:[[:space:]]*/, "", path)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
|
|
||||||
is_kernel && /^[[:space:]]*cmdline:[[:space:]]*/ {
|
|
||||||
has_cmdline = 1
|
|
||||||
cmdline = $0
|
|
||||||
sub(/^[[:space:]]*cmdline:[[:space:]]*/, "", cmdline)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
|
|
||||||
END {
|
|
||||||
finish_entry()
|
|
||||||
if (entries == 0) bad = 1
|
|
||||||
exit bad
|
|
||||||
}
|
|
||||||
' "$config" >"$paths"; then
|
|
||||||
rm -f "$paths" "$section"
|
|
||||||
echo "The generated Limine kernel entries do not all have USB authorization $expected." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
while IFS=$'\t' read -r mode path; do
|
|
||||||
if [[ $path != 'boot():/'* ]]; then
|
|
||||||
rm -f "$paths" "$section"
|
|
||||||
echo "Cannot verify the kernel image referenced by Limine: $path" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
image="$esp/${path#'boot():/'}"
|
|
||||||
image=${image%%#*}
|
|
||||||
if [[ ! -r $image ]]; then
|
|
||||||
rm -f "$paths" "$section"
|
|
||||||
echo "Cannot verify the kernel image referenced by Limine: $image" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $path == *'#'* ]]; then
|
|
||||||
expected_hash=${path##*#}
|
|
||||||
actual_hash=$(b2sum "$image")
|
|
||||||
actual_hash=${actual_hash%% *}
|
|
||||||
if [[ $actual_hash != "$expected_hash" ]]; then
|
|
||||||
rm -f "$paths" "$section"
|
|
||||||
echo "The verification hash for $image does not match the generated Limine entry." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
: >"$section"
|
|
||||||
if ! objcopy -O binary --only-section=.cmdline "$image" "$section" 2>/dev/null; then
|
|
||||||
[[ $mode == "external" ]] && continue
|
|
||||||
rm -f "$paths" "$section"
|
|
||||||
echo "Cannot inspect the embedded kernel command line in $image." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
embedded_cmdline=$(tr '\0' '\n' <"$section")
|
|
||||||
|
|
||||||
# Without Secure Boot, Limine's external command line takes precedence.
|
|
||||||
# Under Secure Boot, systemd-stub uses a nonempty embedded .cmdline.
|
|
||||||
if [[ $mode == "external" && $secure_boot == "disabled" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A signed UKI can ignore Limine's external cmdline and use its embedded
|
|
||||||
# .cmdline instead. If one exists, both representations must agree.
|
|
||||||
if [[ -z $embedded_cmdline ]]; then
|
|
||||||
if [[ $mode == "external" || $expected == "disabled" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
rm -f "$paths" "$section"
|
|
||||||
echo "The generated kernel image $image has no verifiable kernel command line." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ $expected == "enabled" ]]; then
|
|
||||||
if ! usb_authorization_cmdline_has_parameter "$embedded_cmdline" "$kernel_parameter"; then
|
|
||||||
rm -f "$paths" "$section"
|
|
||||||
echo "The generated kernel image $image does not enable boot-time USB authorization." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
elif usb_authorization_cmdline_has_parameter "$embedded_cmdline" "$kernel_parameter"; then
|
|
||||||
rm -f "$paths" "$section"
|
|
||||||
echo "The generated kernel image $image still enables boot-time USB authorization." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
done <"$paths"
|
|
||||||
|
|
||||||
rm -f "$paths" "$section"
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_rebuild_and_verify() {
|
|
||||||
local expected="$1" config="$2" esp="$3"
|
|
||||||
local machine_id="${4:-$(</etc/machine-id)}" secure_boot="${5:-auto}"
|
|
||||||
|
|
||||||
if ! limine-mkinitcpio; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# limine-mkinitcpio-hook can swallow an individual kernel build failure and
|
|
||||||
# still return success, so its exit status alone is not a safety boundary.
|
|
||||||
usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id" "$secure_boot"
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_snapshot_block_status() {
|
|
||||||
local config="$1"
|
|
||||||
|
|
||||||
[[ -r $config ]] || return 2
|
|
||||||
awk -v begin="$snapshot_begin" -v setting="$snapshot_setting" -v end="$snapshot_end" '
|
|
||||||
$0 == begin { begin_count++; state = 1; next }
|
|
||||||
$0 == setting { setting_count++; if (state == 1) state = 2; next }
|
|
||||||
$0 == end { end_count++; if (state == 2) valid_count++; state = 0; next }
|
|
||||||
{ state = 0 }
|
|
||||||
END {
|
|
||||||
if (begin_count == 0 && setting_count == 0 && end_count == 0) exit 1
|
|
||||||
if (begin_count == 1 && setting_count == 1 && end_count == 1 && valid_count == 1) exit 0
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
' "$config"
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_enable_snapshot_setting() {
|
|
||||||
local config="$1" status temporary
|
|
||||||
|
|
||||||
if usb_authorization_snapshot_block_status "$config"; then
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
status=$?
|
|
||||||
fi
|
|
||||||
if (( status != 1 )); then
|
|
||||||
echo "Refusing to modify the USB authorization block in $config." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
temporary=$(mktemp "${config}.XXXXXXXXXX")
|
|
||||||
cp --preserve=mode,ownership "$config" "$temporary"
|
|
||||||
{
|
|
||||||
cat "$config"
|
|
||||||
printf '\n%s\n%s\n%s\n' "$snapshot_begin" "$snapshot_setting" "$snapshot_end"
|
|
||||||
} >"$temporary"
|
|
||||||
mv -f "$temporary" "$config"
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_disable_snapshot_setting() {
|
|
||||||
local config="$1" status temporary
|
|
||||||
|
|
||||||
if usb_authorization_snapshot_block_status "$config"; then
|
|
||||||
status=0
|
|
||||||
else
|
|
||||||
status=$?
|
|
||||||
fi
|
|
||||||
(( status == 1 )) && return 0
|
|
||||||
if (( status != 0 )); then
|
|
||||||
echo "Refusing to modify the USB authorization block in $config." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
temporary=$(mktemp "${config}.XXXXXXXXXX")
|
|
||||||
cp --preserve=mode,ownership "$config" "$temporary"
|
|
||||||
awk -v begin="$snapshot_begin" '
|
|
||||||
$0 == begin { skip = 2; next }
|
|
||||||
skip > 0 { skip--; next }
|
|
||||||
{ print }
|
|
||||||
' "$config" >"$temporary"
|
|
||||||
mv -f "$temporary" "$config"
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_rewrite_boot_cmdlines() {
|
|
||||||
local expected="$1" config="$2" machine_id="${3:-$(</etc/machine-id)}"
|
|
||||||
local backup temporary
|
|
||||||
|
|
||||||
backup=$(mktemp "${config}.omarchy-usb-backup.XXXXXXXXXX")
|
|
||||||
temporary=$(mktemp "${config}.omarchy-usb-new.XXXXXXXXXX")
|
|
||||||
cp --preserve=mode,ownership "$config" "$backup"
|
|
||||||
cp --preserve=mode,ownership "$config" "$temporary"
|
|
||||||
|
|
||||||
if ! awk -v expected="$expected" -v machine_id="$machine_id" -v parameter="$kernel_parameter" '
|
|
||||||
function rewrite_cmdline(line, prefix, value, count, fields, field_index, result, found) {
|
|
||||||
prefix = line
|
|
||||||
sub(/cmdline:.*/, "cmdline:", prefix)
|
|
||||||
value = line
|
|
||||||
sub(/^[[:space:]]*cmdline:[[:space:]]*/, "", value)
|
|
||||||
count = split(value, fields, /[[:space:]]+/)
|
|
||||||
result = ""
|
|
||||||
found = 0
|
|
||||||
for (field_index = 1; field_index <= count; field_index++) {
|
|
||||||
if (fields[field_index] == parameter) {
|
|
||||||
found = 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (fields[field_index] != "") result = result (result == "" ? "" : " ") fields[field_index]
|
|
||||||
}
|
|
||||||
if (expected == "enabled") result = result (result == "" ? "" : " ") parameter
|
|
||||||
return prefix (result == "" ? "" : " ") result
|
|
||||||
}
|
|
||||||
|
|
||||||
/^\/[^/]/ { target_os = 0 }
|
|
||||||
/^[[:space:]]*comment:[[:space:]]*machine-id=/ {
|
|
||||||
target_os = index($0, "machine-id=" machine_id) > 0
|
|
||||||
}
|
|
||||||
target_os && /^[[:space:]]*cmdline:[[:space:]]*/ {
|
|
||||||
print rewrite_cmdline($0)
|
|
||||||
next
|
|
||||||
}
|
|
||||||
{ print }
|
|
||||||
' "$config" >"$temporary"; then
|
|
||||||
rm -f "$backup" "$temporary"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mv -f "$temporary" "$config"
|
|
||||||
if ! usb_authorization_enroll_config "$config" "${config%/*}"; then
|
|
||||||
mv -f "$backup" "$config"
|
|
||||||
if ! usb_authorization_secure_boot_enabled; then
|
|
||||||
limine-enroll-config >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
echo "Restored $config because its bootloader verification data could not be updated." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "$backup"
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_rewrite_snapshot_manifest() {
|
|
||||||
local expected="$1" manifest="$2" temporary
|
|
||||||
|
|
||||||
[[ -e $manifest ]] || return 0
|
|
||||||
temporary=$(mktemp "${manifest}.omarchy-usb.XXXXXXXXXX")
|
|
||||||
if ! cp --preserve=mode,ownership "$manifest" "$temporary"; then
|
|
||||||
rm -f "$temporary"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Limine uses the cached lines when present, and structured command lines
|
|
||||||
# otherwise. Update both, including nested kernels and restore entries.
|
|
||||||
if ! jq --arg expected "$expected" --arg parameter "$kernel_parameter" '
|
|
||||||
def cmdline:
|
|
||||||
if type != "string" then error("Invalid snapshot command line") else
|
|
||||||
[splits("[[:space:]]+") | select(length > 0 and . != $parameter)]
|
|
||||||
| if $expected == "enabled" then . + [$parameter] else . end
|
|
||||||
| join(" ")
|
|
||||||
end;
|
|
||||||
def lines:
|
|
||||||
map(if test("^\\s*(cmdline|kernel_cmdline):"; "i") then
|
|
||||||
capture("^(?<prefix>\\s*(?:cmdline|kernel_cmdline):\\s*)(?<value>.*)$"; "i")
|
|
||||||
| .prefix + (.value | cmdline)
|
|
||||||
else . end);
|
|
||||||
def kernel:
|
|
||||||
.cmdlineDetails |= map(.cmdline |= cmdline | .snapshotCmdline |= cmdline)
|
|
||||||
| .allInConfig |= lines
|
|
||||||
| .allInSnapshotConfig |= lines
|
|
||||||
| .subKernels |= map(kernel);
|
|
||||||
if .jsonFormatVersion != "1.3.0" or (.snapshotEntries | type) != "array" then
|
|
||||||
error("Unsupported Limine snapshot manifest format")
|
|
||||||
else
|
|
||||||
.snapshotEntries |= map(.kernelEntries |= map(kernel))
|
|
||||||
end
|
|
||||||
' "$manifest" >"$temporary"; then
|
|
||||||
rm -f "$temporary"
|
|
||||||
echo "Cannot update stored USB boot policy in $manifest." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mv -f "$temporary" "$manifest"
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_verify_signature() {
|
|
||||||
# sbctl verify can return zero for unsigned or unreadable files. Check its
|
|
||||||
# structured result, including the exact path, instead of just the exit code.
|
|
||||||
if ! sbctl verify --json "$1" | jq -e --arg file "$1" \
|
|
||||||
'length == 1 and .[0].file_name == $file and .[0].is_signed == 1' >/dev/null; then
|
|
||||||
echo "Cannot verify the Secure Boot signature of $1 using this machine's signing keys." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read the same architecture and enrollment setting as the installed Limine tool.
|
|
||||||
usb_authorization_limine_settings() (
|
|
||||||
source /usr/lib/limine/limine-common-functions
|
|
||||||
load_config || return 1
|
|
||||||
printf '%s %s\n' "$(limine_efi_arch | tr '[:upper:]' '[:lower:]')" "${ENABLE_ENROLL_LIMINE_CONFIG:-no}"
|
|
||||||
)
|
|
||||||
|
|
||||||
usb_authorization_verify_bootloader() (
|
|
||||||
local config="$1" esp="$2" architecture enrollment image check digest
|
|
||||||
read -r architecture enrollment < <(usb_authorization_limine_settings) || return 1
|
|
||||||
[[ -n $architecture ]] || return 1
|
|
||||||
image="$esp/EFI/limine/limine_$architecture.efi"
|
|
||||||
usb_authorization_verify_signature "$image" || return 1
|
|
||||||
check=$(mktemp) || return 1
|
|
||||||
trap 'rm -f "$check"' EXIT
|
|
||||||
cp "$image" "$check" || return 1
|
|
||||||
if [[ $enrollment == "yes" ]]; then
|
|
||||||
digest=$(b2sum "$config") || return 1
|
|
||||||
limine enroll-config "$check" "${digest%% *}" --quiet || return 1
|
|
||||||
else
|
|
||||||
limine enroll-config "$check" --reset --quiet || return 1
|
|
||||||
fi
|
|
||||||
# Enrollment edits fixed bytes in the executable. Reapplying the expected
|
|
||||||
# value must be a no-op, including when enrollment is explicitly disabled.
|
|
||||||
if ! cmp -s "$image" "$check"; then
|
|
||||||
echo "Limine's signed executable does not match the configured menu enrollment." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
)
|
|
||||||
|
|
||||||
usb_authorization_enroll_config() {
|
|
||||||
limine-enroll-config || return 1
|
|
||||||
if usb_authorization_secure_boot_enabled; then
|
|
||||||
usb_authorization_verify_bootloader "$1" "$2" || return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Kernel rebuild hooks also reset and re-sign Limine. Checkpoint the complete
|
|
||||||
# boot files, not just the menu: rebuilding can overwrite images it references.
|
|
||||||
# The outer process holds the real boot lock; the private mount namespace gives
|
|
||||||
# nested Limine tools their own lock inode so they cannot deadlock against it.
|
|
||||||
usb_authorization_boot_transaction() (
|
|
||||||
local action="$1" state architecture enrollment index manifest changed=0 committed=0 restored=1
|
|
||||||
local machine_id="$(</etc/machine-id)"
|
|
||||||
local cache="${2:-/var/cache/boot/$machine_id}"
|
|
||||||
local -a paths=("$boot_path" "$cache" "$limine_defaults" "$drop_in")
|
|
||||||
exec 8>/run/lock/boot-partition.lock
|
|
||||||
flock --timeout 30 8 || return 1
|
|
||||||
read -r architecture enrollment < <(usb_authorization_limine_settings) || return 1
|
|
||||||
[[ -n $architecture ]] || return 1
|
|
||||||
usb_authorization_verify_signature "$boot_path/EFI/limine/limine_$architecture.efi" || return 1
|
|
||||||
state=$(mktemp -d /var/tmp/omarchy-usb-boot.XXXXXXXXXX) || return 1
|
|
||||||
trap '
|
|
||||||
if (( changed && ! committed )); then
|
|
||||||
echo "USB boot policy failed; restoring the previous boot files and settings." >&2
|
|
||||||
for ((index=0; index<${#paths[@]}; index++)); do
|
|
||||||
if [[ -d $state/$index && ! -L $state/$index ]]; then
|
|
||||||
mkdir -p "${paths[index]}" && cp -a "$state/$index/." "${paths[index]}/" || restored=0
|
|
||||||
elif [[ -e $state/$index || -L $state/$index ]]; then
|
|
||||||
cp -a --remove-destination "$state/$index" "${paths[index]}" || restored=0
|
|
||||||
else
|
|
||||||
rm -rf -- "${paths[index]}" || restored=0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
# New unreferenced images are harmless, but a newly created cached backup
|
|
||||||
# manifest must not reintroduce the failed policy on a later recovery.
|
|
||||||
for manifest in snapshots.json snapshots.json.old; do
|
|
||||||
[[ -e $state/0/$machine_id/limine_history/$manifest ]] ||
|
|
||||||
rm -f "$boot_path/$machine_id/limine_history/$manifest" || restored=0
|
|
||||||
[[ -e $state/1/lss/$manifest ]] || rm -f "$cache/lss/$manifest" || restored=0
|
|
||||||
done
|
|
||||||
sync -f "$boot_path" || restored=0
|
|
||||||
fi
|
|
||||||
if (( restored )); then
|
|
||||||
rm -rf "$state"
|
|
||||||
else
|
|
||||||
echo "Boot recovery was incomplete. Preserve and restore the checkpoint at $state before rebooting." >&2
|
|
||||||
fi
|
|
||||||
' EXIT
|
|
||||||
trap 'exit 1' HUP INT TERM
|
|
||||||
for ((index=0; index<${#paths[@]}; index++)); do
|
|
||||||
if [[ -d ${paths[index]} ]]; then
|
|
||||||
cp -a "${paths[index]}/." "$state/$index" || return 1
|
|
||||||
elif [[ -e ${paths[index]} || -L ${paths[index]} ]]; then
|
|
||||||
cp -a "${paths[index]}" "$state/$index" || return 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
: >"$state/lock"
|
|
||||||
changed=1
|
|
||||||
usb_authorization_run_transaction "$state" "$action" || return 1
|
|
||||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path" || return 1
|
|
||||||
sync -f "$boot_path" || return 1
|
|
||||||
committed=1
|
|
||||||
)
|
|
||||||
|
|
||||||
usb_authorization_run_transaction() {
|
|
||||||
unshare --mount --propagation private /bin/bash -c '
|
|
||||||
mount --bind "$1/lock" /run/lock/boot-partition.lock || exit 1
|
|
||||||
source "$2"
|
|
||||||
usb_authorization_main "$3"
|
|
||||||
' bash "$1" "${BASH_SOURCE[0]}" "$2"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Called with Limine's boot-partition lock held. Prepare signed copies of
|
|
||||||
# conflicting historical UKIs before changing the selected boot policy.
|
|
||||||
usb_authorization_reconcile_archived_images() (
|
|
||||||
local expected="$1" config="$2" esp="$3" machine_id="$4"
|
|
||||||
local cache="${5:-/var/cache/boot/$machine_id/lss/snapshots.json}"
|
|
||||||
local history="$esp/$machine_id/limine_history" staging manifest names name image
|
|
||||||
local cmdline hash_command old_digest new_digest old_hash new_hash replacement index=0
|
|
||||||
local published=0 committed=0 section layout image_base image_size alignment address
|
|
||||||
local -a manifests=() originals=()
|
|
||||||
|
|
||||||
usb_authorization_secure_boot_enabled || return 0
|
|
||||||
[[ -d $history ]] || return 0
|
|
||||||
staging=$(mktemp -d "$esp/.omarchy-usb-uki.XXXXXXXXXX") || return 1
|
|
||||||
trap '
|
|
||||||
if (( published && ! committed )); then
|
|
||||||
for ((index=0; index<${#originals[@]}; index++)); do
|
|
||||||
cp --preserve=mode,ownership "$staging/original.$index" "${originals[index]}" || true
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
rm -rf "$staging"
|
|
||||||
' EXIT
|
|
||||||
|
|
||||||
for manifest in "$history/snapshots.json" "$history/snapshots.json.old" "$cache"; do
|
|
||||||
[[ -f $manifest ]] || continue
|
|
||||||
jq -e '.jsonFormatVersion == "1.3.0" and (.snapshotEntries | type == "array")' "$manifest" >/dev/null || return 1
|
|
||||||
manifests+=("$manifest")
|
|
||||||
done
|
|
||||||
(( ${#manifests[@]} )) || return 0
|
|
||||||
names=$(jq -sr '[.[] | .snapshotEntries[] | .. | objects | .imageDetails? // empty | .[] | .fileHashName] | unique[]' "${manifests[@]}") || return 1
|
|
||||||
printf '[]\n' >"$staging/replacements.json"
|
|
||||||
|
|
||||||
while IFS= read -r name; do
|
|
||||||
[[ -n $name ]] || continue
|
|
||||||
# Only image names, never arbitrary paths supplied by a manifest.
|
|
||||||
if [[ $name == */* || $name == "." || $name == ".." ]]; then
|
|
||||||
echo "Invalid archived kernel image name: $name" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
image="$history/$name"
|
|
||||||
[[ -f $image && ! -L $image ]] || continue
|
|
||||||
: >"$staging/cmdline"
|
|
||||||
objcopy -O binary --only-section=.cmdline "$image" "$staging/cmdline" 2>/dev/null || continue
|
|
||||||
cmdline=$(tr '\0' '\n' <"$staging/cmdline")
|
|
||||||
[[ -n $cmdline ]] || continue
|
|
||||||
if usb_authorization_cmdline_has_parameter "$cmdline" "$kernel_parameter"; then
|
|
||||||
[[ $expected == "disabled" ]] || continue
|
|
||||||
else
|
|
||||||
[[ $expected == "enabled" ]] || continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Multi-profile images and signed PCR policies need their original builder.
|
|
||||||
# Do not silently invalidate their additional boot/unlock guarantees.
|
|
||||||
for section in .profile .pcrsig; do
|
|
||||||
objcopy -O binary --only-section="$section" "$image" "$staging/section" || return 1
|
|
||||||
if [[ -s $staging/section ]]; then
|
|
||||||
echo "Rebuild archived image $image with its original signing policy before changing USB boot protection ($section)." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [[ ! $name =~ ^(.+)_(sha1|sha256|b3|xxh)_([0-9a-f]+)$ ]]; then
|
|
||||||
echo "Cannot verify the content hash of archived image $image." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
case "${BASH_REMATCH[2]}" in
|
|
||||||
sha1) hash_command=sha1sum ;;
|
|
||||||
sha256) hash_command=sha256sum ;;
|
|
||||||
b3) hash_command=b3sum ;;
|
|
||||||
xxh) hash_command=xxhsum ;;
|
|
||||||
esac
|
|
||||||
old_digest=${BASH_REMATCH[3]}
|
|
||||||
new_digest=$("$hash_command" "$image") || return 1
|
|
||||||
if [[ ${new_digest%% *} != "$old_digest" ]]; then
|
|
||||||
echo "Archived image hash mismatch: $image" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# Verify the old signature before signing a modified copy with this machine's key.
|
|
||||||
usb_authorization_verify_signature "$image" || return 1
|
|
||||||
cp "$image" "$staging/unsigned.efi" || return 1
|
|
||||||
sbattach --remove "$staging/unsigned.efi" || return 1
|
|
||||||
# Preserve the embedded boot arguments and change only our exact USB
|
|
||||||
# parameter. Do not turn a signed command line into an external override.
|
|
||||||
printf '%s' "$cmdline" | jq -Rrs --arg parameter "$kernel_parameter" --arg expected "$expected" '
|
|
||||||
[scan("[^[:space:]]+|[[:space:]]+") | select(. != $parameter)]
|
|
||||||
| join("") | sub("[[:space:]]+$"; "")
|
|
||||||
| if $expected == "enabled" then . + " " + $parameter else . end
|
|
||||||
' >"$staging/new-cmdline" || return 1
|
|
||||||
printf '\0' >>"$staging/new-cmdline"
|
|
||||||
# objcopy --update-section retains PE VirtualSize and can truncate a longer
|
|
||||||
# command line. Add a fresh section after the existing virtual image instead.
|
|
||||||
layout=$(objdump -p "$staging/unsigned.efi" | awk '
|
|
||||||
$1 == "ImageBase" { base = $2 }
|
|
||||||
$1 == "SizeOfImage" { size = $2 }
|
|
||||||
$1 == "SectionAlignment" { alignment = $2 }
|
|
||||||
END {
|
|
||||||
if (base !~ /^[0-9a-fA-F]+$/ || size !~ /^[0-9a-fA-F]+$/ || alignment !~ /^[0-9a-fA-F]+$/) exit 1
|
|
||||||
print "0x" base, "0x" size, "0x" alignment
|
|
||||||
}
|
|
||||||
') || return 1
|
|
||||||
read -r image_base image_size alignment <<<"$layout"
|
|
||||||
(( alignment > 0 )) || return 1
|
|
||||||
address=$((image_base + (image_size + alignment - 1) / alignment * alignment))
|
|
||||||
objcopy --remove-section=.cmdline "$staging/unsigned.efi" "$staging/without-cmdline.efi" || return 1
|
|
||||||
objcopy --add-section .cmdline="$staging/new-cmdline" \
|
|
||||||
--change-section-vma .cmdline="$address" \
|
|
||||||
--set-section-flags .cmdline=alloc,load,readonly,data,contents \
|
|
||||||
"$staging/without-cmdline.efi" "$staging/image.efi" || return 1
|
|
||||||
sbctl sign "$staging/image.efi" || return 1
|
|
||||||
usb_authorization_verify_signature "$staging/image.efi" || return 1
|
|
||||||
objcopy -O binary --only-section=.cmdline "$staging/image.efi" "$staging/cmdline" || return 1
|
|
||||||
cmp -s "$staging/cmdline" "$staging/new-cmdline" || return 1
|
|
||||||
new_digest=$("$hash_command" "$staging/image.efi") || return 1
|
|
||||||
replacement="${name%"$old_digest"}${new_digest%% *}"
|
|
||||||
old_hash=$(b2sum "$image") || return 1
|
|
||||||
new_hash=$(b2sum "$staging/image.efi") || return 1
|
|
||||||
# Publish copies first: every old reference remains usable until its manifest changes.
|
|
||||||
if [[ -e $history/$replacement || -L $history/$replacement ]]; then
|
|
||||||
[[ ! -L $history/$replacement ]] && cmp -s "$staging/image.efi" "$history/$replacement" || return 1
|
|
||||||
else
|
|
||||||
mv "$staging/image.efi" "$history/$replacement" || return 1
|
|
||||||
fi
|
|
||||||
jq --arg old "$name" --arg new "$replacement" \
|
|
||||||
--arg old_hash "${old_hash%% *}" --arg new_hash "${new_hash%% *}" \
|
|
||||||
'. + [{old: $old, new: $new, old_hash: $old_hash, new_hash: $new_hash}]' \
|
|
||||||
"$staging/replacements.json" >"$staging/next.json" || return 1
|
|
||||||
mv "$staging/next.json" "$staging/replacements.json" || return 1
|
|
||||||
done <<<"$names"
|
|
||||||
jq -e 'length > 0' "$staging/replacements.json" >/dev/null || return 0
|
|
||||||
|
|
||||||
originals=("$config" "${manifests[@]}")
|
|
||||||
for ((index=0; index<${#originals[@]}; index++)); do
|
|
||||||
cp --preserve=mode,ownership "${originals[index]}" "$staging/original.$index" || return 1
|
|
||||||
cp --preserve=mode,ownership "${originals[index]}" "$staging/updated.$index" || return 1
|
|
||||||
# Update cached menu lines, structured image metadata, restore paths and
|
|
||||||
# both hashes. The new file name remains a valid Limine content address.
|
|
||||||
if (( index == 0 )); then
|
|
||||||
jq -Rrs --slurpfile replacements "$staging/replacements.json" '
|
|
||||||
split("\n") | map(reduce $replacements[0][] as $r (. ;
|
|
||||||
if contains($r.old) then split($r.old) | join($r.new) | split($r.old_hash) | join($r.new_hash)
|
|
||||||
else . end)) | join("\n")
|
|
||||||
' "$staging/original.$index" >"$staging/updated.$index" || return 1
|
|
||||||
else
|
|
||||||
jq --slurpfile replacements "$staging/replacements.json" '
|
|
||||||
walk(if type == "string" then
|
|
||||||
reduce $replacements[0][] as $r (. ; split($r.old) | join($r.new) | split($r.old_hash) | join($r.new_hash))
|
|
||||||
else . end)
|
|
||||||
' "$staging/original.$index" >"$staging/updated.$index" || return 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
published=1
|
|
||||||
for ((index=0; index<${#originals[@]}; index++)); do
|
|
||||||
mv -f "$staging/updated.$index" "${originals[index]}" || return 1
|
|
||||||
done
|
|
||||||
usb_authorization_enroll_config "$config" "$esp" || return 1
|
|
||||||
committed=1
|
|
||||||
)
|
|
||||||
|
|
||||||
usb_authorization_prepare_archived_images() (
|
|
||||||
local expected="$1" config="$2" esp="$3" machine_id="${4:-$(</etc/machine-id)}"
|
|
||||||
|
|
||||||
usb_authorization_secure_boot_enabled || return 0
|
|
||||||
# Package hooks acquire the boot lock too; install this optional tool before
|
|
||||||
# taking it, never while rewriting archived images.
|
|
||||||
if omarchy-cmd-missing sbattach; then
|
|
||||||
omarchy-pkg-add sbsigntools || return 1
|
|
||||||
fi
|
|
||||||
exec 9>/run/lock/boot-partition.lock
|
|
||||||
flock --timeout 30 9 || return 1
|
|
||||||
limine-snapper-sync --no-mutex --no-hooks || return 1
|
|
||||||
usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine_id"
|
|
||||||
)
|
|
||||||
|
|
||||||
usb_authorization_sync_and_verify() (
|
|
||||||
local expected="$1" config="$2" esp="$3"
|
|
||||||
local machine_id="${4:-$(</etc/machine-id)}" manifest
|
|
||||||
|
|
||||||
# Share Limine's lock so a concurrent watcher cannot restore an old manifest
|
|
||||||
# while we update it. The sync wrapper must not reacquire our lock.
|
|
||||||
exec 9>/run/lock/boot-partition.lock
|
|
||||||
flock --timeout 30 9 || return 1
|
|
||||||
|
|
||||||
# Recover/upgrade the manifest first, including backups if necessary.
|
|
||||||
limine-snapper-sync --no-mutex --no-hooks || return 1
|
|
||||||
usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine_id" || return 1
|
|
||||||
for manifest in \
|
|
||||||
"$esp/$machine_id/limine_history/snapshots.json" \
|
|
||||||
"$esp/$machine_id/limine_history/snapshots.json.old" \
|
|
||||||
"/var/cache/boot/$machine_id/lss/snapshots.json"; do
|
|
||||||
usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest" || return 1
|
|
||||||
done
|
|
||||||
|
|
||||||
usb_authorization_rewrite_boot_cmdlines "$expected" "$config" "$machine_id" || return 1
|
|
||||||
# Verify the regenerated menu, so synchronization cannot mask stale stored
|
|
||||||
# command lines with a one-time edit to limine.conf.
|
|
||||||
limine-snapper-sync --no-mutex --no-hooks || return 1
|
|
||||||
usb_authorization_enroll_config "$config" "$esp" || return 1
|
|
||||||
usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id"
|
|
||||||
)
|
|
||||||
|
|
||||||
# A Mac's boot chain belongs to its boot package, so boot-time denial is not
|
|
||||||
# managed here. Disabling what is not configured succeeds, so turning USB
|
|
||||||
# authorization off still works; anything configured by another route is left
|
|
||||||
# to the owner, since this cannot rebuild a Mac's boot files.
|
|
||||||
usb_authorization_apple_boot() {
|
|
||||||
if [[ ${1:-} == "enable" ]]; then
|
|
||||||
echo "USB authorization from boot is not available on Apple Silicon yet." >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
if [[ -e $drop_in ]] || grep -Fqx "$snapshot_begin" "$limine_defaults" 2>/dev/null; then
|
|
||||||
echo "Boot-time USB authorization is configured in $drop_in or $limine_defaults, which this command cannot remove on Apple Silicon." >&2
|
|
||||||
echo "Remove it by hand: delete $drop_in and the lines from '$snapshot_begin' to '$snapshot_end' in $limine_defaults, then rebuild the boot files." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_main() {
|
|
||||||
local had_drop_in=0 had_snapshot_setting=0 snapshot_status temporary
|
|
||||||
|
|
||||||
if (( EUID != 0 )); then
|
|
||||||
echo "Error: omarchy-usb-authorization-boot must run as root" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
enable)
|
|
||||||
if usb_authorization_snapshot_block_status "$limine_defaults"; then
|
|
||||||
snapshot_status=0
|
|
||||||
else
|
|
||||||
snapshot_status=$?
|
|
||||||
fi
|
|
||||||
if (( snapshot_status == 2 )); then
|
|
||||||
echo "Refusing to modify the USB authorization block in $limine_defaults." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Quote the expected value because the Limine key contains glob brackets.
|
|
||||||
if [[ -e $drop_in ]] && [[ $(<"$drop_in") != "$setting" ]]; then
|
|
||||||
echo "Refusing to replace the existing $drop_in." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
usb_authorization_prepare_archived_images enabled "$limine_conf" "$boot_path" || exit 1
|
|
||||||
usb_authorization_enable_snapshot_setting "$limine_defaults"
|
|
||||||
|
|
||||||
if [[ ! -e $drop_in ]]; then
|
|
||||||
install -d -m755 "${drop_in%/*}"
|
|
||||||
temporary=$(mktemp "${drop_in}.XXXXXXXXXX")
|
|
||||||
trap 'rm -f "$temporary"' EXIT
|
|
||||||
printf '%s\n' "$setting" >"$temporary"
|
|
||||||
chmod 644 "$temporary"
|
|
||||||
mv -f "$temporary" "$drop_in"
|
|
||||||
trap - EXIT
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! limine-mkinitcpio ||
|
|
||||||
! usb_authorization_sync_and_verify enabled "$limine_conf" "$boot_path"; then
|
|
||||||
echo "Boot-time USB authorization remains configured in $drop_in; rerun this command after fixing the boot-image error." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
disable)
|
|
||||||
if usb_authorization_snapshot_block_status "$limine_defaults"; then
|
|
||||||
had_snapshot_setting=1
|
|
||||||
else
|
|
||||||
snapshot_status=$?
|
|
||||||
if (( snapshot_status == 2 )); then
|
|
||||||
echo "Refusing to modify the USB authorization block in $limine_defaults." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -e $drop_in ]]; then
|
|
||||||
if [[ $(<"$drop_in") != "$setting" ]]; then
|
|
||||||
echo "Refusing to remove the modified $drop_in." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
had_drop_in=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
usb_authorization_prepare_archived_images disabled "$limine_conf" "$boot_path" || exit 1
|
|
||||||
if (( had_drop_in )); then
|
|
||||||
rm -f "$drop_in"
|
|
||||||
fi
|
|
||||||
|
|
||||||
usb_authorization_disable_snapshot_setting "$limine_defaults"
|
|
||||||
if ! limine-mkinitcpio ||
|
|
||||||
! usb_authorization_sync_and_verify disabled "$limine_conf" "$boot_path"; then
|
|
||||||
if (( had_drop_in )); then
|
|
||||||
printf '%s\n' "$setting" >"$drop_in"
|
|
||||||
chmod 644 "$drop_in"
|
|
||||||
fi
|
|
||||||
if (( had_snapshot_setting )); then
|
|
||||||
usb_authorization_enable_snapshot_setting "$limine_defaults"
|
|
||||||
fi
|
|
||||||
echo "USBGuard remains required because the boot images could not be verified as default-allow." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: omarchy-usb-authorization-boot <enable|disable>" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
if (( EUID != 0 )); then
|
|
||||||
echo "Error: omarchy-usb-authorization-boot must run as root" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
case "${1:-}" in
|
|
||||||
enable|disable)
|
|
||||||
if /usr/bin/omarchy-hw-aarch64-apple; then
|
|
||||||
usb_authorization_apple_boot "$1"
|
|
||||||
elif usb_authorization_secure_boot_enabled; then
|
|
||||||
usb_authorization_boot_transaction "$1"
|
|
||||||
else
|
|
||||||
usb_authorization_main "$1"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: omarchy-usb-authorization-boot <enable|disable>" >&2
|
|
||||||
return 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
|
||||||
main "$@"
|
|
||||||
fi
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:hidden=true
|
|
||||||
# omarchy:summary=Create an approval request for a blocked USB device
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
case "${USBGUARD_IPC_SIGNAL:-}" in
|
|
||||||
IPC.Connected) ;;
|
|
||||||
Device.PolicyApplied)
|
|
||||||
# PresenceChanged precedes policy evaluation, even for trusted devices.
|
|
||||||
[[ ${USBGUARD_DEVICE_TARGET_NEW:-} == "block" ]] || exit 0
|
|
||||||
;;
|
|
||||||
Device.PresenceChanged)
|
|
||||||
[[ ${USBGUARD_DEVICE_EVENT:-} == "Remove" ]] || exit 0
|
|
||||||
;;
|
|
||||||
*) exit 0 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
requests="$HOME/.local/state/omarchy/usb-authorization/requests"
|
|
||||||
mkdir -p "$requests"
|
|
||||||
chmod 700 "${requests%/*}" "$requests"
|
|
||||||
exec 9>"$requests/.lock"
|
|
||||||
|
|
||||||
request_generation() {
|
|
||||||
local daemon_id
|
|
||||||
|
|
||||||
# USBGuard reuses device IDs after restarting. Include both the daemon
|
|
||||||
# invocation and watcher lifetime so old prompts cannot suppress new ones.
|
|
||||||
daemon_id=$(systemctl show usbguard.service --property=InvocationID --value) || return 1
|
|
||||||
[[ $daemon_id =~ ^[0-9a-f]{32}$ ]] || return 1
|
|
||||||
printf '%s:%s' "${OMARCHY_USB_AUTHORIZATION_WATCH_ID:-standalone}" "$daemon_id"
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ $USBGUARD_IPC_SIGNAL == "IPC.Connected" ]]; then
|
|
||||||
flock 9
|
|
||||||
generation=$(request_generation) || exit 1
|
|
||||||
for pending in "$requests"/request-*.json; do
|
|
||||||
[[ -f $pending ]] || continue
|
|
||||||
if ! jq -e --arg generation "$generation" '.generation == $generation' "$pending" >/dev/null 2>&1; then
|
|
||||||
rm -f "$pending"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
flock -u 9
|
|
||||||
|
|
||||||
# USBGuard scans present devices before accepting IPC connections. Repeat
|
|
||||||
# this on every reconnect: temporary approvals disappear on daemon restart.
|
|
||||||
blocked=$(usbguard list-devices --blocked) || exit 1
|
|
||||||
while IFS= read -r line; do
|
|
||||||
if [[ $line =~ ^([0-9]+):[[:space:]](block.*)$ ]]; then
|
|
||||||
USBGUARD_IPC_SIGNAL=Device.PolicyApplied \
|
|
||||||
USBGUARD_DEVICE_ID="${BASH_REMATCH[1]}" \
|
|
||||||
USBGUARD_DEVICE_TARGET_NEW=block \
|
|
||||||
USBGUARD_DEVICE_RULE="${BASH_REMATCH[2]}" \
|
|
||||||
"$OMARCHY_PATH/bin/omarchy-usb-authorization-event" || true
|
|
||||||
fi
|
|
||||||
done <<<"$blocked"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
[[ ${USBGUARD_DEVICE_ID:-} =~ ^[0-9]+$ ]] || exit 0
|
|
||||||
[[ -n ${USBGUARD_DEVICE_RULE:-} ]] || exit 0
|
|
||||||
|
|
||||||
# A removed device's final target may be allow even though a blocked request
|
|
||||||
# remains. Its identity attributes are unchanged; normalize the target.
|
|
||||||
USBGUARD_DEVICE_RULE="block ${USBGUARD_DEVICE_RULE#* }"
|
|
||||||
|
|
||||||
# The token is safe to carry through the presentation launcher's command
|
|
||||||
# string. The device-controlled rule itself stays in a mode-0600 JSON file and
|
|
||||||
# is never interpolated into a shell command.
|
|
||||||
token="request-$(printf '%s\0%s' "$USBGUARD_DEVICE_ID" "$USBGUARD_DEVICE_RULE" | sha256sum | cut -d' ' -f1)"
|
|
||||||
request="$requests/$token.json"
|
|
||||||
# watch executes handlers concurrently. A reconnect scan and a policy event
|
|
||||||
# can refer to the same device; publish only one pending request.
|
|
||||||
if [[ $USBGUARD_IPC_SIGNAL == "Device.PresenceChanged" ]]; then
|
|
||||||
flock 9
|
|
||||||
blocked=$(usbguard list-devices --blocked) || exit 1
|
|
||||||
# A delayed removal event must not erase a request for a reappeared device.
|
|
||||||
if ! grep -Fqx -- "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" <<<"$blocked"; then
|
|
||||||
rm -f "$request"
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Keep retrying without needing another USB event. Handlers belong to the
|
|
||||||
# watcher service and are stopped with the graphical session.
|
|
||||||
while true; do
|
|
||||||
flock 9
|
|
||||||
generation=$(request_generation) || exit 1
|
|
||||||
if [[ -f $request ]] && jq -e --arg generation "$generation" '.generation == $generation' "$request" >/dev/null 2>&1; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Discard events overtaken by another policy decision or device replacement.
|
|
||||||
blocked=$(usbguard list-devices --blocked) || exit 1
|
|
||||||
current=0
|
|
||||||
while IFS= read -r line; do
|
|
||||||
if [[ $line == "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" ]]; then
|
|
||||||
current=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done <<<"$blocked"
|
|
||||||
(( current )) || exit 0
|
|
||||||
|
|
||||||
# Login and shell restarts can leave D-Bus alive before notifications are ready.
|
|
||||||
# Bound each wait so disappeared devices are rechecked between attempts.
|
|
||||||
if ! omarchy-notification-wait 1; then
|
|
||||||
flock -u 9
|
|
||||||
sleep 1
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
temporary=$(mktemp "$requests/.request.XXXXXXXXXX")
|
|
||||||
trap 'rm -f "$temporary"' EXIT
|
|
||||||
|
|
||||||
jq -n \
|
|
||||||
--arg id "$USBGUARD_DEVICE_ID" \
|
|
||||||
--arg rule "$USBGUARD_DEVICE_RULE" \
|
|
||||||
--arg generation "$generation" \
|
|
||||||
'{id: $id, rule: $rule, generation: $generation}' >"$temporary"
|
|
||||||
chmod 600 "$temporary"
|
|
||||||
mv -f "$temporary" "$request"
|
|
||||||
|
|
||||||
if omarchy-notification-send \
|
|
||||||
--app-name omarchy-action \
|
|
||||||
--urgency critical \
|
|
||||||
--glyph \
|
|
||||||
"$(omarchy-text "USB accessory blocked")" \
|
|
||||||
"$(omarchy-text "Click to review it. Keep it blocked unless you recognize the device.")" \
|
|
||||||
--exec omarchy-launch-floating-terminal-with-presentation \
|
|
||||||
omarchy-usb-authorization-review "$token"; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
rm -f "$request"
|
|
||||||
flock -u 9
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:summary=Restore the kernel's default USB authorization state
|
|
||||||
# omarchy:requires-sudo=true
|
|
||||||
# omarchy:hidden=true
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if (( EUID != 0 )); then
|
|
||||||
echo "Error: omarchy-usb-authorization-restore-default must run as root" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# USBGuard intentionally leaves root hubs in default-deny mode when it exits.
|
|
||||||
# Restore both the hub defaults and devices that are still connected.
|
|
||||||
shopt -s nullglob
|
|
||||||
for attribute in /sys/bus/usb/devices/usb*/authorized_default; do
|
|
||||||
echo 1 >"$attribute"
|
|
||||||
done
|
|
||||||
for attribute in /sys/bus/usb/devices/*/authorized; do
|
|
||||||
echo 1 >"$attribute"
|
|
||||||
done
|
|
||||||
shopt -u nullglob
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:hidden=true
|
|
||||||
# omarchy:summary=Review a blocked USB device authorization request
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
token="${1:-}"
|
|
||||||
if [[ ! $token =~ ^request-[0-9a-f]{64}$ ]]; then
|
|
||||||
echo "This USB authorization request is invalid or expired." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
request="$HOME/.local/state/omarchy/usb-authorization/requests/$token.json"
|
|
||||||
# Serialize request updates with watcher events, but release the lock while the
|
|
||||||
# user chooses. A changed request must never inherit an old dialog's approval.
|
|
||||||
if [[ ! -d ${request%/*} ]]; then
|
|
||||||
echo "This USB authorization request is no longer available." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
exec 9>"${request%/*}/.lock"
|
|
||||||
flock 9
|
|
||||||
if [[ ! -f $request || -L $request ]]; then
|
|
||||||
echo "This USB authorization request is no longer available." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
snapshot=$(cat "$request")
|
|
||||||
id=$(jq -er '.id | select(test("^[0-9]+$"))' <<<"$snapshot")
|
|
||||||
expected_rule=$(jq -er '.rule | select(type == "string" and startswith("block "))' <<<"$snapshot")
|
|
||||||
choice=$(jq -er '.approval // "" | select(. == "" or . == "Allow once" or . == "Always allow this device")' <<<"$snapshot")
|
|
||||||
allowed_rule="allow ${expected_rule#block }"
|
|
||||||
|
|
||||||
approval_failed() {
|
|
||||||
echo "Could not confirm USB approval. The request has been kept; try again." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
read_current_rule() {
|
|
||||||
local devices line
|
|
||||||
|
|
||||||
current_rule=""
|
|
||||||
devices=$(usbguard list-devices) || approval_failed
|
|
||||||
while IFS= read -r line; do
|
|
||||||
if [[ $line == "$id: "* ]]; then
|
|
||||||
current_rule="${line#"$id: "}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
done <<<"$devices"
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_device() {
|
|
||||||
read_current_rule
|
|
||||||
if [[ $current_rule == "$expected_rule" ]] ||
|
|
||||||
[[ -n $choice && $current_rule == "$allowed_rule" ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
rm -f "$request"
|
|
||||||
echo "That USB device was removed or changed. Connect it again to review it." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_device
|
|
||||||
|
|
||||||
if [[ -z $choice ]]; then
|
|
||||||
flock -u 9
|
|
||||||
gum style --foreground 212 --bold "Blocked USB accessory"
|
|
||||||
echo
|
|
||||||
echo "The following details come from the device and can be forged:"
|
|
||||||
printf '%s\n' "$current_rule" | LC_ALL=C tr -c '\11\12\15\40-\176' '?'
|
|
||||||
echo
|
|
||||||
|
|
||||||
choice=$(gum choose \
|
|
||||||
"Allow once" \
|
|
||||||
"Always allow this device" \
|
|
||||||
"Keep blocked") || exit 130
|
|
||||||
|
|
||||||
flock 9
|
|
||||||
if [[ ! -f $request || -L $request || $(cat "$request") != "$snapshot" ]]; then
|
|
||||||
echo "This USB authorization request changed. Open the latest notification to review it." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# A fresh choice can authorize only the blocked identity shown in the dialog.
|
|
||||||
read_current_rule
|
|
||||||
if [[ $current_rule != "$expected_rule" ]]; then
|
|
||||||
rm -f "$request"
|
|
||||||
echo "That USB device was removed or changed. Connect it again to review it." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$choice" in
|
|
||||||
"Allow once" | "Always allow this device")
|
|
||||||
# Save intent before applying policy: a later query can fail after the device
|
|
||||||
# was authorized. Retry must verify that approval, including persistent trust.
|
|
||||||
temporary=$(mktemp "${request%/*}/.request.XXXXXXXXXX")
|
|
||||||
trap 'rm -f "$temporary"' EXIT
|
|
||||||
jq --arg approval "$choice" '.approval = $approval' "$request" >"$temporary"
|
|
||||||
mv -f "$temporary" "$request"
|
|
||||||
|
|
||||||
if [[ $choice == "Always allow this device" ]]; then
|
|
||||||
pkexec /usr/bin/omarchy-usb-authorization-approve "$id" "$current_rule" || approval_failed
|
|
||||||
elif [[ $current_rule == "$expected_rule" ]]; then
|
|
||||||
# Match the complete identity, since numeric IDs can be reassigned.
|
|
||||||
usbguard allow-device "$expected_rule" || approval_failed
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Rule-based USBGuard commands swallow applyDevicePolicy exceptions and can
|
|
||||||
# return success without authorizing anything. Verify the exact device.
|
|
||||||
allowed=$(usbguard list-devices --allowed) || approval_failed
|
|
||||||
grep -Fqx -- "$id: $allowed_rule" <<<"$allowed" || approval_failed
|
|
||||||
|
|
||||||
if [[ $choice == "Always allow this device" ]]; then
|
|
||||||
# Let USBGuard match policy rules: saved rules can omit the device's port.
|
|
||||||
# --show-devices indents the devices matching each policy rule.
|
|
||||||
policy=$(usbguard list-rules --label omarchy-usb-authorization-v1 --show-devices) || approval_failed
|
|
||||||
policy_allows=0
|
|
||||||
matched=0
|
|
||||||
while IFS= read -r line; do
|
|
||||||
if [[ $line =~ ^[0-9]+:[[:space:]] ]]; then
|
|
||||||
policy_allows=0
|
|
||||||
if [[ $line =~ ^[0-9]+:[[:space:]]allow[[:space:]] ]]; then
|
|
||||||
policy_allows=1
|
|
||||||
fi
|
|
||||||
elif (( policy_allows )) && [[ $line == $'\t'"$id: $allowed_rule" ]]; then
|
|
||||||
matched=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done <<<"$policy"
|
|
||||||
(( matched )) || approval_failed
|
|
||||||
echo "USB accessory added to the trusted policy."
|
|
||||||
else
|
|
||||||
echo "USB accessory allowed until it is disconnected."
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
"Keep blocked")
|
|
||||||
echo "USB accessory remains blocked."
|
|
||||||
;;
|
|
||||||
*) exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
rm -f "$request"
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# omarchy:hidden=true
|
|
||||||
# omarchy:summary=Watch USBGuard for devices awaiting user approval
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Starting a new graphical-session watcher must offer fresh prompts even if
|
|
||||||
# their old notification actions were dismissed and aged out of history.
|
|
||||||
OMARCHY_USB_AUTHORIZATION_WATCH_ID=$(cat /proc/sys/kernel/random/uuid)
|
|
||||||
export OMARCHY_USB_AUTHORIZATION_WATCH_ID
|
|
||||||
|
|
||||||
event_handler="$OMARCHY_PATH/bin/omarchy-usb-authorization-event"
|
|
||||||
|
|
||||||
# The handler scans blocked devices on IPC.Connected, including reconnects
|
|
||||||
# after a daemon restart, once the event subscription is established.
|
|
||||||
exec usbguard watch --wait --exec "$event_handler"
|
|
||||||
@@ -1,6 +1,298 @@
|
|||||||
<?xml version="1.0"?>
|
<?xml version="1.0"?>
|
||||||
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
|
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
|
||||||
<fontconfig>
|
<fontconfig>
|
||||||
|
<!-- CJK: fold the language tag onto one tag per Noto CJK variant -->
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="eq">
|
||||||
|
<string>zh</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-cn</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="eq">
|
||||||
|
<string>zh-hant</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-tw</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-hant-hk</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-hk</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-hant-mo</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-hk</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-hant</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-tw</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-hans</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-cn</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-sg</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-cn</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-mo</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-hk</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="eq">
|
||||||
|
<string>yue</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-hk</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>yue-hans</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-cn</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>yue-cn</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-cn</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>yue</string>
|
||||||
|
</test>
|
||||||
|
<edit name="lang" mode="assign">
|
||||||
|
<string>zh-hk</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<!-- Then append that variant after the generic family it falls back from -->
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-cn</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>sans-serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Sans CJK SC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-cn</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Serif CJK SC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-cn</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>monospace</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Sans Mono CJK SC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-hk</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>sans-serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Sans CJK HK</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-hk</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Serif CJK HK</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-hk</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>monospace</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Sans Mono CJK HK</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-tw</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>sans-serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Sans CJK TC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-tw</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Serif CJK TC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<string>zh-tw</string>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>monospace</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Sans Mono CJK TC</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<langset><string>ja</string></langset>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>sans-serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Sans CJK JP</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<langset><string>ja</string></langset>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Serif CJK JP</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<langset><string>ja</string></langset>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>monospace</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Sans Mono CJK JP</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<langset><string>ko</string></langset>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>sans-serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Sans CJK KR</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<langset><string>ko</string></langset>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>serif</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Serif CJK KR</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
|
<match target="pattern">
|
||||||
|
<test name="lang" qual="first" compare="contains">
|
||||||
|
<langset><string>ko</string></langset>
|
||||||
|
</test>
|
||||||
|
<test name="family">
|
||||||
|
<string>monospace</string>
|
||||||
|
</test>
|
||||||
|
<edit name="family" mode="append" binding="strong">
|
||||||
|
<string>Noto Sans Mono CJK KR</string>
|
||||||
|
</edit>
|
||||||
|
</match>
|
||||||
|
|
||||||
<match target="pattern">
|
<match target="pattern">
|
||||||
<test name="family" qual="any">
|
<test name="family" qual="any">
|
||||||
<string>sans-serif</string>
|
<string>sans-serif</string>
|
||||||
|
|||||||
@@ -215,6 +215,9 @@
|
|||||||
"setup.language": {"icon":"","label":"Language"},
|
"setup.language": {"icon":"","label":"Language"},
|
||||||
"setup.language.en": {"icon":"","label":"English","checked":"[[ \"$(omarchy-language)\" == \"en_US.UTF-8\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-language en'"},
|
"setup.language.en": {"icon":"","label":"English","checked":"[[ \"$(omarchy-language)\" == \"en_US.UTF-8\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-language en'"},
|
||||||
"setup.language.zh": {"icon":"","label":"简体中文","checked":"[[ \"$(omarchy-language)\" == \"zh_CN.UTF-8\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-language zh'"},
|
"setup.language.zh": {"icon":"","label":"简体中文","checked":"[[ \"$(omarchy-language)\" == \"zh_CN.UTF-8\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-language zh'"},
|
||||||
|
"setup.region": {"icon":"","label":"Region"},
|
||||||
|
"setup.region.global": {"icon":"","label":"Global","checked":"[[ \"$(omarchy-region)\" == \"Global\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-region global'"},
|
||||||
|
"setup.region.china": {"icon":"","label":"China","checked":"[[ \"$(omarchy-region)\" == \"China\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-region cn'"},
|
||||||
"setup.plugin": {"icon":"","label":"Plugins","aliases":["plugin","plugins"]},
|
"setup.plugin": {"icon":"","label":"Plugins","aliases":["plugin","plugins"]},
|
||||||
"setup.plugin.enable": {"icon":"","label":"Enable Plugin","action":"omarchy-menu-plugin enable"},
|
"setup.plugin.enable": {"icon":"","label":"Enable Plugin","action":"omarchy-menu-plugin enable"},
|
||||||
"setup.plugin.disable": {"icon":"","label":"Disable Plugin","action":"omarchy-menu-plugin disable"},
|
"setup.plugin.disable": {"icon":"","label":"Disable Plugin","action":"omarchy-menu-plugin disable"},
|
||||||
@@ -223,12 +226,8 @@
|
|||||||
"setup.plugin.remove": {"icon":"","label":"Remove Plugin","when":"compgen -G \"$HOME/.config/omarchy/plugins/*/manifest.json\"","action":"omarchy-menu-plugin remove"},
|
"setup.plugin.remove": {"icon":"","label":"Remove Plugin","when":"compgen -G \"$HOME/.config/omarchy/plugins/*/manifest.json\"","action":"omarchy-menu-plugin remove"},
|
||||||
"setup.security": {"icon":"","label":"Security"},
|
"setup.security": {"icon":"","label":"Security"},
|
||||||
"setup.config": {"icon":"","label":"Config"},
|
"setup.config": {"icon":"","label":"Config"},
|
||||||
"setup.security.thunderbolt": {"icon":"","label":"Thunderbolt","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization"},
|
|
||||||
"setup.security.thunderbolt-boot": {"icon":"","label":"Thunderbolt Boot","when":"[[ -f /etc/omarchy/thunderbolt-authorization.enabled ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-boot"},
|
|
||||||
"setup.security.fingerprint": {"icon":"","label":"Fingerprint","when":"omarchy-hw-fingerprint","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"},
|
"setup.security.fingerprint": {"icon":"","label":"Fingerprint","when":"omarchy-hw-fingerprint","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"},
|
||||||
"setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"},
|
"setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"},
|
||||||
"setup.security.usb-authorization": {"icon":"","label":"USB Device Authorization","checked":"systemctl is-enabled --quiet usbguard.service && systemctl --user is-enabled --quiet omarchy-usb-authorization.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-usb-authorization"},
|
|
||||||
"setup.security.usb-authorization-boot": {"icon":"","label":"USB at Boot","when":"! omarchy-hw-aarch64-apple","checked":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-security-usb-authorization --boot'"},
|
|
||||||
"setup.security.sshd": {"icon":"","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"},
|
"setup.security.sshd": {"icon":"","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"},
|
||||||
"setup.security.ssh-agent": {"icon":"","label":"SSH Agent","when":"! systemctl --user -q is-enabled gcr-ssh-agent.socket","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-ssh-agent"},
|
"setup.security.ssh-agent": {"icon":"","label":"SSH Agent","when":"! systemctl --user -q is-enabled gcr-ssh-agent.socket","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-ssh-agent"},
|
||||||
"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.security.sudoless-docker": {"icon":"","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"},
|
||||||
@@ -262,6 +261,16 @@
|
|||||||
"install.tui": {"icon":"","label":"TUI","action":"omarchy-launch-floating-terminal-with-presentation omarchy-tui-install"},
|
"install.tui": {"icon":"","label":"TUI","action":"omarchy-launch-floating-terminal-with-presentation omarchy-tui-install"},
|
||||||
"install.windows": {"icon":"","label":"Windows","when":"omarchy-hw-x86","disabled":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm install'"},
|
"install.windows": {"icon":"","label":"Windows","when":"omarchy-hw-x86","disabled":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm install'"},
|
||||||
"install.aur": {"icon":"","label":"AUR","action":"xdg-terminal-exec --app-id=org.omarchy.terminal omarchy-pkg-aur-install"},
|
"install.aur": {"icon":"","label":"AUR","action":"xdg-terminal-exec --app-id=org.omarchy.terminal omarchy-pkg-aur-install"},
|
||||||
|
"install.china": {"icon":"","label":"China Apps","when":"[[ \"$(omarchy-region)\" == \"China\" ]]"},
|
||||||
|
"install.china.wechat": {"icon":"","label":"WeChat","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present wechat-universal-bwrap","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add wechat-universal-bwrap'"},
|
||||||
|
"install.china.qq": {"icon":"","label":"QQ","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present linuxqq","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add linuxqq'"},
|
||||||
|
"install.china.feishu": {"icon":"","label":"Feishu","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present feishu-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add feishu-bin'"},
|
||||||
|
"install.china.dingtalk": {"icon":"","label":"DingTalk","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present dingtalk-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add dingtalk-bin'"},
|
||||||
|
"install.china.wemeet": {"icon":"","label":"Tencent Meeting","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present wemeet-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add wemeet-bin'"},
|
||||||
|
"install.china.wps": {"icon":"","label":"WPS Office","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present wps-office-cn","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add wps-office-cn'"},
|
||||||
|
"install.china.tencent-docs": {"icon":"","label":"Tencent Docs","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"[[ -f \"$HOME/.local/share/applications/腾讯文档.desktop\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-webapp-install 腾讯文档 https://docs.qq.com \"\"'"},
|
||||||
|
"install.china.yuque": {"icon":"","label":"Yuque","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"[[ -f \"$HOME/.local/share/applications/语雀.desktop\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-webapp-install 语雀 https://www.yuque.com/dashboard \"\"'"},
|
||||||
|
"install.china.shimo": {"icon":"","label":"Shimo","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"[[ -f \"$HOME/.local/share/applications/石墨文档.desktop\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-webapp-install 石墨文档 https://shimo.im/desktop \"\"'"},
|
||||||
"install.preinstalls": {"icon":"","label":"Preinstalls","disabled":"[[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-preinstalls"},
|
"install.preinstalls": {"icon":"","label":"Preinstalls","disabled":"[[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-preinstalls"},
|
||||||
"install.browser.chrome": {"icon":"","label":"Chrome","disabled":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser chrome'"},
|
"install.browser.chrome": {"icon":"","label":"Chrome","disabled":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser chrome'"},
|
||||||
"install.browser.edge": {"icon":"","label":"Edge","when":"omarchy-hw-x86","disabled":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser edge'"},
|
"install.browser.edge": {"icon":"","label":"Edge","when":"omarchy-hw-x86","disabled":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser edge'"},
|
||||||
@@ -347,12 +356,8 @@
|
|||||||
"remove.windows": {"icon":"","label":"Windows","when":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm remove'"},
|
"remove.windows": {"icon":"","label":"Windows","when":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm remove'"},
|
||||||
"remove.preinstalls": {"icon":"","label":"Preinstalls","when":"[[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-preinstalls"},
|
"remove.preinstalls": {"icon":"","label":"Preinstalls","when":"[[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-preinstalls"},
|
||||||
"remove.security": {"icon":"","label":"Security","title":"Remove"},
|
"remove.security": {"icon":"","label":"Security","title":"Remove"},
|
||||||
"remove.security.thunderbolt": {"icon":"","label":"Thunderbolt","when":"[[ -f /etc/omarchy/thunderbolt-authorization.enabled || -f /etc/omarchy/thunderbolt-authorization.pending ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-thunderbolt-authorization"},
|
|
||||||
"remove.security.thunderbolt-boot": {"icon":"","label":"Thunderbolt Boot","when":"omarchy-thunderbolt-authorization-boot-enabled","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-thunderbolt-boot"},
|
|
||||||
"remove.security.fingerprint": {"icon":"","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"},
|
"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.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"},
|
||||||
"remove.security.usb-authorization": {"icon":"","label":"USB Device Authorization","when":"systemctl is-enabled --quiet usbguard.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-usb-authorization"},
|
|
||||||
"remove.security.usb-authorization-boot": {"icon":"","label":"USB at Boot","when":"! omarchy-hw-aarch64-apple && grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-security-usb-authorization --boot-only'"},
|
|
||||||
"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.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":"! omarchy-sudo-docker --configured","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.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"},
|
"remove.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"},
|
||||||
|
|||||||
@@ -189,12 +189,15 @@
|
|||||||
"setup.typing": {"label":"输入"},
|
"setup.typing": {"label":"输入"},
|
||||||
"setup.typing.keyboard": {"label":"键盘布局"},
|
"setup.typing.keyboard": {"label":"键盘布局"},
|
||||||
"setup.typing.input": {"label":"输入法"},
|
"setup.typing.input": {"label":"输入法"},
|
||||||
"setup.security.thunderbolt": {"label":"雷电接口"},
|
"setup.region": {"label":"地区"},
|
||||||
"setup.security.thunderbolt-boot": {"label":"开机时的雷电接口"},
|
"setup.region.global": {"label":"全球"},
|
||||||
"setup.security.usb-authorization": {"label":"USB 设备授权"},
|
"setup.region.china": {"label":"中国"},
|
||||||
"setup.security.usb-authorization-boot": {"label":"开机时的 USB"},
|
"install.china": {"label":"国内应用"},
|
||||||
"remove.security.thunderbolt": {"label":"雷电接口"},
|
"install.china.wechat": {"label":"微信"},
|
||||||
"remove.security.thunderbolt-boot": {"label":"开机时的雷电接口"},
|
"install.china.feishu": {"label":"飞书"},
|
||||||
"remove.security.usb-authorization": {"label":"USB 设备授权"},
|
"install.china.dingtalk": {"label":"钉钉"},
|
||||||
"remove.security.usb-authorization-boot": {"label":"开机时的 USB"},
|
"install.china.wemeet": {"label":"腾讯会议"},
|
||||||
|
"install.china.tencent-docs": {"label":"腾讯文档"},
|
||||||
|
"install.china.yuque": {"label":"语雀"},
|
||||||
|
"install.china.shimo": {"label":"石墨文档"},
|
||||||
}
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
|
||||||
<policyconfig>
|
|
||||||
<action id="org.omarchy.thunderbolt.approve">
|
|
||||||
<description>Approve a connected Thunderbolt accessory</description>
|
|
||||||
<message>Authentication is required to approve this Thunderbolt accessory</message>
|
|
||||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/omarchy-thunderbolt-authorization-approve</annotate>
|
|
||||||
<defaults>
|
|
||||||
<allow_any>auth_admin</allow_any>
|
|
||||||
<allow_inactive>auth_admin</allow_inactive>
|
|
||||||
<allow_active>auth_admin</allow_active>
|
|
||||||
</defaults>
|
|
||||||
</action>
|
|
||||||
</policyconfig>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
|
||||||
<policyconfig>
|
|
||||||
<action id="org.omarchy.usb.approve">
|
|
||||||
<description>Trust a connected USB accessory on any port</description>
|
|
||||||
<message>Authentication is required to trust this USB accessory</message>
|
|
||||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/omarchy-usb-authorization-approve</annotate>
|
|
||||||
<defaults>
|
|
||||||
<allow_any>auth_admin</allow_any>
|
|
||||||
<allow_inactive>auth_admin</allow_inactive>
|
|
||||||
<allow_active>auth_admin</allow_active>
|
|
||||||
</defaults>
|
|
||||||
</action>
|
|
||||||
</policyconfig>
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[source.crates-io]
|
||||||
|
replace-with = "aliyun"
|
||||||
|
|
||||||
|
[source.aliyun]
|
||||||
|
registry = "sparse+https://mirrors.aliyun.com/crates.io-index/"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
|
||||||
|
PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/
|
||||||
|
UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple/
|
||||||
|
GOPROXY=https://mirrors.aliyun.com/goproxy/,direct
|
||||||
|
RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
|
||||||
|
RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup
|
||||||
|
BUNDLE_MIRROR__ALL=https://mirrors.aliyun.com/rubygems/
|
||||||
|
MAVEN_ARGS=--global-settings /usr/share/omarchy/default/regions/cn/maven-settings.xml
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
:sources:
|
||||||
|
- https://mirrors.aliyun.com/rubygems/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<settings>
|
||||||
|
<mirrors>
|
||||||
|
<mirror>
|
||||||
|
<id>aliyun</id>
|
||||||
|
<mirrorOf>central</mirrorOf>
|
||||||
|
<url>https://maven.aliyun.com/repository/public</url>
|
||||||
|
</mirror>
|
||||||
|
</mirrors>
|
||||||
|
</settings>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Installed from the ISO's offline repository before enabling ArchLinuxCN.
|
||||||
|
archlinuxcn-keyring
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Server = https://mirror.omarchy.cn/$repo/os/$arch
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Server = https://rc-mirror.omarchy.cn/$repo/os/$arch
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Server = https://stable-mirror.omarchy.cn/$repo/os/$arch
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[archlinuxcn]
|
||||||
|
Server = https://mirrors.ustc.edu.cn/archlinuxcn/$arch
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Installs in these timezones get this region. The installer's timezone list
|
||||||
|
# includes tzdata's backward-compatible names, so they are listed too.
|
||||||
|
Asia/Shanghai
|
||||||
|
Asia/Urumqi
|
||||||
|
Asia/Chongqing
|
||||||
|
Asia/Chungking
|
||||||
|
Asia/Harbin
|
||||||
|
Asia/Kashgar
|
||||||
|
PRC
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Offer approval for blocked Thunderbolt accessories
|
|
||||||
After=graphical-session.target
|
|
||||||
PartOf=graphical-session.target
|
|
||||||
ConditionEnvironment=WAYLAND_DISPLAY
|
|
||||||
ConditionPathExists=|/etc/omarchy/thunderbolt-authorization.enabled
|
|
||||||
ConditionPathExists=|/etc/omarchy/thunderbolt-authorization.pending
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=/usr/bin/omarchy-thunderbolt-authorization-watch
|
|
||||||
Restart=always
|
|
||||||
RestartSec=2
|
|
||||||
UMask=0077
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=graphical-session.target
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Ask before allowing new USB devices
|
|
||||||
After=graphical-session.target dbus.socket
|
|
||||||
Requires=dbus.socket
|
|
||||||
PartOf=graphical-session.target
|
|
||||||
ConditionEnvironment=OMARCHY_PATH
|
|
||||||
ConditionEnvironment=WAYLAND_DISPLAY
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/bin/bash -lc 'exec "$OMARCHY_PATH/bin/omarchy-usb-authorization-watch"'
|
|
||||||
Restart=always
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=graphical-session.target
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Regional defaults
|
||||||
|
|
||||||
|
Regions customize bootstrap defaults independently of update channels and of the installer's language. Country identifiers are lowercase, officially assigned ISO 3166-1 alpha-2 codes. `global` is a reserved value for no country-specific additions. Only implemented profiles are accepted; currently `global` and `cn`.
|
||||||
|
|
||||||
|
## Runtime configuration
|
||||||
|
|
||||||
|
The ISO installer writes the region it derived to `/etc/omarchy/region` before target finalization. It is plain text, not executable configuration, owned by root and world-readable (`0644`), because refresh renders as the calling user. A missing file means `global`; an unsupported value is an error.
|
||||||
|
|
||||||
|
`omarchy-apply-pacman <stable|rc|edge> [target-root]` restores the selected channel's two pacman templates, appends the region's `pacman.conf.append` to `pacman.conf`, and puts the region's `mirrorlist-<channel>.prepend` ahead of the channel's mirrorlist. Both files are generated before either live file is replaced. Existing files are backed up with `.bak`. Dev uses the edge pacman channel, as before.
|
||||||
|
|
||||||
|
Both installation finalization and `omarchy-refresh-pacman` use this command. Explicit refreshes and channel changes restore regional defaults; ordinary updates do not enforce them. The existing `pre-refresh-pacman` user hook runs after regeneration and before the update, so users can maintain overrides there. Switching to the dev channel refuses a checkout that lacks this machine's region profile, because that checkout's own refresh would otherwise drop the regional repositories. Users can edit the files directly or set the region to `global` before a refresh to stop applying regional repository defaults. This does not uninstall packages or rewrite anything in existing home directories.
|
||||||
|
|
||||||
|
Refresh renders both files as the calling user and gives root only the finished copies, so the sudo boundary stays command-scoped. That boundary does not defend against other code already running as the same user: such code could change the rendered files while refresh waits for the password, as it could alter that user's own environment in other ways.
|
||||||
|
|
||||||
|
The China profile puts Omarchy's China replica of the channel's Arch mirror first (`stable-mirror.omarchy.cn`, `rc-mirror.omarchy.cn`, or `mirror.omarchy.cn` for edge and dev) and keeps the channel's `.org` mirror as the fallback. pacman tries servers in order and moves to the next one when a download fails, including a 404. After three failed downloads from one server, it skips that server for the rest of the transaction. It also takes the package databases from the first server that answers, without comparing them, so the China replicas must carry complete, current metadata. The profile also adds the ArchLinuxCN community repository from USTC's mirror, run by the University of Science and Technology of China. It does not change the `[omarchy]` package repository. ArchLinuxCN is rolling and built against current Arch, while stable and RC are snapshots, so its packages can need newer libraries than a stable machine has.
|
||||||
|
|
||||||
|
## Profile layout
|
||||||
|
|
||||||
|
Each profile lives in `default/regions/<code>/`:
|
||||||
|
|
||||||
|
- `timezones`: the timezones that select this region, one per line, with blank lines and `#` comments allowed. List tzdata's backward-compatible names too, since the installer's timezone list includes them. A timezone may belong to at most one profile.
|
||||||
|
- `packages`: additional target packages, in the same format. Every ISO carries every profile's packages in its offline repository; the installer installs them only on targets in that region. A package named `<name>-keyring` is the profile's trust anchor: the builder installs it through Arch's existing trust chain, and a regional target populates the `<name>` keyring.
|
||||||
|
- `pacman/`: `pacman.conf.append`, added after the channel's `pacman.conf`, and one `mirrorlist-<channel>.prepend` per channel (`stable`, `rc`, `edge`), placed ahead of the channel's mirrorlist so the regional mirror is tried first.
|
||||||
|
|
||||||
|
## ISO installer contract
|
||||||
|
|
||||||
|
There is one ISO for every region. The companion `omarchy-iso` repository reads the profiles from the runtime it bundles, so a region and its repository defaults can never disagree.
|
||||||
|
|
||||||
|
- **Build:** every profile's `packages` go into the offline repository, but not into the base package list. The builder adds each profile's community repository to its writable online config to download them; it does not change its Arch channel mirrors.
|
||||||
|
- **Install:** the installer maps the timezone the user picked to a region through the `timezones` files; a timezone no profile lists means `global`. It shows the result on the summary screen. Unattended installs can set the region explicitly instead. For a regional install it installs the profile's packages from the offline repository and writes `/etc/omarchy/region`; the runtime finalizer then applies the fragments.
|
||||||
|
- **Deferred installs** have no timezone until first boot, so they are `global` unless the unattended configuration sets a region.
|
||||||
|
|
||||||
|
For China, the builder installs `archlinuxcn-keyring` using the existing Arch trust chain before downloading ArchLinuxCN packages. Signature or keyring failures abort the build; no unverified keyserver trust or online signature bypass is added. On a China target the installer installs the keyring from the offline repository, then initializes and populates it before the online repositories are enabled. Global targets never populate ArchLinuxCN's keys. The ISO's existing offline repository verification policy is unchanged.
|
||||||
|
|
||||||
|
## Scope of the China profile
|
||||||
|
|
||||||
|
The China profile provides repository defaults and developer mirrors. Language is a separate installer choice, not part of a region: a Chinese speaker outside China keeps global mirrors, and a China install in English still gets the regional ones.
|
||||||
|
|
||||||
|
## Developer mirrors
|
||||||
|
|
||||||
|
The `cn` profile also points the common package managers at mirrors inside China: `environment.conf` (npm, PyPI and uv, Go, rustup, Bundler, and Maven through `MAVEN_ARGS`), `cargo.toml` for crates.io, `gemrc` for RubyGems, and `maven-settings.xml`, which Maven reads in place. During installation `install/config/region.sh` links the first three to `/etc/environment.d/90-omarchy-china.conf`, `/.cargo/config.toml` and `/etc/gemrc` when the marker says `cn`. It refuses to replace a regular file it did not create. The environment reaches sessions started after login.
|
||||||
|
|
||||||
|
`omarchy region cn|global` (_Setup > Region_) switches an installed machine. `cn` upgrades, installs and trusts the profile's keyring through a rendered China config, records the marker and links the developer mirrors through the same script, then refreshes pacman. `global` removes the marker and only the links that point into the profile, then refreshes. The region also shows _Install > China Apps_.
|
||||||
|
|
||||||
|
## Remaining release validation
|
||||||
|
|
||||||
|
Unit tests cover region/channel independence, the timezone mapping, generated pacman contents, repeat application, and refresh ordering; refresh itself is exercised only inside the sudo-boundary sandbox. They do not establish live mirror availability, real package trust, or successful offline installation. Before distribution, build from matching local checkouts on an x86_64 builder and run the disposable-VM acceptance workflow in `agents/skills/acceptance-tests.md`.
|
||||||
|
|
||||||
|
The acceptance run must install without network access in both directions: a non-China timezone must produce pacman files byte for byte identical to the channel templates, and `Asia/Shanghai` must produce the China profile, boot to a new user, and keep the regional mirrors, repository, marker, and keyring trust through `stable`, `rc`, and `edge` refreshes.
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Thunderbolt authorization
|
|
||||||
|
|
||||||
Omarchy uses Bolt for Thunderbolt/USB4 PCIe authorization. USB functions remain separate and need USB authorization. The end-user controls and firmware limitations are documented in [the Security manual](../manual/48-security.md#thunderbolt-device-authorization).
|
|
||||||
|
|
||||||
## Policy and services
|
|
||||||
|
|
||||||
Bolt's default IOMMU policy can enroll new devices without asking. Omarchy keeps `AuthMode=disabled` in `/var/lib/boltd/boltd.conf` and authorizes individual devices through Bolt's D-Bus API. This does not disable Bolt or prevent explicit authorization. Setting only `DefaultPolicy=manual` would still leave IOMMU auto-enrollment enabled.
|
|
||||||
|
|
||||||
The root `omarchy-thunderbolt-authorization.service` holds explicit consent in `/var/lib/omarchy/thunderbolt-authorization/policy.json`. Bolt can import devices authorized by firmware, so its database is not treated as consent. Initial setup captures currently connected accessories; subsequent enables preserve that inventory, including an initially empty inventory. Always-allow approvals authorize, read back the real status, enroll with manual policy, verify storage, and save consent. Trusted devices are restored even without an unlocked graphical session. No operation globally enables Bolt to authorize one device.
|
|
||||||
|
|
||||||
Omarchy records which Bolt entries it enrolled, including their store timestamps, separately from consent. Removing protection converts only matching, successfully approved entries from manual to automatic policy before restoring Bolt's global mode; this includes disconnected accessories. Existing manual records and externally recreated records retain their policies. Secure keys are kept. The handoff verifies the stored policies after restarting Bolt and checkpoints policies, firmware allowlists, configuration and services for rollback. Automatic policies resume Bolt's normal firmware allowlist behavior, so every controller must be online for a recoverable handoff. A completed handoff clears enrollment ownership while retaining the trust list for re-enabling.
|
|
||||||
|
|
||||||
The Bolt startup drop-in sets the persisted mode before hardware enumeration whenever `/etc/omarchy/thunderbolt-authorization.enabled` exists. The Omarchy service checks the live mode again. Firmware security `none` remains a bypass: software cannot prevent driver loading after firmware has already opened a PCIe tunnel. This produces a warning instead of disconnecting active storage.
|
|
||||||
|
|
||||||
Secure-capable devices require both a stored Bolt record and a saved key for automatic trust. An explicit permanent approval pins the current sysfs directory and key descriptor, seeds an empty kernel key before authorization, and verifies Bolt imported it before saving consent. Automatic reconciliation cannot initialize a missing key and retains its read-only sysfs sandbox. First activation is deferred when a captured secure-capable device lacks a saved Bolt key: the `.pending` marker starts a setup notification, while existing Bolt configuration and services remain untouched. Installation can finish; only explicit setup after safe disconnection clears the pending state. Initial identity capture uses Bolt's numeric device/vendor fallback when descriptive sysfs attributes are absent.
|
|
||||||
|
|
||||||
The runtime is Bash, with `busctl --json=short` for Bolt's structured D-Bus replies and `jq` for JSON. The root daemon publishes a root-owned snapshot in `/run/omarchy-thunderbolt-authorization`. The user service polls that snapshot and refuses unavailable, stale, or mismatched generations. Both loops tick every 2 s without forking. While Linux sees no Thunderbolt controller and nothing is connected or boot-protected, the daemon reconciles Bolt every 10 s instead of every tick, still well inside a snapshot's 20 s lifetime; a controller appearing, a policy change or a boot recovery checkpoint restores the 2 s cadence at once. The user service skips an unchanged, fresh snapshot with no devices, warnings or requests in flight. It does not infer final policy from presence signals. Requests use opaque tokens and bind the policy-service generation, Bolt bus owner, device path, connection timestamp, sysfs inode, and device identity. The inode distinguishes same-second reconnects. Device text is displayed as untrusted data. Failed delivery retries; lost approval responses retain intent and offer another notification. Watcher restarts preserve unfinished intent only while the exact connection and policy-service incarnation remain unchanged. A changed device or daemon requires a new request.
|
|
||||||
|
|
||||||
The terminal dialog saves an approval intent; the background watcher submits it through `pkexec` to the fixed installed `/usr/bin/omarchy-thunderbolt-authorization-approve` helper. Polkit grants active local wheel users this individual approval action. The helper validates its arguments and the live connection, then independently verifies authorization and any saved trust before returning success. Root entrypoints use protected Bash startup and fixed installed libraries; they discard caller-supplied D-Bus addresses. A shared lock serializes setup, reconciliation, and approvals. General setup uses a separate administrator command invoked with `sudo` from the terminal. Blanket Bolt management requires administrator authentication while protection is enabled. These controls protect against unsolicited accessories, not malicious software already running as an administrator. Identifiers can be forged; Bolt's secure connection keys provide additional verification on supported hardware.
|
|
||||||
|
|
||||||
## Firmware boot access
|
|
||||||
|
|
||||||
The optional boot control applies only to online controllers with a firmware BootACL in `user` or `secure` mode. It captures connected accessories, changes stored policies to manual, clears each allowlist, and independently reads back the results. Reconciliation keeps firmware imports and returning controllers from silently restoring automatic boot permissions. Unsupported or offline controllers produce explicit warnings/errors.
|
|
||||||
|
|
||||||
Changes retain the previous policy and firmware lists. A failed transition restores matching state, or leaves a recovery checkpoint and reports failure. Device-authorization setup has a separate checkpoint that includes service state and firmware settings when boot protection was active. Restore commands are described in the manual. Firmware modified by another OS or an older snapshot is outside the current system's control; this is not an early-boot kernel authorization switch.
|
|
||||||
|
|
||||||
Deferred owner setup leaves ordinary Bolt behavior in place until the owner finishes provisioning. Factory reset disables the copied service and removes previous-owner trust, recovery data, Bolt identities, and keys from both staged and retained roots. It does not change the live daemon.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Run policy, notification, rollback, deferred-owner, and real offline-systemctl tests with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bash test/shell.d/thunderbolt-authorization-test.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The optional integration suite runs the installed `boltd` against upstream's UMockdev Thunderbolt fixture and a private D-Bus. Its hardware simulator uses Python/GObject; all Omarchy policy code and test assertions execute Bash. It needs `python-gobject`, `python-dbusmock`, `umockdev`, Bolt, and a Bolt source checkout (validated with 0.9.11):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
umockdev-wrapper env BOLT_TEST_SOURCE=/path/to/bolt bash test/shell.d/thunderbolt-authorization-test.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
It covers user/secure modes, IOMMU default denial, verified permanent and one-time approval, daemon restart, stale requests, firmware ACL restoration, and a separate Bash daemon publishing status after reconnecting to Bolt. Removal cases exercise the real setup and approval functions, automatic reconnection with retained secure keys, preservation of existing manual policies, disconnected accessories, and rollback of policies, firmware ACLs and the real private approval daemon after a setup failure. A service-control bridge manages only the fixture's private processes. These tests simulate kernel devices and firmware. They do not demonstrate physical DMA isolation or real firmware enforcement during boot. Systemd packaging, the installed approval helper, and the actual desktop Polkit rules require installed-guest validation; source-only dev linking does not install system units or rules.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
polkit.addRule(function(action, subject) {
|
|
||||||
if (action.id === "org.omarchy.thunderbolt.approve" &&
|
|
||||||
subject.active && subject.local && subject.isInGroup("wheel")) {
|
|
||||||
return polkit.Result.YES;
|
|
||||||
}
|
|
||||||
// Bolt's vendor rule grants wheel passwordless management, including enabling
|
|
||||||
// automatic approval of every IOMMU device. Keep blanket policy changes behind
|
|
||||||
// administrator authentication; the narrow Omarchy action approves one device.
|
|
||||||
if (action.id === "org.freedesktop.bolt.manage" && subject.user !== "root") {
|
|
||||||
try {
|
|
||||||
polkit.spawn(["/usr/bin/test", "-f", "/etc/omarchy/thunderbolt-authorization.enabled"]);
|
|
||||||
return polkit.Result.AUTH_ADMIN;
|
|
||||||
} catch (error) {
|
|
||||||
// Protection is disabled: retain the packaged Bolt permission rules.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
polkit.addRule(function(action, subject) {
|
|
||||||
if (action.id === "org.omarchy.usb.approve" &&
|
|
||||||
subject.active && subject.local && subject.isInGroup("wheel")) {
|
|
||||||
return polkit.Result.YES;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[Service]
|
|
||||||
# Runs before Bolt enumerates hardware, including D-Bus/udev activation.
|
|
||||||
ExecStartPre=/usr/bin/omarchy-thunderbolt-authorization-admin guard
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Restore approved Thunderbolt devices and handle new approvals
|
|
||||||
Wants=bolt.service
|
|
||||||
After=bolt.service polkit.service
|
|
||||||
ConditionPathExists=/etc/omarchy/thunderbolt-authorization.enabled
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/bin/omarchy-thunderbolt-authorization-daemon
|
|
||||||
RuntimeDirectory=omarchy-thunderbolt-authorization
|
|
||||||
RuntimeDirectoryMode=0755
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
UMask=0077
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
ProtectSystem=strict
|
|
||||||
ReadWritePaths=/var/lib/omarchy/thunderbolt-authorization /run/lock
|
|
||||||
ProtectHome=yes
|
|
||||||
PrivateTmp=yes
|
|
||||||
ProtectKernelTunables=yes
|
|
||||||
ProtectKernelModules=yes
|
|
||||||
ProtectControlGroups=yes
|
|
||||||
RestrictAddressFamilies=AF_UNIX
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
run_logged "$OMARCHY_INSTALL/config/region.sh"
|
||||||
run_logged "$OMARCHY_INSTALL/config/theme-system.sh"
|
run_logged "$OMARCHY_INSTALL/config/theme-system.sh"
|
||||||
run_logged "$OMARCHY_INSTALL/config/browser-policy.sh"
|
run_logged "$OMARCHY_INSTALL/config/browser-policy.sh"
|
||||||
run_logged "$OMARCHY_INSTALL/config/increase-lockout-limit.sh"
|
run_logged "$OMARCHY_INSTALL/config/increase-lockout-limit.sh"
|
||||||
@@ -8,7 +9,5 @@ run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh"
|
|||||||
run_logged "$OMARCHY_INSTALL/config/docker.sh"
|
run_logged "$OMARCHY_INSTALL/config/docker.sh"
|
||||||
run_logged "$OMARCHY_INSTALL/config/locale.sh"
|
run_logged "$OMARCHY_INSTALL/config/locale.sh"
|
||||||
run_logged "$OMARCHY_INSTALL/config/snapper.sh"
|
run_logged "$OMARCHY_INSTALL/config/snapper.sh"
|
||||||
run_logged "$OMARCHY_INSTALL/config/usb-authorization.sh"
|
|
||||||
run_logged "$OMARCHY_INSTALL/config/enable-services.sh"
|
run_logged "$OMARCHY_INSTALL/config/enable-services.sh"
|
||||||
run_logged "$OMARCHY_INSTALL/config/firewall.sh"
|
run_logged "$OMARCHY_INSTALL/config/firewall.sh"
|
||||||
run_logged "$OMARCHY_INSTALL/config/thunderbolt-authorization.sh"
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
cn_links=(
|
||||||
|
"environment.conf /etc/environment.d/90-omarchy-china.conf"
|
||||||
|
"cargo.toml /.cargo/config.toml"
|
||||||
|
"gemrc /etc/gemrc"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The installer writes the marker from the timezone; omarchy-region passes the region it switches to.
|
||||||
|
region=global
|
||||||
|
if [[ -e /etc/omarchy/region ]]; then
|
||||||
|
region=$(</etc/omarchy/region)
|
||||||
|
fi
|
||||||
|
region=${OMARCHY_REGION:-$region}
|
||||||
|
|
||||||
|
case $region in
|
||||||
|
global)
|
||||||
|
rm -f /etc/omarchy/region
|
||||||
|
for link in "${cn_links[@]}"; do
|
||||||
|
read -r _ target <<<"$link"
|
||||||
|
if [[ -L $target && $(readlink "$target") == */default/regions/cn/* ]]; then
|
||||||
|
rm "$target"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
cn)
|
||||||
|
for link in "${cn_links[@]}"; do
|
||||||
|
read -r _ target <<<"$link"
|
||||||
|
if [[ -e $target && ! -L $target ]]; then
|
||||||
|
echo "Refusing to replace $target, which Omarchy did not create" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
mkdir -p /etc/omarchy
|
||||||
|
echo cn >/etc/omarchy/region
|
||||||
|
for link in "${cn_links[@]}"; do
|
||||||
|
read -r source target <<<"$link"
|
||||||
|
mkdir -p "${target%/*}"
|
||||||
|
ln -sfn "$OMARCHY_PATH/default/regions/cn/$source" "$target"
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown region: $region" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
echo "Configure Thunderbolt device authorization"
|
|
||||||
|
|
||||||
install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.thunderbolt.policy" /usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy
|
|
||||||
|
|
||||||
# The builder's accessories must not become the new owner's trust policy.
|
|
||||||
# Keep ordinary Bolt behavior until interactive owner setup has finished.
|
|
||||||
if [[ -n ${OMARCHY_INSTALL_USER:-} ]]; then
|
|
||||||
/usr/bin/omarchy-thunderbolt-authorization-admin prepare
|
|
||||||
systemctl enable omarchy-thunderbolt-authorization.service
|
|
||||||
fi
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
echo "Configure USB device authorization"
|
|
||||||
|
|
||||||
install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.usb.policy" /usr/share/polkit-1/actions/org.omarchy.usb.policy
|
|
||||||
|
|
||||||
# A deferred install has only the builder's peripherals attached. The owner
|
|
||||||
# needs working input before there is an account or a session for approvals.
|
|
||||||
if [[ -z ${OMARCHY_INSTALL_USER:-} ]]; then
|
|
||||||
systemctl disable usbguard.service
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
rules_file="${OMARCHY_USB_AUTHORIZATION_RULES_FILE:-/etc/usbguard/rules.conf}"
|
|
||||||
daemon_config="${OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG:-/etc/usbguard/usbguard-daemon.conf}"
|
|
||||||
|
|
||||||
source "$OMARCHY_INSTALL/helpers/usb-authorization.sh"
|
|
||||||
usb_authorization_require_secure_settings "$daemon_config"
|
|
||||||
|
|
||||||
if [[ ! -s $rules_file ]]; then
|
|
||||||
usb_authorization_install_policy "$rules_file"
|
|
||||||
fi
|
|
||||||
|
|
||||||
usb_authorization_add_user "$OMARCHY_INSTALL_USER"
|
|
||||||
|
|
||||||
# Installs are followed by a reboot, so enable the daemon without starting it
|
|
||||||
# in the live ISO's chroot.
|
|
||||||
systemctl enable usbguard.service
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
# Shared by the root controller and session commands. These paths are fixed at
|
|
||||||
# privileged entrypoints; tests may source the functions in an isolated fixture.
|
|
||||||
TB_STATE=/var/lib/omarchy/thunderbolt-authorization/policy.json
|
|
||||||
TB_CONFIG=/var/lib/boltd/boltd.conf
|
|
||||||
TB_MARKER=/etc/omarchy/thunderbolt-authorization.enabled
|
|
||||||
TB_PENDING=/etc/omarchy/thunderbolt-authorization.pending
|
|
||||||
TB_RUNTIME=/run/omarchy-thunderbolt-authorization
|
|
||||||
TB_LOCK=/run/lock/omarchy-thunderbolt-authorization.lock
|
|
||||||
TB_SERVICE=omarchy-thunderbolt-authorization.service
|
|
||||||
TB_BOLT=org.freedesktop.bolt
|
|
||||||
TB_MANAGER=org.freedesktop.bolt1.Manager
|
|
||||||
TB_DEVICE=org.freedesktop.bolt1.Device
|
|
||||||
TB_DOMAIN=org.freedesktop.bolt1.Domain
|
|
||||||
TB_PATH=/org/freedesktop/bolt
|
|
||||||
TB_SYSFS=/sys/bus/thunderbolt/devices
|
|
||||||
# How often the daemon re-checks Bolt while there is no controller and no boot
|
|
||||||
# protection: well inside the 20 s a published snapshot stays valid.
|
|
||||||
TB_IDLE_RECHECK=10
|
|
||||||
TB_TRUST_JQ='def trusted($state): $state.trusted[.Uid] == {Uid,Name,Vendor} and
|
|
||||||
((.Secure | not) or (.Stored and (.Key | IN("have","new"))));'
|
|
||||||
|
|
||||||
tb_fail() { printf '%s\n' "$*" >&2; return 1; }
|
|
||||||
|
|
||||||
# Sleeps without forking on each tick: a pipe open for reading and writing
|
|
||||||
# never reaches EOF, so a timed read on it returns only on timeout or a signal.
|
|
||||||
tb_wait() {
|
|
||||||
[[ -n ${TB_TICK:-} ]] || exec {TB_TICK}<> <(:)
|
|
||||||
read -r -t "$1" -u "$TB_TICK" _ || true
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_controller_present() {
|
|
||||||
local domain
|
|
||||||
for domain in "$TB_SYSFS"/domain*; do
|
|
||||||
[[ -e $domain ]] && return 0
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_json_write() {
|
|
||||||
local target=$1 mode=${2:-600} temporary
|
|
||||||
temporary=$(mktemp "${target%/*}/.thunderbolt.XXXXXX") || return 1
|
|
||||||
if jq -cS . > "$temporary" && chmod "$mode" "$temporary" && sync "$temporary" &&
|
|
||||||
mv -fT -- "$temporary" "$target" && sync "${target%/*}"; then
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
rm -f -- "$temporary"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_state() {
|
|
||||||
jq -ce 'select(.version == 1 and (.trusted | type == "object") and (.enabled | type == "boolean"))' "$TB_STATE"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_bus() {
|
|
||||||
busctl --system --auto-start=no --timeout=10 --json=short "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_owner() {
|
|
||||||
tb_bus call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetNameOwner s "$TB_BOLT" | jq -er '.data[0]'
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_properties() {
|
|
||||||
tb_bus call "$1" "$2" org.freedesktop.DBus.Properties GetAll s "$3" | jq -ce '.data[0] | map_values(.data)'
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_set() {
|
|
||||||
local owner=$1 path=$2 interface=$3 key=$4 value=$5 signature=${6:-s} current
|
|
||||||
local -a values=()
|
|
||||||
current=$(tb_properties "$owner" "$path" "$interface") || return 1
|
|
||||||
if ! jq -e --arg key "$key" --argjson value "$value" '.[$key] == $value' <<< "$current" >/dev/null; then
|
|
||||||
if [[ $signature == "as" ]]; then
|
|
||||||
mapfile -t values < <(jq -r '.[]' <<< "$value")
|
|
||||||
tb_bus set-property "$owner" "$path" "$interface" "$key" as "${#values[@]}" "${values[@]}" >/dev/null || return 1
|
|
||||||
else
|
|
||||||
tb_bus set-property "$owner" "$path" "$interface" "$key" s "$(jq -r . <<< "$value")" >/dev/null || return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
# Bolt rejects no-op BootACL writes; even skipped writes are read back.
|
|
||||||
current=$(tb_properties "$owner" "$path" "$interface") || return 1
|
|
||||||
jq -e --arg key "$key" --argjson value "$value" '.[$key] == $value' <<< "$current" >/dev/null ||
|
|
||||||
tb_fail "Bolt did not retain the requested $key"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_inventory() {
|
|
||||||
local owner manager paths path item inode domains='[]' devices='[]' stored='[]'
|
|
||||||
owner=$(tb_owner) || return 1
|
|
||||||
manager=$(tb_properties "$owner" "$TB_PATH" "$TB_MANAGER") || return 1
|
|
||||||
paths=$(tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" ListDomains | jq -cer '.data[0]') || return 1
|
|
||||||
while IFS= read -r path; do
|
|
||||||
item=$(tb_properties "$owner" "$path" "$TB_DOMAIN" | jq -c --arg path "$path" '. + {path:$path}') || return 1
|
|
||||||
domains=$(jq -c --argjson item "$item" '. + [$item]' <<< "$domains") || return 1
|
|
||||||
done < <(jq -r '.[]' <<< "$paths")
|
|
||||||
paths=$(tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" ListDevices | jq -cer '.data[0]') || return 1
|
|
||||||
while IFS= read -r path; do
|
|
||||||
item=$(tb_properties "$owner" "$path" "$TB_DEVICE" | jq -c --arg path "$path" '. + {path:$path}') || return 1
|
|
||||||
[[ $(jq -r .Type <<< "$item") == "peripheral" ]] || continue
|
|
||||||
if jq -e .Stored <<< "$item" >/dev/null; then
|
|
||||||
stored=$(jq -c --argjson item "$item" '. + [$item]' <<< "$stored") || return 1
|
|
||||||
fi
|
|
||||||
[[ $(jq -r .Status <<< "$item") != "disconnected" ]] || continue
|
|
||||||
inode=$(stat -Lc '%i' -- "$(jq -r .SysfsPath <<< "$item")") || return 1
|
|
||||||
item=$(jq -c --arg inode "$inode" '. + {inode:$inode}' <<< "$item") || return 1
|
|
||||||
devices=$(jq -c --argjson item "$item" '. + [$item]' <<< "$devices") || return 1
|
|
||||||
done < <(jq -r '.[]' <<< "$paths")
|
|
||||||
[[ $(tb_owner) == "$owner" ]] || { tb_fail "Bolt restarted while reading its inventory"; return 1; }
|
|
||||||
jq -cn --arg owner "$owner" --argjson manager "$manager" --argjson domains "$domains" \
|
|
||||||
--argjson devices "$devices" --argjson stored "$stored" '
|
|
||||||
{owner:$owner,manager:$manager,domains:$domains,stored:$stored,devices:[$devices[] |
|
|
||||||
. + {Secure:((.Domain as $domain | any($domains[]; .Uid == $domain and .SecurityLevel == "secure"))
|
|
||||||
and (.AuthFlags | contains("nokey") | not))}]}'
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_read_inventory() {
|
|
||||||
local inventory
|
|
||||||
inventory=$(tb_inventory) || return 1
|
|
||||||
jq -e '.manager.AuthMode == "disabled"' <<< "$inventory" >/dev/null || {
|
|
||||||
tb_fail "Bolt automatic authorization is enabled; protection is not active"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
printf '%s\n' "$inventory"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_identity() {
|
|
||||||
jq -c --arg owner "$1" --arg generation "$2" \
|
|
||||||
'{owner:$owner,generation:$generation,path,inode,Uid,Name,Vendor,Parent,SysfsPath,ConnectTime}'
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_snapshot() {
|
|
||||||
local state inventory generation recovery=false
|
|
||||||
state=$(tb_state) || return 1
|
|
||||||
inventory=$(tb_read_inventory) || return 1
|
|
||||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
|
||||||
[[ -f ${TB_STATE%/*}/boot-recovery.json ]] && recovery=true
|
|
||||||
jq -c --argjson state "$state" --arg generation "$generation" --argjson recovery "$recovery" \
|
|
||||||
--arg error "${1:-}" --argjson time "$(date +%s)" "$TB_TRUST_JQ"'
|
|
||||||
.owner as $owner |
|
|
||||||
[.devices[] | . as $d | {
|
|
||||||
identity: ({owner:$owner,generation:$generation} + ($d | {path,inode,Uid,Name,Vendor,Parent,SysfsPath,ConnectTime})),
|
|
||||||
trusted: ($d | trusted($state)),
|
|
||||||
status:.Status,stored:.Stored,policy:.Policy,flags:.AuthFlags
|
|
||||||
}] as $devices |
|
|
||||||
{available:true,time:$time,owner:$owner,generation:$generation,devices:$devices,error:$error,
|
|
||||||
boot_protection:($state.boot_protection // false),
|
|
||||||
domains:[.domains[] | {Uid,SecurityLevel,IOMMU,BootACL}],
|
|
||||||
warnings: ([
|
|
||||||
(if $recovery then "A firmware boot-access change needs recovery. Boot protection is not confirmed." else empty end),
|
|
||||||
(.domains[] | if .SecurityLevel == "none" then
|
|
||||||
"This controller allows PCIe connections in firmware. Select user authorization in firmware settings; Omarchy cannot block its devices before their drivers load."
|
|
||||||
else empty end),
|
|
||||||
(.domains[] | .SecurityLevel as $level | select(["none","user","secure","dponly","usbonly","nopcie"] | index($level) | not) |
|
|
||||||
"The controller authorization mode could not be verified."),
|
|
||||||
(.domains[] | select($state.boot_protection and (.SysfsPath == "" or
|
|
||||||
(.SecurityLevel != "user" and .SecurityLevel != "secure") or (.BootACL | length == 0) or any(.BootACL[]; . != ""))) |
|
|
||||||
"The firmware boot allowlist is not confirmed empty on every controller. Check Thunderbolt Boot before relying on boot protection."),
|
|
||||||
($devices[] | select((.status | startswith("authorized")) and (.trusted | not) and (.flags | contains("boot"))) |
|
|
||||||
"An untrusted device was authorized by firmware before Linux started. Disconnect it safely to apply approval on its next connection.")
|
|
||||||
] | unique)}' <<< "$inventory"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_current() {
|
|
||||||
local expected=$1 inventory owner generation device identity
|
|
||||||
inventory=$(tb_read_inventory) || return 1
|
|
||||||
owner=$(jq -r .owner <<< "$inventory")
|
|
||||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
|
||||||
while IFS= read -r device; do
|
|
||||||
identity=$(tb_identity "$owner" "$generation" <<< "$device") || return 1
|
|
||||||
if jq -e --argjson expected "$expected" '. == $expected' <<< "$identity" >/dev/null; then
|
|
||||||
jq -cn --arg owner "$owner" --argjson device "$device" '{owner:$owner,device:$device}'
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done < <(jq -c '.devices[]' <<< "$inventory")
|
|
||||||
tb_fail "That Thunderbolt device was removed or changed. Open its latest notification."
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_prepare_key() (
|
|
||||||
local identity=$1 current key key_fd
|
|
||||||
current=$(tb_current "$identity") || return 1
|
|
||||||
# Bolt creates keys for stored devices itself. For a new device, disabled
|
|
||||||
# AuthMode prevents enrollment before authorization. Seed the kernel key so
|
|
||||||
# Authorize provisions it and the subsequent enrollment imports it into Bolt.
|
|
||||||
jq -e '.device.Secure and (.device.Stored | not)' <<< "$current" >/dev/null || return 0
|
|
||||||
cd -- "$(jq -r .device.SysfsPath <<< "$current")" || return 1
|
|
||||||
[[ $(stat -Lc '%i' .) == "$(jq -r .device.inode <<< "$current")" &&
|
|
||||||
$(cat unique_id) == "$(jq -r .device.Uid <<< "$current")" ]] || {
|
|
||||||
tb_fail 'The Thunderbolt connection changed before key setup'; return 1;
|
|
||||||
}
|
|
||||||
# Pin the attribute in the original directory; do not follow a replacement
|
|
||||||
# connection at the same path, or truncate an existing key on a retry.
|
|
||||||
exec {key_fd}<>key || return 1
|
|
||||||
key=$(cat key) || return 1
|
|
||||||
if [[ -z $key ]]; then
|
|
||||||
[[ $(cat authorized) == "0" ]] || { tb_fail 'Reconnect the device before saving secure approval'; return 1; }
|
|
||||||
key=$(openssl rand -hex 32) || return 1
|
|
||||||
[[ $key =~ ^[0-9a-f]{64}$ ]] || return 1
|
|
||||||
tb_current "$identity" >/dev/null || return 1
|
|
||||||
printf '%s' "$key" >&"$key_fd" || return 1
|
|
||||||
[[ $(cat key) == "$key" ]] || { tb_fail 'Could not verify the Thunderbolt key'; return 1; }
|
|
||||||
else
|
|
||||||
[[ $key =~ ^[0-9a-fA-F]{64}$ ]] || { tb_fail 'The Thunderbolt key is invalid'; return 1; }
|
|
||||||
fi
|
|
||||||
)
|
|
||||||
|
|
||||||
tb_approve() {
|
|
||||||
local identity=$1 permanent=$2 initialize=${3:-false} current owner path status state
|
|
||||||
state=$(tb_state) || return 1
|
|
||||||
jq -e .enabled <<< "$state" >/dev/null || { tb_fail "Thunderbolt protection is disabled"; return 1; }
|
|
||||||
current=$(tb_current "$identity") || return 1
|
|
||||||
owner=$(jq -r .owner <<< "$current")
|
|
||||||
path=$(jq -r .device.path <<< "$current")
|
|
||||||
status=$(jq -r .device.Status <<< "$current")
|
|
||||||
case "$status" in
|
|
||||||
connected|auth-error)
|
|
||||||
if [[ $permanent == "true" ]]; then
|
|
||||||
if [[ $initialize == "true" ]]; then
|
|
||||||
tb_prepare_key "$identity" || return 1
|
|
||||||
else
|
|
||||||
jq -e '(.device.Secure | not) or (.device.Stored and (.device.Key | IN("have","new")))' <<< "$current" >/dev/null || {
|
|
||||||
tb_fail 'A secure key requires explicit device approval'; return 1;
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
current=$(tb_current "$identity") || return 1
|
|
||||||
tb_bus call "$owner" "$path" "$TB_DEVICE" Authorize s '' >/dev/null || return 1 ;;
|
|
||||||
authorized|authorized-newkey|authorized-secure) ;;
|
|
||||||
*) tb_fail "Device authorization is still in progress; try again"; return 1 ;;
|
|
||||||
esac
|
|
||||||
current=$(tb_current "$identity") || return 1
|
|
||||||
jq -e '.device.Status | IN("authorized","authorized-newkey","authorized-secure")' <<< "$current" >/dev/null || {
|
|
||||||
tb_fail "Could not confirm device authorization; try again"; return 1;
|
|
||||||
}
|
|
||||||
if [[ $permanent == "true" ]]; then
|
|
||||||
if ! jq -e .device.Stored <<< "$current" >/dev/null; then
|
|
||||||
# Save enrollment intent before Bolt writes its record, so a lost reply
|
|
||||||
# can be retried without confusing our record with a pre-existing one.
|
|
||||||
state=$(jq --argjson device "$(jq -c .device <<< "$current")" \
|
|
||||||
'.enrolled[$device.Uid] = ($device | {Uid,Name,Vendor})' <<< "$state") || return 1
|
|
||||||
tb_json_write "$TB_STATE" <<< "$state" || return 1
|
|
||||||
tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" EnrollDevice sss "$(jq -r .device.Uid <<< "$current")" manual '' >/dev/null || return 1
|
|
||||||
fi
|
|
||||||
current=$(tb_current "$identity") || return 1
|
|
||||||
jq -e .device.Stored <<< "$current" >/dev/null || { tb_fail "Could not confirm saved device information; try again"; return 1; }
|
|
||||||
jq -e '(.device.Secure | not) or (.device.Key | IN("have","new"))' <<< "$current" >/dev/null || {
|
|
||||||
tb_fail 'Secure approval has no saved key. Safely disconnect, reconnect, and approve the device again.'; return 1;
|
|
||||||
}
|
|
||||||
jq --argjson device "$(jq -c .device <<< "$current")" '
|
|
||||||
if .enrolled[$device.Uid] == ($device | {Uid,Name,Vendor}) then
|
|
||||||
.enrolled[$device.Uid] = ($device | {Uid,Name,Vendor,StoreTime})
|
|
||||||
else . end |
|
|
||||||
.trusted[$device.Uid] = ($device | {Uid,Name,Vendor})' <<< "$state" |
|
|
||||||
tb_json_write "$TB_STATE" || return 1
|
|
||||||
fi
|
|
||||||
tb_snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_boot_apply() {
|
|
||||||
local saved=$1 inventory owner item uid policies acls value path
|
|
||||||
inventory=$(tb_inventory) || return 1
|
|
||||||
owner=$(jq -r .owner <<< "$inventory")
|
|
||||||
jq -e --argjson saved "$saved" '
|
|
||||||
(($saved.policies | keys) - [.stored[].Uid] | length == 0) and
|
|
||||||
(($saved.acls | keys) - [.domains[] | select(.SysfsPath != "") | .Uid] | length == 0)' <<< "$inventory" >/dev/null || {
|
|
||||||
tb_fail "Reconnect the original controllers and restore missing Bolt records before recovery"; return 1;
|
|
||||||
}
|
|
||||||
while IFS= read -r item; do
|
|
||||||
uid=$(jq -r .Uid <<< "$item")
|
|
||||||
value=$(jq -c --arg uid "$uid" '.policies[$uid] // empty' <<< "$saved") || return 1
|
|
||||||
[[ -n $value ]] || continue
|
|
||||||
tb_set "$owner" "$(jq -r .path <<< "$item")" "$TB_DEVICE" Policy "$value" || return 1
|
|
||||||
done < <(jq -c '.stored[]' <<< "$inventory")
|
|
||||||
# Changing a device policy can update firmware ACLs, so restore ACLs last.
|
|
||||||
while IFS= read -r item; do
|
|
||||||
uid=$(jq -r .Uid <<< "$item")
|
|
||||||
value=$(jq -c --arg uid "$uid" '.acls[$uid] // empty' <<< "$saved") || return 1
|
|
||||||
[[ -n $value ]] || continue
|
|
||||||
[[ $(jq length <<< "$value") == "$(jq '.BootACL | length' <<< "$item")" ]] || {
|
|
||||||
tb_fail "Firmware boot allowlist capacity changed"; return 1;
|
|
||||||
}
|
|
||||||
tb_set "$owner" "$(jq -r .path <<< "$item")" "$TB_DOMAIN" BootACL "$value" as || return 1
|
|
||||||
done < <(jq -c '.domains[]' <<< "$inventory")
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_reconcile() {
|
|
||||||
local inventory state owner generation device identity error='' saved
|
|
||||||
state=$(tb_state) || return 1
|
|
||||||
inventory=$(tb_read_inventory) || return 1
|
|
||||||
owner=$(jq -r .owner <<< "$inventory")
|
|
||||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
|
||||||
if jq -e .boot_protection <<< "$state" >/dev/null; then
|
|
||||||
saved=$(jq -c '{policies:(.stored | map({key:.Uid,value:"manual"}) | from_entries),
|
|
||||||
acls:(.domains | map(select(.SysfsPath != "" and (.SecurityLevel == "user" or .SecurityLevel == "secure") and (.BootACL|length>0))) |
|
|
||||||
map({key:.Uid,value:(.BootACL | map(""))}) | from_entries)}' <<< "$inventory") || return 1
|
|
||||||
tb_boot_apply "$saved" || return 1
|
|
||||||
fi
|
|
||||||
while IFS= read -r device; do
|
|
||||||
identity=$(tb_identity "$owner" "$generation" <<< "$device") || return 1
|
|
||||||
if ! tb_approve "$identity" true >/dev/null; then
|
|
||||||
error='A trusted Thunderbolt device could not be restored. Check the system service log.'
|
|
||||||
fi
|
|
||||||
done < <(jq -c --argjson state "$state" "$TB_TRUST_JQ"'.devices | sort_by(.SysfsPath | length)[] |
|
|
||||||
select((.Status == "connected" or .Status == "auth-error") and (.AuthFlags | contains("nopcie") | not)) |
|
|
||||||
# Never silently initialize a missing secure binding on a later connection.
|
|
||||||
select(trusted($state))' <<< "$inventory")
|
|
||||||
tb_snapshot "$error"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_daemon() {
|
|
||||||
local snapshot error policy idle_policy='' idle_since=0
|
|
||||||
install -d -m 755 "$TB_RUNTIME"
|
|
||||||
cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation"
|
|
||||||
chmod 644 "$TB_RUNTIME/generation"
|
|
||||||
while [[ -f $TB_MARKER ]]; do
|
|
||||||
policy=''
|
|
||||||
[[ -r $TB_STATE ]] && IFS= read -r -d '' policy < "$TB_STATE" || true
|
|
||||||
# With no controller, no device and no boot protection there is nothing to
|
|
||||||
# restore or reapply, so Bolt is reconciled every TB_IDLE_RECHECK seconds
|
|
||||||
# instead of every tick. A controller appearing, a policy change or a boot
|
|
||||||
# recovery checkpoint brings back the full check at once.
|
|
||||||
if [[ -n $idle_policy && $policy == "$idle_policy" && ! -e ${TB_STATE%/*}/boot-recovery.json ]] &&
|
|
||||||
(( EPOCHSECONDS - idle_since >= 0 && EPOCHSECONDS - idle_since < TB_IDLE_RECHECK )) &&
|
|
||||||
! tb_controller_present; then
|
|
||||||
tb_wait 2
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
idle_policy=''
|
|
||||||
(
|
|
||||||
flock -x 9
|
|
||||||
if snapshot=$(tb_reconcile 2> "$TB_RUNTIME/error"); then
|
|
||||||
tb_json_write "$TB_RUNTIME/snapshot.json" 644 <<< "$snapshot"
|
|
||||||
else
|
|
||||||
cat "$TB_RUNTIME/error" >&2
|
|
||||||
error=$(< "$TB_RUNTIME/error")
|
|
||||||
jq -cn --arg error "$error" --argjson time "$(date +%s)" '{available:false,time:$time,error:$error}' |
|
|
||||||
tb_json_write "$TB_RUNTIME/snapshot.json" 644
|
|
||||||
fi
|
|
||||||
) 9> "$TB_LOCK"
|
|
||||||
snapshot=''
|
|
||||||
[[ -r $TB_RUNTIME/snapshot.json ]] && IFS= read -r -d '' snapshot < "$TB_RUNTIME/snapshot.json" || true
|
|
||||||
if [[ -n $policy ]] && jq -e '.available == true and .domains == [] and .devices == [] and
|
|
||||||
.warnings == [] and .error == "" and .boot_protection == false' <<< "$snapshot" >/dev/null 2>&1; then
|
|
||||||
idle_policy=$policy idle_since=$EPOCHSECONDS
|
|
||||||
fi
|
|
||||||
tb_wait 2
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_public_snapshot() {
|
|
||||||
local snapshot generation
|
|
||||||
snapshot=$(cat "$TB_RUNTIME/snapshot.json") || return 1
|
|
||||||
generation=$(< "$TB_RUNTIME/generation") || return 1
|
|
||||||
jq -e --argjson now "$(date +%s)" --arg generation "$generation" \
|
|
||||||
'.available and .generation == $generation and .time <= $now and $now - .time < 20' <<< "$snapshot" >/dev/null || {
|
|
||||||
tb_fail 'Thunderbolt policy is unavailable or could not be verified.'; return 1;
|
|
||||||
}
|
|
||||||
printf '%s\n' "$snapshot"
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
# Session-side notification and terminal dialog functions.
|
|
||||||
|
|
||||||
tb_requests() {
|
|
||||||
TB_REQUESTS="$HOME/.local/state/omarchy/thunderbolt-authorization/requests"
|
|
||||||
mkdir -p -m 700 "$TB_REQUESTS"
|
|
||||||
chmod 700 "${TB_REQUESTS%/*}" "$TB_REQUESTS"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_safe_text() {
|
|
||||||
jq -r 'gsub("[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]"; "?")'
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_notify() {
|
|
||||||
local token=$1 retry=${2:-false} title description
|
|
||||||
title=$(omarchy-text "Thunderbolt accessory blocked")
|
|
||||||
description=$(omarchy-text "Click to review it. Keep it blocked unless you recognize the device.")
|
|
||||||
if [[ $retry == "true" ]]; then
|
|
||||||
title=$(omarchy-text "Thunderbolt approval needs retry")
|
|
||||||
description=$(omarchy-text "The last approval could not be confirmed. Click to retry it.")
|
|
||||||
fi
|
|
||||||
omarchy-notification-wait 1 >/dev/null || return 1
|
|
||||||
timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical --glyph '' \
|
|
||||||
"$title" "$description" --exec omarchy-launch-floating-terminal-with-presentation \
|
|
||||||
omarchy-thunderbolt-authorization-review "$token"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_attention() {
|
|
||||||
omarchy-notification-wait 1 >/dev/null || return 1
|
|
||||||
timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical \
|
|
||||||
"$(omarchy-text "Thunderbolt protection needs attention")" "$(omarchy-text "The approval or firmware policy could not be confirmed. Click for details.")" \
|
|
||||||
--exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization status
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_setup_notice() {
|
|
||||||
omarchy-notification-wait 1 >/dev/null || return 1
|
|
||||||
timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical \
|
|
||||||
"$(omarchy-text "Finish Thunderbolt protection setup")" "$(omarchy-text "A connected accessory needs secure enrollment. Protection is not enabled. Click to finish safely.")" \
|
|
||||||
--exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_token() {
|
|
||||||
local digest
|
|
||||||
digest=$(jq -cS --arg watch "$2" '{watch:$watch,identity:.}' <<< "$1" | sha256sum) || return 1
|
|
||||||
printf 'request-%s\n' "${digest%% *}"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_request_read() {
|
|
||||||
[[ -f $1 && ! -L $1 ]] || return 1
|
|
||||||
jq -ce 'select(.identity | type == "object")' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_deliver() {
|
|
||||||
local path=$1 request token
|
|
||||||
request=$(tb_request_read "$path") || return 1
|
|
||||||
if ! jq -e '.notified // false' <<< "$request" >/dev/null; then
|
|
||||||
token=${path##*/}
|
|
||||||
if tb_notify "${token%.json}" "$(jq -r '.approval != null' <<< "$request")"; then
|
|
||||||
jq '.notified=true' <<< "$request" | tb_json_write "$path"
|
|
||||||
else
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_request_apply() {
|
|
||||||
# The watcher has no terminal. Polkit permits this one fixed helper for an
|
|
||||||
# active local administrator; it cannot dispatch general setup operations.
|
|
||||||
pkexec --disable-internal-agent /usr/bin/omarchy-thunderbolt-authorization-approve "$1" "$2"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_request_process() {
|
|
||||||
local path=$1 request identity permanent result error_file error
|
|
||||||
request=$(tb_request_read "$path") || return 1
|
|
||||||
if jq -e '.pending // false' <<< "$request" >/dev/null; then
|
|
||||||
identity=$(jq -c .identity <<< "$request")
|
|
||||||
permanent=$(jq -r '.approval == "Always allow this device"' <<< "$request")
|
|
||||||
error_file=$(mktemp "$TB_REQUESTS/.error.XXXXXX") || return 1
|
|
||||||
if result=$(tb_request_apply "$permanent" "$identity" 2> "$error_file") &&
|
|
||||||
jq -e --argjson identity "$identity" --argjson permanent "$permanent" \
|
|
||||||
'any(.devices[]; .identity==$identity and (.status | IN("authorized","authorized-newkey","authorized-secure")) and
|
|
||||||
($permanent == false or .trusted))' <<< "$result" >/dev/null; then
|
|
||||||
jq --argjson result "$result" '.pending=false | .result=$result | del(.error)' <<< "$request" |
|
|
||||||
tb_json_write "$path" || return 1
|
|
||||||
else
|
|
||||||
error=$(cat "$error_file")
|
|
||||||
[[ -n $error ]] || error='Could not confirm approval. Click the retry notification.'
|
|
||||||
jq --arg error "$error" '.pending=false | .notified=false | .error=$error' <<< "$request" |
|
|
||||||
tb_json_write "$path" || return 1
|
|
||||||
fi
|
|
||||||
rm -f -- "$error_file"
|
|
||||||
fi
|
|
||||||
tb_deliver "$path"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_scan() {
|
|
||||||
local watch=$1 snapshot path request device identity token existing
|
|
||||||
snapshot=$(tb_public_snapshot) || return 1
|
|
||||||
(
|
|
||||||
flock -x 9
|
|
||||||
for path in "$TB_REQUESTS"/request-*.json; do
|
|
||||||
[[ -e $path || -L $path ]] || continue
|
|
||||||
if ! request=$(tb_request_read "$path"); then
|
|
||||||
rm -f -- "$path"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
identity=$(jq -c .identity <<< "$request")
|
|
||||||
device=$(jq -c --argjson identity "$identity" '.devices[] | select(.identity==$identity)' <<< "$snapshot") || exit 1
|
|
||||||
if [[ -z $device ]] || { ! jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null &&
|
|
||||||
! jq -e '.approval != null' <<< "$request" >/dev/null; }; then
|
|
||||||
rm -- "$path"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [[ $(jq -r .watch <<< "$request") != "$watch" ]]; then
|
|
||||||
rm -- "$path"
|
|
||||||
# Resume an interrupted attempt only for the same exact connection.
|
|
||||||
jq -e '.approval != null and .result == null' <<< "$request" >/dev/null || continue
|
|
||||||
token=$(tb_token "$identity" "$watch") || exit 1
|
|
||||||
path="$TB_REQUESTS/$token.json"
|
|
||||||
request=$(jq -c --arg watch "$watch" '.watch=$watch | .notified=false' <<< "$request") || exit 1
|
|
||||||
tb_json_write "$path" <<< "$request" || exit 1
|
|
||||||
fi
|
|
||||||
tb_request_process "$path" || true
|
|
||||||
done
|
|
||||||
snapshot=$(tb_public_snapshot) || exit 1
|
|
||||||
while IFS= read -r device; do
|
|
||||||
identity=$(jq -c .identity <<< "$device")
|
|
||||||
token=$(tb_token "$identity" "$watch") || exit 1
|
|
||||||
path="$TB_REQUESTS/$token.json"
|
|
||||||
if [[ ! -f $path ]]; then
|
|
||||||
jq -cn --argjson identity "$identity" --arg watch "$watch" \
|
|
||||||
'{identity:$identity,watch:$watch,notified:false}' | tb_json_write "$path" || exit 1
|
|
||||||
if ! tb_deliver "$path"; then
|
|
||||||
rm -f -- "$path"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done < <(jq -c '.devices[] | select((.trusted | not) and (.status | IN("connected","auth-error")) and
|
|
||||||
(.flags | contains("nopcie") | not))' <<< "$snapshot")
|
|
||||||
) 9> "$TB_REQUESTS/.lock" || return 1
|
|
||||||
printf '%s\n' "$snapshot"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_watch() {
|
|
||||||
local watch snapshot warning warned='' failures=0 text generation clean='' clean_generation='' clean_time=0
|
|
||||||
tb_requests
|
|
||||||
watch=$(cat /proc/sys/kernel/random/uuid)
|
|
||||||
while true; do
|
|
||||||
if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then
|
|
||||||
if [[ $warned != "pending" ]] && tb_setup_notice; then warned=pending; fi
|
|
||||||
clean=''
|
|
||||||
tb_wait 2
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
# The last scan's snapshot, unchanged, still fresh and from the same daemon,
|
|
||||||
# with no device, warning or request in flight, has nothing to deliver.
|
|
||||||
text='' generation=''
|
|
||||||
[[ -r $TB_RUNTIME/snapshot.json ]] && IFS= read -r -d '' text < "$TB_RUNTIME/snapshot.json" || true
|
|
||||||
[[ -r $TB_RUNTIME/generation ]] && IFS= read -r generation < "$TB_RUNTIME/generation" || true
|
|
||||||
if [[ -n $clean && ${text%$'\n'} == "$clean" && $generation == "$clean_generation" ]] &&
|
|
||||||
(( EPOCHSECONDS - clean_time >= 0 && EPOCHSECONDS - clean_time < 20 )) &&
|
|
||||||
! compgen -G "$TB_REQUESTS/request-*.json" >/dev/null; then
|
|
||||||
tb_wait 2
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
clean=''
|
|
||||||
if snapshot=$(tb_scan "$watch"); then
|
|
||||||
failures=0
|
|
||||||
warning=$(jq -c '[.generation,.warnings,.error]' <<< "$snapshot")
|
|
||||||
if jq -e '(.warnings | length > 0) or .error != ""' <<< "$snapshot" >/dev/null; then
|
|
||||||
if [[ $warning != "$warned" ]] && tb_attention; then
|
|
||||||
warned=$warning
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warned=''
|
|
||||||
if jq -e '.devices == []' <<< "$snapshot" >/dev/null; then
|
|
||||||
IFS=' ' read -r clean_generation clean_time <<< "$(jq -r '"\(.generation) \(.time)"' <<< "$snapshot")"
|
|
||||||
[[ $clean_time =~ ^[0-9]+$ ]] && clean=$snapshot
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
failures=$((failures + 1))
|
|
||||||
if (( failures >= 3 )) && [[ $warned != "unavailable" ]] && tb_attention; then
|
|
||||||
warned=unavailable
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
tb_wait 2
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_find_device() {
|
|
||||||
local snapshot
|
|
||||||
snapshot=$(tb_public_snapshot) || return 1
|
|
||||||
jq -ce --argjson identity "$1" '.devices[] | select(.identity==$identity)' <<< "$snapshot" ||
|
|
||||||
tb_fail 'That Thunderbolt device was removed or changed. Open its latest notification.'
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_review() {
|
|
||||||
local token=$1 path request identity device choice latest index
|
|
||||||
[[ $token =~ ^request-[0-9a-f]{64}$ ]] || { tb_fail 'Invalid or expired Thunderbolt request'; return 1; }
|
|
||||||
tb_requests
|
|
||||||
path="$TB_REQUESTS/$token.json"
|
|
||||||
request=$(tb_request_read "$path") || return 1
|
|
||||||
identity=$(jq -c .identity <<< "$request")
|
|
||||||
device=$(tb_find_device "$identity") || return 1
|
|
||||||
choice=$(jq -r '.approval // empty' <<< "$request")
|
|
||||||
if [[ -z $choice ]]; then
|
|
||||||
jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null || { tb_fail 'This device is no longer blocked'; return 1; }
|
|
||||||
gum style --foreground 212 --bold 'Blocked Thunderbolt accessory'
|
|
||||||
printf '\nThese details come from the device and can be forged:\n'
|
|
||||||
for index in Name Vendor Uid; do
|
|
||||||
printf '%s: ' "$index"
|
|
||||||
jq --arg key "$index" '.[$key]' <<< "$identity" | tb_safe_text
|
|
||||||
done
|
|
||||||
printf '\nApproval gives this accessory access to its PCIe drivers.\n\n'
|
|
||||||
choice=$(gum choose 'Allow once' 'Always allow this device' 'Keep blocked') || return 0
|
|
||||||
fi
|
|
||||||
case "$choice" in
|
|
||||||
'Allow once'|'Always allow this device'|'Keep blocked') ;;
|
|
||||||
*) tb_fail 'Invalid approval choice'; return 1 ;;
|
|
||||||
esac
|
|
||||||
(
|
|
||||||
flock -x 9
|
|
||||||
latest=$(tb_request_read "$path") || exit 1
|
|
||||||
[[ $latest == "$request" ]] || { tb_fail 'This request changed. Open the latest notification.'; exit 1; }
|
|
||||||
device=$(tb_find_device "$identity") || exit 1
|
|
||||||
if [[ $choice == "Keep blocked" ]]; then
|
|
||||||
printf 'Thunderbolt accessory remains blocked.\n'
|
|
||||||
else
|
|
||||||
if ! jq -e '.approval != null' <<< "$request" >/dev/null; then
|
|
||||||
jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null || exit 1
|
|
||||||
fi
|
|
||||||
jq --arg choice "$choice" '.approval=$choice | .pending=true | del(.error,.result)' <<< "$request" |
|
|
||||||
tb_json_write "$path"
|
|
||||||
fi
|
|
||||||
) 9> "$TB_REQUESTS/.lock" || return 1
|
|
||||||
if [[ $choice != "Keep blocked" ]]; then
|
|
||||||
for ((index=0; index<60; index++)); do
|
|
||||||
latest=$(tb_request_read "$path") || return 1
|
|
||||||
if jq -e '.result != null' <<< "$latest" >/dev/null; then
|
|
||||||
rm -- "$path"
|
|
||||||
if [[ $choice == "Always allow this device" ]]; then
|
|
||||||
printf 'Thunderbolt accessory trusted.\n'
|
|
||||||
else
|
|
||||||
printf 'Thunderbolt accessory allowed for this connection.\n'
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
elif jq -e '.error != null' <<< "$latest" >/dev/null; then
|
|
||||||
jq .error <<< "$latest" | tb_safe_text >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
tb_fail 'Approval is still pending. The watcher will retain and retry the request.'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_status() {
|
|
||||||
local snapshot
|
|
||||||
if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then
|
|
||||||
printf 'Thunderbolt protection is deferred. Previous Bolt behavior remains active.\nKeep a keyboard/input device available independently of Thunderbolt. Safely disconnect the Thunderbolt accessories, then run Setup > Security > Thunderbolt. Reconnect each accessory and choose Always allow to save its secure key.\n'
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
snapshot=$(tb_public_snapshot) || return 1
|
|
||||||
printf 'Thunderbolt device approval is enabled.\nTrusted accessories reconnect automatically; new accessories require approval.\n'
|
|
||||||
if jq -e '.domains | length == 0' <<< "$snapshot" >/dev/null; then
|
|
||||||
printf 'No Thunderbolt controller is currently visible. Its firmware policy will be checked when it appears.\n'
|
|
||||||
fi
|
|
||||||
jq '.warnings[], (.error | select(. != ""))' <<< "$snapshot" | while IFS= read -r warning; do
|
|
||||||
printf 'Attention: '
|
|
||||||
tb_safe_text <<< "$warning"
|
|
||||||
done
|
|
||||||
printf 'Firmware boot access is separate; see the Security manual before relying on pre-boot protection.\n'
|
|
||||||
}
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
# Root setup functions. The caller sources thunderbolt-policy.sh first.
|
|
||||||
|
|
||||||
tb_config_authmode() {
|
|
||||||
local path=$1
|
|
||||||
if [[ -f $path ]]; then
|
|
||||||
awk '
|
|
||||||
/^[[:space:]]*\[/ { section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/) }
|
|
||||||
section && /^[[:space:]]*AuthMode[[:space:]]*=/ {
|
|
||||||
sub(/^[^=]*=[[:space:]]*/, ""); sub(/[[:space:]]*$/, ""); mode=$0
|
|
||||||
}
|
|
||||||
END { print mode == "" ? "enabled" : mode }
|
|
||||||
' "$path"
|
|
||||||
else
|
|
||||||
printf 'enabled\n'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_config_write() {
|
|
||||||
local path=$1 mode=$2 temporary
|
|
||||||
[[ $mode == "enabled" || $mode == "disabled" ]] || return 1
|
|
||||||
install -d -m 700 "${path%/*}" || return 1
|
|
||||||
temporary=$(mktemp "${path%/*}/.config.XXXXXX") || return 1
|
|
||||||
if awk -v mode="$mode" '
|
|
||||||
/^[[:space:]]*\[/ {
|
|
||||||
section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/)
|
|
||||||
if (section) { found=1; print; print "AuthMode = " mode; next }
|
|
||||||
}
|
|
||||||
section && /^[[:space:]]*AuthMode[[:space:]]*=/ { next }
|
|
||||||
{ print }
|
|
||||||
END { if (!found) print "\n[config]\nAuthMode = " mode }
|
|
||||||
' "$(if [[ -e $path ]]; then printf '%s' "$path"; else printf /dev/null; fi)" > "$temporary" &&
|
|
||||||
sync "$temporary" && mv -fT -- "$temporary" "$path" && sync "${path%/*}"; then
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
rm -f -- "$temporary"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_guard() {
|
|
||||||
if [[ -f $TB_MARKER ]]; then
|
|
||||||
tb_state | jq -e .enabled >/dev/null || { tb_fail 'Thunderbolt policy and enable marker disagree'; return 1; }
|
|
||||||
tb_config_write "$TB_CONFIG" disabled
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_capture() {
|
|
||||||
local path uid name vendor trusted='{}'
|
|
||||||
for path in "$TB_SYSFS"/*; do
|
|
||||||
[[ -f $path/unique_id && $path != *-0 ]] || continue
|
|
||||||
uid=$(cat "$path/unique_id") || return 1
|
|
||||||
# Bolt uses the numeric attribute when a descriptive name is absent.
|
|
||||||
if [[ -e $path/device_name ]]; then
|
|
||||||
name=$(cat "$path/device_name") || return 1
|
|
||||||
else
|
|
||||||
name=$(cat "$path/device") || return 1
|
|
||||||
fi
|
|
||||||
if [[ -e $path/vendor_name ]]; then
|
|
||||||
vendor=$(cat "$path/vendor_name") || return 1
|
|
||||||
else
|
|
||||||
vendor=$(cat "$path/vendor") || return 1
|
|
||||||
fi
|
|
||||||
[[ -n $uid ]] || { tb_fail 'A connected Thunderbolt device has no identity'; return 1; }
|
|
||||||
trusted=$(jq -c --arg uid "$uid" --arg name "$name" --arg vendor "$vendor" \
|
|
||||||
'.[$uid] = {Uid:$uid,Name:$name,Vendor:$vendor}' <<< "$trusted") || return 1
|
|
||||||
done
|
|
||||||
printf '%s\n' "$trusted"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_capture_ready() {
|
|
||||||
local fresh=${1:-false} offline=${2:-false} path uid inode inventory=''
|
|
||||||
# Do not relax an active policy, or capture new devices when re-enabling an
|
|
||||||
# existing policy. This preflight only guards the initial trust inventory.
|
|
||||||
[[ ! -f $TB_MARKER ]] || return 0
|
|
||||||
if [[ -f $TB_STATE && ! -f $TB_PENDING && $fresh != "true" ]]; then
|
|
||||||
tb_state | jq -e '.enrollment_pending // false' >/dev/null || return 0
|
|
||||||
fi
|
|
||||||
for path in "$TB_SYSFS"/*; do
|
|
||||||
[[ -f $path/unique_id && $path != *-0 && -e $path/key ]] || continue
|
|
||||||
uid=$(cat "$path/unique_id") || return 1
|
|
||||||
[[ $uid =~ ^[0-9a-fA-F-]{36}$ ]] || { tb_fail 'A connected Thunderbolt device has an invalid identity'; return 1; }
|
|
||||||
path=$(realpath -e -- "$path") || return 1
|
|
||||||
inode=$(stat -Lc '%i' -- "$path") || return 1
|
|
||||||
# A key file alone can be orphaned after a failed Bolt record write. Ask
|
|
||||||
# Bolt whether this exact connection has a stored record and key. Offline
|
|
||||||
# prepare must not accept the live installer's daemon as the target store.
|
|
||||||
if [[ $offline != "true" && -z $inventory ]]; then
|
|
||||||
inventory=$(tb_inventory) || inventory='{}'
|
|
||||||
fi
|
|
||||||
if [[ $offline == "true" ]] || ! jq -e --arg uid "$uid" --arg path "$path" --arg inode "$inode" \
|
|
||||||
'any(.devices[]?; .Uid==$uid and .SysfsPath==$path and .inode==$inode and .Stored and (.Key | IN("have","new")))' <<< "$inventory" >/dev/null; then
|
|
||||||
# An active keyless dock cannot receive a key without disconnecting it.
|
|
||||||
# Preserve input/storage and finish installation with setup deferred.
|
|
||||||
install -d -m 755 "${TB_PENDING%/*}" || return 1
|
|
||||||
install -m 644 /dev/null "$TB_PENDING" || return 1
|
|
||||||
printf 'Thunderbolt protection is deferred: safely disconnect accessories without saved secure keys, keep independent input available, then run Setup > Security > Thunderbolt.\n' >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_prepare() {
|
|
||||||
local fresh=${1:-false} state='{}' trusted mode
|
|
||||||
if [[ -f $TB_STATE ]]; then
|
|
||||||
state=$(tb_state) || return 1
|
|
||||||
fi
|
|
||||||
if [[ ! -f $TB_STATE || -f $TB_PENDING || $fresh == "true" ]] || jq -e '.enrollment_pending // false' <<< "$state" >/dev/null; then
|
|
||||||
trusted=$(tb_capture) || return 1
|
|
||||||
mode=$(jq -r '.original_authmode // empty' <<< "$state") || return 1
|
|
||||||
[[ -n $mode ]] || mode=$(tb_config_authmode "$TB_CONFIG") || return 1
|
|
||||||
[[ $mode == "enabled" || $mode == "disabled" ]] || { tb_fail 'Unsupported existing Bolt authorization mode'; return 1; }
|
|
||||||
state=$(jq -c --arg mode "$mode" --argjson trusted "$trusted" \
|
|
||||||
'del(.enrollment_pending) | . + {version:1,enabled:true,trusted:$trusted,original_authmode:$mode}' <<< "$state") || return 1
|
|
||||||
else
|
|
||||||
mode=$(jq -r .original_authmode <<< "$state") || return 1
|
|
||||||
[[ $mode == "enabled" || $mode == "disabled" ]] || { tb_fail 'Unsupported existing Bolt authorization mode'; return 1; }
|
|
||||||
state=$(jq -c '.enabled=true' <<< "$state") || return 1
|
|
||||||
fi
|
|
||||||
install -d -m 700 "${TB_STATE%/*}" || return 1
|
|
||||||
tb_json_write "$TB_STATE" <<< "$state" || return 1
|
|
||||||
tb_config_write "$TB_CONFIG" disabled || return 1
|
|
||||||
install -d -m 755 "${TB_MARKER%/*}" || return 1
|
|
||||||
install -m 644 /dev/null "$TB_MARKER"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_boot_saved() {
|
|
||||||
jq -c '{policies:(.stored | map({key:.Uid,value:.Policy}) | from_entries),
|
|
||||||
acls:(.domains | map({key:.Uid,value:.BootACL}) | from_entries)}'
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_boot_change() {
|
|
||||||
local enabled=$1 state inventory saved after desired backup="${TB_STATE%/*}/boot-recovery.json"
|
|
||||||
state=$(tb_state) || return 1
|
|
||||||
jq -e .enabled <<< "$state" >/dev/null || { tb_fail 'Enable Thunderbolt device authorization first'; return 1; }
|
|
||||||
if [[ $enabled == "false" ]] && ! jq -e .boot_protection <<< "$state" >/dev/null; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
[[ ! -e $backup ]] || { tb_fail 'Recover the previous firmware boot-access change before retrying'; return 1; }
|
|
||||||
inventory=$(tb_read_inventory) || return 1
|
|
||||||
jq -e '.domains | length > 0 and all(.[]; .SysfsPath != "")' <<< "$inventory" >/dev/null || {
|
|
||||||
tb_fail 'Connect every Thunderbolt controller before changing firmware boot access'; return 1;
|
|
||||||
}
|
|
||||||
saved=$(tb_boot_saved <<< "$inventory" | jq -c --argjson state "$state" '. + {state:$state}') || return 1
|
|
||||||
if [[ $enabled == "true" ]]; then
|
|
||||||
jq -e 'all(.domains[]; (.SecurityLevel == "user" or .SecurityLevel == "secure") and (.BootACL|length > 0))' <<< "$inventory" >/dev/null || {
|
|
||||||
tb_fail 'This controller has no supported firmware boot allowlist. Disable Thunderbolt pre-boot/PCIe boot support in firmware settings; Omarchy cannot verify that setting.'; return 1;
|
|
||||||
}
|
|
||||||
desired=$(jq -c '{policies:(.stored | map({key:.Uid,value:"manual"}) | from_entries),
|
|
||||||
acls:(.domains | map({key:.Uid,value:(.BootACL | map(""))}) | from_entries)}' <<< "$inventory") || return 1
|
|
||||||
after=$(jq -c --argjson saved "$saved" --argjson devices "$(jq -c .devices <<< "$inventory")" '
|
|
||||||
if .boot_protection then . else .boot_original=($saved | {policies,acls}) end |
|
|
||||||
.boot_protection=true | .trusted += ($devices | map({key:.Uid,value:{Uid,Name,Vendor}}) | from_entries)' <<< "$state") || return 1
|
|
||||||
else
|
|
||||||
desired=$(jq -ce .boot_original <<< "$state") || return 1
|
|
||||||
after=$(jq -c 'del(.boot_original) | .boot_protection=false' <<< "$state") || return 1
|
|
||||||
fi
|
|
||||||
tb_json_write "$backup" <<< "$saved" || return 1
|
|
||||||
if tb_boot_apply "$desired" && tb_json_write "$TB_STATE" <<< "$after"; then
|
|
||||||
rm -- "$backup"
|
|
||||||
else
|
|
||||||
if tb_boot_apply "$saved" && tb_json_write "$TB_STATE" <<< "$state"; then
|
|
||||||
rm -- "$backup" || return 1
|
|
||||||
tb_fail 'Firmware change failed; previous state restored'
|
|
||||||
else
|
|
||||||
tb_fail "Firmware change failed; recovery is incomplete. Recovery data retained at $backup"
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_boot_recover() {
|
|
||||||
local backup="${TB_STATE%/*}/boot-recovery.json" saved
|
|
||||||
saved=$(jq -ce . "$backup") || return 1
|
|
||||||
tb_boot_apply "$saved" || return 1
|
|
||||||
jq .state <<< "$saved" | tb_json_write "$TB_STATE" || return 1
|
|
||||||
rm -- "$backup"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_unit_state() {
|
|
||||||
local properties
|
|
||||||
properties=$(systemctl show -p ActiveState -p UnitFileState "$1") || return 1
|
|
||||||
jq -Rn '[inputs | split("=") | {key:.[0],value:.[1]}] | from_entries |
|
|
||||||
{active:(.ActiveState == "active"),enabled:(.UnitFileState == "enabled")}' <<< "$properties"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_handoff_policies() {
|
|
||||||
local state=$1
|
|
||||||
# StoreTime distinguishes a record recreated outside Omarchy. A completed
|
|
||||||
# enrollment and matching consent are both required, including offline
|
|
||||||
# accessories. Never promote somebody else's manual policy.
|
|
||||||
jq -c --argjson state "$state" '{policies:([.stored[] |
|
|
||||||
select(.Policy == "manual" and .StoreTime > 0 and
|
|
||||||
$state.enrolled[.Uid] == {Uid,Name,Vendor,StoreTime} and
|
|
||||||
$state.trusted[.Uid] == {Uid,Name,Vendor}) |
|
|
||||||
{key:.Uid,value:"auto"}] | from_entries),acls:{}}'
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_config_checkpoint() {
|
|
||||||
local backup="${TB_STATE%/*}/setup-recovery.json" path original files='{}' units='{}' unit value saved inventory data mode state
|
|
||||||
[[ ! -e $backup && ! -e ${TB_STATE%/*}/boot-recovery.json ]] || {
|
|
||||||
tb_fail 'A previous setup change needs recovery before continuing'; return 1;
|
|
||||||
}
|
|
||||||
for path in "$TB_STATE" "$TB_CONFIG" "$TB_MARKER"; do
|
|
||||||
original=null
|
|
||||||
if [[ -e $path ]]; then
|
|
||||||
data=$(base64 -w0 -- "$path") || return 1
|
|
||||||
mode=$(stat -c '%a' -- "$path") || return 1
|
|
||||||
original=$(jq -cn --arg data "$data" --arg mode "$mode" '{data:$data,mode:$mode}') || return 1
|
|
||||||
fi
|
|
||||||
files=$(jq -c --arg path "$path" --argjson original "$original" '.[$path]=$original' <<< "$files") || return 1
|
|
||||||
done
|
|
||||||
for unit in bolt.service "$TB_SERVICE"; do
|
|
||||||
value=$(tb_unit_state "$unit") || return 1
|
|
||||||
units=$(jq -c --arg unit "$unit" --argjson value "$value" '.[$unit]=$value' <<< "$units") || return 1
|
|
||||||
done
|
|
||||||
saved=$(jq -cn --argjson files "$files" --argjson units "$units" '{files:$files,units:$units}') || return 1
|
|
||||||
state='{}'
|
|
||||||
if [[ -f $TB_STATE ]]; then state=$(tb_state) || return 1; fi
|
|
||||||
# A handoff to auto also changes Bolt's firmware allowlists. Checkpoint the
|
|
||||||
# policies and ACLs even when the optional boot protection was never used.
|
|
||||||
if jq -e '.enabled and (.boot_protection or ((.enrolled // {}) | length > 0))' <<< "$state" >/dev/null; then
|
|
||||||
inventory=$(tb_read_inventory) || return 1
|
|
||||||
jq -e 'all(.domains[]; .SysfsPath != "")' <<< "$inventory" >/dev/null || {
|
|
||||||
tb_fail 'Reconnect every Thunderbolt controller before changing device policies'; return 1;
|
|
||||||
}
|
|
||||||
saved=$(jq -c --argjson firmware "$(tb_boot_saved <<< "$inventory")" '.firmware=$firmware' <<< "$saved") || return 1
|
|
||||||
fi
|
|
||||||
install -d -m 700 "${TB_STATE%/*}" || return 1
|
|
||||||
tb_json_write "$backup" <<< "$saved"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_config_restore() {
|
|
||||||
local backup="${TB_STATE%/*}/setup-recovery.json" saved path original temporary unit
|
|
||||||
saved=$(jq -ce . "$backup") || return 1
|
|
||||||
systemctl stop "$TB_SERVICE" bolt.service || return 1
|
|
||||||
for path in "$TB_STATE" "$TB_CONFIG" "$TB_MARKER"; do
|
|
||||||
original=$(jq -c --arg path "$path" '.files[$path]' <<< "$saved") || return 1
|
|
||||||
if [[ $original == "null" ]]; then
|
|
||||||
rm -f -- "$path" || return 1
|
|
||||||
else
|
|
||||||
mkdir -p -- "${path%/*}" || return 1
|
|
||||||
temporary=$(mktemp "${path%/*}/.restore.XXXXXX") || return 1
|
|
||||||
if jq -r .data <<< "$original" | base64 -d > "$temporary" &&
|
|
||||||
chmod "$(jq -r .mode <<< "$original")" "$temporary" && sync "$temporary" &&
|
|
||||||
mv -fT -- "$temporary" "$path" && sync "${path%/*}"; then
|
|
||||||
:
|
|
||||||
else
|
|
||||||
rm -f -- "$temporary"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if jq -e .firmware <<< "$saved" >/dev/null; then
|
|
||||||
systemctl start bolt.service || return 1
|
|
||||||
tb_boot_apply "$(jq -c .firmware <<< "$saved")" || return 1
|
|
||||||
fi
|
|
||||||
rm -f -- "${TB_STATE%/*}/boot-recovery.json" || return 1
|
|
||||||
if jq -e --arg unit "$TB_SERVICE" '.units[$unit].enabled' <<< "$saved" >/dev/null; then
|
|
||||||
systemctl enable "$TB_SERVICE" || return 1
|
|
||||||
else
|
|
||||||
systemctl disable "$TB_SERVICE" || return 1
|
|
||||||
fi
|
|
||||||
for unit in bolt.service "$TB_SERVICE"; do
|
|
||||||
if jq -e --arg unit "$unit" '.units[$unit].active' <<< "$saved" >/dev/null; then
|
|
||||||
systemctl start "$unit" || return 1
|
|
||||||
else
|
|
||||||
systemctl stop "$unit" || return 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
rm -- "$backup"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_transaction() {
|
|
||||||
tb_config_checkpoint || return 1
|
|
||||||
if "$@"; then
|
|
||||||
rm -- "${TB_STATE%/*}/setup-recovery.json"
|
|
||||||
else
|
|
||||||
if tb_config_restore; then
|
|
||||||
tb_fail 'Thunderbolt setup failed; previous configuration restored'
|
|
||||||
else
|
|
||||||
tb_fail 'Thunderbolt setup failed; restoration is incomplete. Run the setup recovery command.'
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_enable() {
|
|
||||||
systemctl stop "$TB_SERVICE" bolt.service || return 1
|
|
||||||
tb_prepare "${1:-false}" || return 1
|
|
||||||
systemctl daemon-reload || return 1
|
|
||||||
systemctl start bolt.service || return 1
|
|
||||||
tb_read_inventory >/dev/null || return 1
|
|
||||||
systemctl enable --now "$TB_SERVICE"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_disable() {
|
|
||||||
local state mode policies
|
|
||||||
systemctl stop "$TB_SERVICE" || return 1
|
|
||||||
tb_boot_change false || return 1
|
|
||||||
state=$(tb_state) || return 1
|
|
||||||
mode=$(jq -er .original_authmode <<< "$state") || return 1
|
|
||||||
policies=$(tb_read_inventory | tb_handoff_policies "$state") || return 1
|
|
||||||
tb_boot_apply "$policies" || return 1
|
|
||||||
systemctl stop bolt.service || return 1
|
|
||||||
tb_config_write "$TB_CONFIG" "$mode" || return 1
|
|
||||||
rm -f -- "$TB_MARKER" || return 1
|
|
||||||
jq '.enabled=false | del(.enrolled)' <<< "$state" | tb_json_write "$TB_STATE" || return 1
|
|
||||||
systemctl disable "$TB_SERVICE" || return 1
|
|
||||||
systemctl start bolt.service || return 1
|
|
||||||
tb_inventory | jq -e --arg mode "$mode" --argjson policies "$policies" '
|
|
||||||
.manager.AuthMode==$mode and
|
|
||||||
(([.stored[] | {key:.Uid,value:.Policy}] | from_entries) as $actual |
|
|
||||||
all($policies.policies | to_entries[]; $actual[.key]==.value))' >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_cancel_pending() {
|
|
||||||
local state
|
|
||||||
if [[ -f $TB_STATE ]]; then
|
|
||||||
state=$(tb_state) || return 1
|
|
||||||
else
|
|
||||||
state=$(jq -cn --arg mode "$(tb_config_authmode "$TB_CONFIG")" \
|
|
||||||
'{version:1,enabled:false,trusted:{},enrollment_pending:true,original_authmode:$mode}') || return 1
|
|
||||||
fi
|
|
||||||
install -d -m 700 "${TB_STATE%/*}" || return 1
|
|
||||||
jq '.enabled=false | .enrollment_pending=true' <<< "$state" | tb_json_write "$TB_STATE" || return 1
|
|
||||||
rm -- "$TB_PENDING"
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_reset_root() {
|
|
||||||
local root directory
|
|
||||||
root=$(realpath -e -- "$1") || return 1
|
|
||||||
[[ $root != "/" && -d $root ]] || { tb_fail 'Refusing to reset the running system'; return 1; }
|
|
||||||
[[ -e $root$TB_MARKER || -e $root$TB_PENDING || -d $root${TB_STATE%/*} ]] || return 0
|
|
||||||
# Resolve fixed paths inside the cloned root, never through an outside link.
|
|
||||||
for directory in etc etc/omarchy var var/lib var/lib/omarchy var/lib/boltd; do
|
|
||||||
[[ ! -L $root/$directory ]] || { tb_fail 'Refusing a linked factory policy directory'; return 1; }
|
|
||||||
done
|
|
||||||
systemctl --root="$root" disable "$TB_SERVICE" || return 1
|
|
||||||
rm -f -- "$root$TB_MARKER" || return 1
|
|
||||||
rm -f -- "$root$TB_PENDING" || return 1
|
|
||||||
rm -rf -- "$root${TB_STATE%/*}" || return 1
|
|
||||||
for directory in devices keys domains; do
|
|
||||||
rm -rf -- "$root/var/lib/boltd/$directory" || return 1
|
|
||||||
done
|
|
||||||
tb_config_write "$root/var/lib/boltd/boltd.conf" enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
tb_admin() {
|
|
||||||
local action=${1:-} status=0 state
|
|
||||||
if [[ $action != "guard" ]]; then
|
|
||||||
exec 9> "$TB_LOCK"
|
|
||||||
flock -x 9 || return 1
|
|
||||||
fi
|
|
||||||
case "$action" in
|
|
||||||
prepare|enable|owner|migrate)
|
|
||||||
# A deferred setup needs explicit consent after the accessories have
|
|
||||||
# been disconnected; a later migration must not silently finish it.
|
|
||||||
if [[ -f $TB_PENDING && $action != "enable" ]]; then return 0; fi
|
|
||||||
if [[ $action == "migrate" && -f $TB_STATE ]] && tb_state | jq -e '.enabled == false' >/dev/null; then return 0; fi
|
|
||||||
if tb_capture_ready "$(if [[ $action == "owner" ]]; then echo true; else echo false; fi)" \
|
|
||||||
"$(if [[ $action == "prepare" ]]; then echo true; else echo false; fi)"; then
|
|
||||||
:
|
|
||||||
else
|
|
||||||
status=$?
|
|
||||||
if (( status == 2 )); then return 0; else return "$status"; fi
|
|
||||||
fi ;;
|
|
||||||
esac
|
|
||||||
case "$action" in
|
|
||||||
guard) tb_guard ;;
|
|
||||||
prepare) tb_prepare && rm -f -- "$TB_PENDING" ;;
|
|
||||||
enable) tb_transaction tb_enable && rm -f -- "$TB_PENDING" ;;
|
|
||||||
owner) tb_transaction tb_enable true && rm -f -- "$TB_PENDING" ;;
|
|
||||||
migrate)
|
|
||||||
state='{"enabled":true}'
|
|
||||||
if [[ -f $TB_STATE ]]; then state=$(tb_state) || return 1; fi
|
|
||||||
if jq -e .enabled <<< "$state" >/dev/null; then
|
|
||||||
tb_transaction tb_enable
|
|
||||||
fi ;;
|
|
||||||
disable)
|
|
||||||
if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then
|
|
||||||
tb_cancel_pending
|
|
||||||
else
|
|
||||||
tb_transaction tb_disable
|
|
||||||
fi ;;
|
|
||||||
recover) tb_config_restore ;;
|
|
||||||
boot-enable|boot-disable|boot-recover)
|
|
||||||
systemctl stop "$TB_SERVICE" || return 1
|
|
||||||
case "$action" in
|
|
||||||
boot-enable) tb_boot_change true || status=$? ;;
|
|
||||||
boot-disable) tb_boot_change false || status=$? ;;
|
|
||||||
boot-recover) tb_boot_recover || status=$? ;;
|
|
||||||
esac
|
|
||||||
systemctl start "$TB_SERVICE" || return 1
|
|
||||||
return "$status" ;;
|
|
||||||
reset-root) [[ $# == 2 ]] && tb_reset_root "$2" ;;
|
|
||||||
*) tb_fail 'Unknown Thunderbolt setup action' ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# Device identity is portable; the full live rule is still used when granting
|
|
||||||
# access to the connection shown in the approval dialog.
|
|
||||||
usb_authorization_portable_rule() {
|
|
||||||
local rule="$1" token="" char quoted=0 escaped=0 index
|
|
||||||
local -a tokens=() portable=()
|
|
||||||
|
|
||||||
# Split canonical USBGuard output without evaluating device-controlled text.
|
|
||||||
# An attribute name inside a quoted name/serial is never a rule attribute.
|
|
||||||
for (( index=0; index<${#rule}; index++ )); do
|
|
||||||
char=${rule:index:1}
|
|
||||||
if (( escaped )); then
|
|
||||||
token+="$char"
|
|
||||||
escaped=0
|
|
||||||
elif (( quoted )) && [[ $char == '\' ]]; then
|
|
||||||
token+="$char"
|
|
||||||
escaped=1
|
|
||||||
elif [[ $char == '"' ]]; then
|
|
||||||
token+="$char"
|
|
||||||
quoted=$((1 - quoted))
|
|
||||||
elif (( ! quoted )) && [[ $char == " " ]]; then
|
|
||||||
if [[ -n $token ]]; then tokens+=("$token"); token=""; fi
|
|
||||||
else
|
|
||||||
token+="$char"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
(( ! quoted && ! escaped )) || return 1
|
|
||||||
if [[ -n $token ]]; then tokens+=("$token"); fi
|
|
||||||
[[ ${tokens[0]:-} == "allow" || ${tokens[0]:-} == "block" ]] || return 1
|
|
||||||
portable=(allow)
|
|
||||||
for (( index=1; index<${#tokens[@]}; index++ )); do
|
|
||||||
case "${tokens[index]}" in
|
|
||||||
via-port | parent-hash)
|
|
||||||
(( ++index < ${#tokens[@]} )) || return 1
|
|
||||||
[[ ${tokens[index]} == \"*\" ]] || return 1
|
|
||||||
;;
|
|
||||||
label) return 1 ;;
|
|
||||||
*) portable+=("${tokens[index]}") ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
local IFS=" "
|
|
||||||
printf '%s label "omarchy-usb-authorization-v1"\n' "${portable[*]}"
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_permanent() {
|
|
||||||
local id="$1" expected="$2" devices current="" allowed portable policy line target new_id matches rule saved=0
|
|
||||||
|
|
||||||
[[ $id =~ ^[0-9]+$ && ( $expected == block\ * || $expected == allow\ * ) ]] || return 1
|
|
||||||
(( ${#expected} <= 16384 )) || return 1
|
|
||||||
devices=$(usbguard list-devices) || return 1
|
|
||||||
while IFS= read -r line; do
|
|
||||||
if [[ $line == "$id: "* ]]; then current=${line#"$id: "}; fi
|
|
||||||
done <<<"$devices"
|
|
||||||
[[ $current == "$expected" ]] || return 1
|
|
||||||
allowed="allow ${expected#* }"
|
|
||||||
portable=$(usb_authorization_portable_rule "$current") || return 1
|
|
||||||
usbguard-rule-parser "$portable" >/dev/null || return 1
|
|
||||||
|
|
||||||
# Respect earlier administrator deny rules. Do not report permanent success
|
|
||||||
# when a saved allow would lose to an explicit block/reject on reconnect.
|
|
||||||
policy=$(usbguard list-rules) || return 1
|
|
||||||
while IFS= read -r line; do
|
|
||||||
if [[ $line =~ ^[0-9]+:[[:space:]](allow|block|reject)([[:space:]]|$) ]]; then
|
|
||||||
target=${BASH_REMATCH[1]}
|
|
||||||
rule=${line#*: }
|
|
||||||
# --show-devices filters by current target, hiding deny rules for a
|
|
||||||
# temporarily allowed device. A match query compares identity instead.
|
|
||||||
matches=$(usbguard list-devices "match${rule#"$target"}") || return 1
|
|
||||||
if grep -Fqx -- "$id: $current" <<<"$matches"; then
|
|
||||||
if [[ $target != "allow" ]]; then
|
|
||||||
echo "An administrator rule blocks this device. Review that rule before saving approval." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done <<<"$policy"
|
|
||||||
while IFS= read -r line; do
|
|
||||||
if [[ $line =~ ^([0-9]+):[[:space:]] && ${line#*: } == "$portable" ]]; then
|
|
||||||
saved=1
|
|
||||||
fi
|
|
||||||
done <<<"$policy"
|
|
||||||
|
|
||||||
# A retry can reuse only an exact owned rule present in both the daemon and
|
|
||||||
# its persistent file. Never remove/reorder rules around manual restrictions.
|
|
||||||
if (( ! saved )) || ! grep -Fqx -- "$portable" /etc/usbguard/rules.conf; then
|
|
||||||
new_id=$(usbguard append-rule "$portable") || return 1
|
|
||||||
[[ $new_id =~ ^[0-9]+$ ]] || return 1
|
|
||||||
grep -Fqx -- "$portable" /etc/usbguard/rules.conf || return 1
|
|
||||||
fi
|
|
||||||
if [[ $current == block\ * ]]; then
|
|
||||||
usbguard allow-device "$expected" || return 1
|
|
||||||
fi
|
|
||||||
devices=$(usbguard list-devices --allowed) || return 1
|
|
||||||
grep -Fqx -- "$1: $allowed" <<<"$devices"
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
source "${BASH_SOURCE[0]%/*}/usb-authorization-policy.sh"
|
|
||||||
|
|
||||||
usb_authorization_config_value() {
|
|
||||||
local config="$1" key="$2"
|
|
||||||
local awk_command=(awk)
|
|
||||||
|
|
||||||
if [[ ! -r $config ]]; then
|
|
||||||
if [[ $config == "/etc/usbguard/usbguard-daemon.conf" ]]; then
|
|
||||||
awk_command=(sudo awk)
|
|
||||||
else
|
|
||||||
echo "Cannot read USBGuard configuration: $config" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
"${awk_command[@]}" -F= -v key="$key" '
|
|
||||||
/^[[:space:]]*#/ { next }
|
|
||||||
$1 ~ "^[[:space:]]*" key "[[:space:]]*$" {
|
|
||||||
value=$0
|
|
||||||
sub(/^[^=]*=[[:space:]]*/, "", value)
|
|
||||||
sub(/[[:space:]]*$/, "", value)
|
|
||||||
print value
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
' "$config"
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_rules_present() {
|
|
||||||
local rules="$1"
|
|
||||||
|
|
||||||
if [[ -r $rules ]]; then
|
|
||||||
[[ -s $rules ]]
|
|
||||||
elif [[ $rules == "/etc/usbguard/rules.conf" ]]; then
|
|
||||||
sudo test -s "$rules"
|
|
||||||
else
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_require_secure_setting() {
|
|
||||||
local config="$1" key="$2" expected="$3" actual
|
|
||||||
|
|
||||||
actual=$(usb_authorization_config_value "$config" "$key")
|
|
||||||
if [[ $actual != "$expected" ]]; then
|
|
||||||
echo "USBGuard's $key must be '$expected' before Omarchy enables it." >&2
|
|
||||||
echo "Current value: ${actual:-<unset>} in $config" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_require_secure_settings() {
|
|
||||||
local config="$1"
|
|
||||||
|
|
||||||
usb_authorization_require_secure_setting "$config" ImplicitPolicyTarget block || return 1
|
|
||||||
usb_authorization_require_secure_setting "$config" PresentDevicePolicy apply-policy || return 1
|
|
||||||
usb_authorization_require_secure_setting "$config" InsertedDevicePolicy apply-policy || return 1
|
|
||||||
usb_authorization_require_secure_setting "$config" AuthorizedDefault none || return 1
|
|
||||||
usb_authorization_require_secure_setting "$config" RestoreControllerDeviceState false || return 1
|
|
||||||
usb_authorization_require_secure_setting "$config" RuleFile /etc/usbguard/rules.conf || return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_generate_policy() {
|
|
||||||
local policy="$1" generated line
|
|
||||||
|
|
||||||
if ! generated=$(usbguard generate-policy); then
|
|
||||||
echo "USBGuard could not enumerate devices; nothing was enabled." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
: >"$policy"
|
|
||||||
while IFS= read -r line; do
|
|
||||||
if [[ $line == allow\ * ]]; then
|
|
||||||
usb_authorization_portable_rule "$line" >>"$policy" || return 1
|
|
||||||
elif [[ -n $line ]]; then
|
|
||||||
printf '%s\n' "$line" >>"$policy"
|
|
||||||
fi
|
|
||||||
done <<<"$generated"
|
|
||||||
if ! grep -q '^allow ' "$policy"; then
|
|
||||||
if grep -q '^[[:space:]]*[^#[:space:]]' "$policy"; then
|
|
||||||
echo "USBGuard did not generate a usable policy; nothing was enabled." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
# A successful empty inventory is valid on machines without USB controllers.
|
|
||||||
# Keep the file nonempty so re-enabling preserves this default-deny policy.
|
|
||||||
echo '# No USB devices were present during enrollment.' >>"$policy"
|
|
||||||
fi
|
|
||||||
usbguard-rule-parser -f "$policy" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_add_user() {
|
|
||||||
local user="$1"
|
|
||||||
|
|
||||||
# This user can inspect USBGuard events and approve the device named by an
|
|
||||||
# event. Policy and daemon parameters remain unavailable through the IPC ACL.
|
|
||||||
usbguard add-user "$user" \
|
|
||||||
--devices=list,listen,modify \
|
|
||||||
--policy=list \
|
|
||||||
--exceptions=listen
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_install_policy() {
|
|
||||||
local rules="$1" policy status=0
|
|
||||||
|
|
||||||
policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX") || return 1
|
|
||||||
if usb_authorization_generate_policy "$policy"; then
|
|
||||||
install -Dm600 -o root -g root "$policy" "$rules" || status=$?
|
|
||||||
else
|
|
||||||
status=1
|
|
||||||
fi
|
|
||||||
rm -f "$policy"
|
|
||||||
return "$status"
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_provision_owner() {
|
|
||||||
local user="$1"
|
|
||||||
local rules="${2:-/etc/usbguard/rules.conf}"
|
|
||||||
local config="${3:-/etc/usbguard/usbguard-daemon.conf}"
|
|
||||||
|
|
||||||
usb_authorization_require_secure_settings "$config" || return 1
|
|
||||||
# Enroll the owner's hardware, replacing any policy inherited from the
|
|
||||||
# builder. Do this only after the interactive provisioning steps finish.
|
|
||||||
usb_authorization_install_policy "$rules" || return 1
|
|
||||||
usb_authorization_add_user "$user" || return 1
|
|
||||||
systemctl enable usbguard.service || return 1
|
|
||||||
systemctl restart usbguard.service
|
|
||||||
}
|
|
||||||
|
|
||||||
usb_authorization_trust_present_devices() {
|
|
||||||
local line id devices
|
|
||||||
local count=0
|
|
||||||
devices=$(usbguard list-devices) || return 1
|
|
||||||
local -a inventory
|
|
||||||
mapfile -t inventory <<<"$devices"
|
|
||||||
|
|
||||||
for line in "${inventory[@]}"; do
|
|
||||||
if [[ $line =~ ^([0-9]+):[[:space:]] ]]; then
|
|
||||||
id="${BASH_REMATCH[1]}"
|
|
||||||
sudo /usr/bin/omarchy-usb-authorization-approve "$id" "${line#*: }" || return 1
|
|
||||||
(( ++count ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if (( count == 0 )); then
|
|
||||||
echo "USBGuard did not report any connected USB devices; boot authorization was not enabled." >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
@@ -146,7 +146,6 @@ udiskie
|
|||||||
ufw
|
ufw
|
||||||
ufw-docker
|
ufw-docker
|
||||||
unzip
|
unzip
|
||||||
usbguard
|
|
||||||
usage
|
usage
|
||||||
uwsm
|
uwsm
|
||||||
vi
|
vi
|
||||||
|
|||||||
@@ -21,8 +21,13 @@ fi
|
|||||||
# rc on aarch64) gets the platform's default channel instead.
|
# rc on aarch64) gets the platform's default channel instead.
|
||||||
channel=${OMARCHY_MIRROR:-}
|
channel=${OMARCHY_MIRROR:-}
|
||||||
[[ -n $channel && -f $templates/pacman-$channel.conf ]] || channel=$(omarchy_pacman_default_channel "$platform")
|
[[ -n $channel && -f $templates/pacman-$channel.conf ]] || channel=$(omarchy_pacman_default_channel "$platform")
|
||||||
cp -f "$templates/pacman-$channel.conf" /etc/pacman.conf
|
# Regions render x86 pacman only (omarchy #13690).
|
||||||
cp -f "$templates/mirrorlist-$channel" /etc/pacman.d/mirrorlist
|
if [[ $platform == x86 && -e /etc/omarchy/region ]]; then
|
||||||
|
"$OMARCHY_PATH/bin/omarchy-apply-pacman" "$channel"
|
||||||
|
else
|
||||||
|
cp -f "$templates/pacman-$channel.conf" /etc/pacman.conf
|
||||||
|
cp -f "$templates/mirrorlist-$channel" /etc/pacman.d/mirrorlist
|
||||||
|
fi
|
||||||
|
|
||||||
# An image build leaves the keyring to each machine's first boot, so no two
|
# An image build leaves the keyring to each machine's first boot, so no two
|
||||||
# share a master key (install/helpers/image-target.sh).
|
# share a master key (install/helpers/image-target.sh).
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ for unit in \
|
|||||||
omarchy-sleep-lock.service \
|
omarchy-sleep-lock.service \
|
||||||
omarchy-migrate-notify.service \
|
omarchy-migrate-notify.service \
|
||||||
omarchy-fcitx5.service \
|
omarchy-fcitx5.service \
|
||||||
omarchy-crash-watch.service \
|
omarchy-crash-watch.service; do
|
||||||
omarchy-usb-authorization.service \
|
|
||||||
omarchy-thunderbolt-authorization.service; do
|
|
||||||
systemctl --user enable --now "$unit" || failed=1
|
systemctl --user enable --now "$unit" || failed=1
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ Now you're ready to Omarchy!
|
|||||||
|
|
||||||
The full-disk encryption won't allow you to enter the password from a Bluetooth keyboard at startup. Just like you can't use a Bluetooth keyboard to enter the BIOS on a PC. You'll need a keyboard that either uses a 2.4ghz dongle or a cable (which is much nicer for latency anyway!). I personally love the [Lofree Flow84](https://www.lofree.co/products/lofree-flow-the-smoothest-mechanical-keyboard)!
|
The full-disk encryption won't allow you to enter the password from a Bluetooth keyboard at startup. Just like you can't use a Bluetooth keyboard to enter the BIOS on a PC. You'll need a keyboard that either uses a 2.4ghz dongle or a cable (which is much nicer for latency anyway!). I personally love the [Lofree Flow84](https://www.lofree.co/products/lofree-flow-the-smoothest-mechanical-keyboard)!
|
||||||
|
|
||||||
|
### Installing in China
|
||||||
|
|
||||||
|
The installer's first screen asks for your language; pick _Chinese_ for a Chinese installer and system. The timezone you pick later decides the region: `Asia/Shanghai` brings Chinese package and developer mirrors and the China apps. See [China](52-china.md).
|
||||||
|
|
||||||
### Installing for another owner
|
### Installing for another owner
|
||||||
|
|
||||||
If you're setting up a machine for someone else — a family member, a new employee, a buyer — you shouldn't be answering the personal questions on their behalf. Hit `Ctrl + C` on the very first screen of the installer (the language and keyboard selection), and Omarchy will offer to prepare the machine for another owner instead. The system installs right away, but all the personal setup — keyboard layout, username, password — is deferred until the machine boots for the first time. The drive is still encrypted by default, and the password the new owner picks on that first boot becomes the encryption password too. (A machine you've already been using can be handed over without a reinstall too — see [resetting the computer](48-security.md).)
|
If you're setting up a machine for someone else — a family member, a new employee, a buyer — you shouldn't be answering the personal questions on their behalf. Hit `Ctrl + C` on the very first screen of the installer (the language and keyboard selection), and Omarchy will offer to prepare the machine for another owner instead. The system installs right away, but all the personal setup — keyboard layout, username, password — is deferred until the machine boots for the first time. The drive is still encrypted by default, and the password the new owner picks on that first boot becomes the encryption password too. (A machine you've already been using can be handed over without a reinstall too — see [resetting the computer](48-security.md).)
|
||||||
|
|||||||
+1
-44
@@ -6,30 +6,7 @@ Omarchy takes security extremely seriously. This is meant to be an operating sys
|
|||||||
2. *Firewall is enabled by default*: All incoming traffic is blocked by default except for port 53317 for [LocalSend](https://localsend.org/). Even ssh is off until you turn it on via _Setup > Security > SSHD_, which opens port 22 (rate limited against brute force) as part of the setup. We even lock down Docker access using the [ufw-docker](https://github.com/chaifeng/ufw-docker) setup to prevent that your containers are accidentally exposed to the world.
|
2. *Firewall is enabled by default*: All incoming traffic is blocked by default except for port 53317 for [LocalSend](https://localsend.org/). Even ssh is off until you turn it on via _Setup > Security > SSHD_, which opens port 22 (rate limited against brute force) as part of the setup. We even lock down Docker access using the [ufw-docker](https://github.com/chaifeng/ufw-docker) setup to prevent that your containers are accidentally exposed to the world.
|
||||||
3. *Arch always have the latest updates*: Arch, the underlying distro that Omarchy is built on, is a rolling distribution. This means that any security vulnerability that's discovered and patched in any package is quickly available for install using `omarchy-update`. You're always running the latest, most secure versions of everything that way.
|
3. *Arch always have the latest updates*: Arch, the underlying distro that Omarchy is built on, is a rolling distribution. This means that any security vulnerability that's discovered and patched in any package is quickly available for install using `omarchy-update`. You're always running the latest, most secure versions of everything that way.
|
||||||
4. *Omarchy maintains its own packages and mirror*: Omarchy only relies on packages from Arch's own core/extra/multilib repositories and its own Omarchy Package Repository by default. You can install software directly from AUR, but the base install doesn't — only a few optional installs, like the third-party browsers, pull from the AUR.
|
4. *Omarchy maintains its own packages and mirror*: Omarchy only relies on packages from Arch's own core/extra/multilib repositories and its own Omarchy Package Repository by default. You can install software directly from AUR, but the base install doesn't — only a few optional installs, like the third-party browsers, pull from the AUR.
|
||||||
5. *New USB devices require approval*: Omarchy trusts the USB devices present during installation, then blocks newly connected accessories until you approve them. This keeps an unknown accessory from reaching a matching kernel driver merely because it claims a supported device identity.
|
5. *Cloudflare protects us from DDoS*: All the Omarchy distribution infrastructure — the ISOs, the Omarchy packages, the Arch mirror — is protected behind Cloudflare's formidable DDoS shield and hosted on their CDN. This provides superb availability.
|
||||||
6. *Cloudflare protects us from DDoS*: All the Omarchy distribution infrastructure — the ISOs, the Omarchy packages, the Arch mirror — is protected behind Cloudflare's formidable DDoS shield and hosted on their CDN. This provides superb availability.
|
|
||||||
|
|
||||||
## USB Device Authorization
|
|
||||||
|
|
||||||
USB device authorization is enabled by default. Omarchy uses USBGuard to trust the devices connected during installation and block USB devices it has not seen before. When a device is blocked, click the persistent notification and choose whether to allow it once, always allow it, or keep it blocked.
|
|
||||||
|
|
||||||
**Always allow this device** trusts the same device identity across USB ports and hubs, including after reboot. Changes to its identity still require approval. A hub may need its own approval before devices connected through it become available.
|
|
||||||
|
|
||||||
Older approvals can be tied to a port or hub. Run `omarchy-setup-security-usb-authorization --review-existing` to review connected devices and confirm which should be trusted on any port. Existing rules are preserved. For a disconnected device, reconnect it and choose **Always allow this device** if prompted.
|
|
||||||
|
|
||||||
On a preinstalled machine that asks you to create your account at first boot, USB protection starts at the end of that setup. It trusts the devices connected then, so your keyboard works throughout account setup even if it differs from the installer's keyboard.
|
|
||||||
|
|
||||||
You can turn the protection off from _Remove > Security > USB Device Authorization_. _Setup > Security > USB Device Authorization_ turns it back on with the existing trusted-device policy.
|
|
||||||
|
|
||||||
Only approve hardware you recognize. A malicious USB device can forge the manufacturer, model, serial number, and device class shown during review. Permanent approval is a convenience for accessories you control, not proof of the device's identity.
|
|
||||||
|
|
||||||
The policy protects devices connected after USBGuard starts during boot. It does not replace operating-system and driver security updates, and USB devices needed before the service starts, such as a keyboard used to unlock an encrypted disk, remain governed by the kernel's boot-time policy.
|
|
||||||
|
|
||||||
For machines whose disk unlock and recovery never depend on any USB devices, _Setup > Security > USB at Boot_ can extend the block to the beginning of kernel boot. Omarchy adds every currently connected device to USBGuard's trusted policy, sets `usbcore.authorized_default=0`, and updates current and Limine snapshot boot entries. The trusted policy applies only once USBGuard starts: even a trusted USB keyboard remains unavailable at an earlier encrypted-disk password prompt.
|
|
||||||
|
|
||||||
Older snapshots created before USBGuard was installed and enabled keep all USB disabled even after startup, including keyboards, network adapters, and storage. Trusting the devices connected now does not make them available in those snapshots. Enable boot-time protection only if you can unlock and recover the machine without any USB devices. _Remove > Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup.
|
|
||||||
|
|
||||||
With Secure Boot enabled, changing USB boot protection requires access to your signing keys and enough free space on the system drive for a temporary copy of the boot files. If signing fails, Omarchy restores the previous boot files and settings. If an image uses a custom measured-boot policy or multiple boot profiles, rebuild it with its original signing setup before retrying.
|
|
||||||
|
|
||||||
## Changing your passwords
|
## Changing your passwords
|
||||||
|
|
||||||
@@ -56,23 +33,3 @@ Be clear-eyed about this one: while it's on, anything running as your user can d
|
|||||||
The public key for all ISO signatures and Omarchy repo package is `40DFB630FF42BCFFB047046CF0134EE680CAC571` ([verify at openpgp.org](https://keys.openpgp.org/search?q=pkgs%40omarchy.org)). The `omarchy/omarchy-keyring` package contains this as well and will be used to rollout any potential updates seamlessly.
|
The public key for all ISO signatures and Omarchy repo package is `40DFB630FF42BCFFB047046CF0134EE680CAC571` ([verify at openpgp.org](https://keys.openpgp.org/search?q=pkgs%40omarchy.org)). The `omarchy/omarchy-keyring` package contains this as well and will be used to rollout any potential updates seamlessly.
|
||||||
|
|
||||||
You can find the signature for any ISO release by adding .sig to the URL. Like https://iso.omarchy.org/omarchy-x.x.x.iso.sig.
|
You can find the signature for any ISO release by adding .sig to the URL. Like https://iso.omarchy.org/omarchy-x.x.x.iso.sig.
|
||||||
|
|
||||||
## Thunderbolt device authorization
|
|
||||||
|
|
||||||
Omarchy requires approval before connecting new Thunderbolt and USB4 PCIe accessories on controllers that support authorization. Devices connected during initial setup are trusted. New devices show a persistent notification with **Allow once**, **Always allow this device**, and **Keep blocked**. Trusted devices reconnect automatically, including while the screen is locked. IOMMU memory protection stays enabled; it does not count as consent to load a device's driver.
|
|
||||||
|
|
||||||
On controllers in secure mode, permanent approval also saves the accessory's authentication key. If a connected accessory has no saved key during initial setup, Omarchy defers Thunderbolt protection and keeps the previous Bolt behavior so your keyboard and storage remain usable. A **Finish Thunderbolt protection setup** notification explains how to continue: keep input available independently of Thunderbolt, safely disconnect the accessories, enable protection, then reconnect and approve each one. Losing a previously saved secure key requires a new explicit approval; Omarchy does not silently create a replacement key while restoring trusted devices.
|
|
||||||
|
|
||||||
_Setup > Security > Thunderbolt_ enables protection. _Remove > Security > Thunderbolt_ restores the previous Bolt behavior, which may automatically approve new devices when IOMMU protection is available. Re-enabling keeps your saved trust list. Omarchy never disconnects active storage when changing these settings.
|
|
||||||
|
|
||||||
When you remove protection, devices enrolled by Omarchy for permanent approval reconnect through Bolt's automatic policy, keeping their secure keys. Devices with a manual Bolt policy from before setup retain that policy. Bolt also resumes managing firmware boot allowlists for automatically approved devices. Keep all Thunderbolt controllers connected during removal so Omarchy can restore their settings if the change fails.
|
|
||||||
|
|
||||||
Firmware can authorize PCIe accessories before Linux runs. A controller configured with security mode `none` bypasses software approval; select user authorization or secure authorization in its firmware settings. Omarchy reports these limitations instead of claiming that the controller is protected. Existing firmware boot allowlists can also authorize devices early. USB accessories attached through a dock require separate USB authorization.
|
|
||||||
|
|
||||||
Approval does not repair a vulnerable driver. Device names and identifiers can be forged; secure connection keys are used where Bolt and the hardware support them.
|
|
||||||
|
|
||||||
_Setup > Security > Thunderbolt Boot_ can clear and verify supported firmware boot allowlists. It captures connected accessories for authorization after Linux starts, prevents Bolt from repopulating those allowlists from automatic device policies, and retains the previous lists for _Remove > Security > Thunderbolt Boot_. This option requires every controller to be connected and to expose a boot allowlist in `user` or `secure` mode. Other hardware needs firmware settings instead; Omarchy cannot verify those settings automatically.
|
|
||||||
|
|
||||||
Enable this only if you can unlock and recover without any Thunderbolt accessories. Even a trusted keyboard or disk may remain unavailable until the policy service starts. Older snapshots and other operating systems can restore their own firmware permissions. A failed change restores the previous settings when possible; an incomplete restoration retains recovery data. Reconnect the original controllers and run `omarchy setup security thunderbolt-boot recover` to retry that recovery.
|
|
||||||
|
|
||||||
If enabling or removing device authorization fails and reports incomplete restoration, run `omarchy setup security thunderbolt-authorization recover` before trying again. This restores the configuration and service state saved before that change.
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# China
|
||||||
|
|
||||||
|
Omarchy works in mainland China without a VPN. Two installer answers set a machine up for China, and each stands on its own:
|
||||||
|
|
||||||
|
- **Language.** The installer's first screen asks for English or Simplified Chinese. Chinese puts the installer, the system language (`LANG=zh_CN.UTF-8`), the menus and the desktop shell in Chinese.
|
||||||
|
- **Region.** The timezone you pick decides the region. `Asia/Shanghai`, `Asia/Urumqi` and their older names select China; any other timezone is Global. The summary screen shows _Region: cn_ before anything touches the disk.
|
||||||
|
|
||||||
|
A Chinese speaker abroad keeps the global mirrors, and an English install in Shanghai still gets the China mirrors.
|
||||||
|
|
||||||
|
## What the China region sets up
|
||||||
|
|
||||||
|
| Area | China | Global |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Arch packages | The channel's `.omarchy.cn` mirror first, its `.omarchy.org` mirror behind it, and the ArchLinuxCN repository with its keyring | `.omarchy.org` mirrors |
|
||||||
|
| Omarchy packages | `pkgs.omarchy.org` | `pkgs.omarchy.org` |
|
||||||
|
| Developer mirrors | npm, PyPI and uv, Go, rustup and crates.io, RubyGems and Bundler, Maven | Upstream registries |
|
||||||
|
| Software | _Install > China Apps_ | Hidden |
|
||||||
|
|
||||||
|
pacman tries the `.cn` mirror first. If it is unreachable or lacks a package, pacman falls through to `.org`, which works for anyone with their own VPN. The installer carries the China packages on the ISO, so a China install needs no network.
|
||||||
|
|
||||||
|
## Chinese input
|
||||||
|
|
||||||
|
After choosing Chinese, the installer's keyboard screen opens on _Chinese (Simplified, Pinyin)_; keep it and Fcitx5 Pinyin is ready at first login. Press `Super + I` to switch to Pinyin, and tap `Shift` to step between Chinese and Latin letters. Add or change an input method later under _Setup > Typing > Input Methods_. See [Keyboard, Mouse, Trackpad](34-keyboard-mouse-trackpad.md).
|
||||||
|
|
||||||
|
Chinese text renders in the right Noto CJK variant everywhere: Simplified for Chinese, Traditional for Taiwan and Hong Kong, Japanese and Korean for those languages.
|
||||||
|
|
||||||
|
## Developer mirrors
|
||||||
|
|
||||||
|
China machines point the common package managers at mirrors inside China. Environment variables in `/etc/environment.d/90-omarchy-china.conf` cover npm, PyPI and uv, Go, rustup, Bundler and Maven; they reach everything started from the desktop after your next login (not SSH or TTY shells), and they take precedence over npm, pip, uv and Go config files, so unset the variable in a shell to opt out. `/.cargo/config.toml` points crates.io at the mirror unless a project's `.cargo/config.toml` says otherwise, and `/etc/gemrc` sets the RubyGems source unless your `~/.gemrc` sets another. Omarchy never replaces one of those two files if it is a regular file you created. Toolchain downloads by mise, such as Node.js or Go themselves, still come from upstream.
|
||||||
|
|
||||||
|
## China Apps
|
||||||
|
|
||||||
|
_Install > China Apps_ installs WeChat, QQ, Feishu, DingTalk, Tencent Meeting and WPS Office from the AUR, and sets up Tencent Docs, Yuque and Shimo as web apps.
|
||||||
|
|
||||||
|
## Switching later
|
||||||
|
|
||||||
|
_Setup > Region_, or `omarchy region cn` and `omarchy region global`, switches the region. China runs a full system update, trusts the ArchLinuxCN keyring, records the region, moves pacman to the `.cn` mirrors and links the developer mirrors. Global moves pacman back to `.org`, removes the developer mirrors and hides _Install > China Apps_; apps you installed from it stay. The developer mirrors change at the next login, so the switch offers to log out.
|
||||||
|
|
||||||
|
_Setup > Language_, or `omarchy language zh` and `omarchy language en`, switches the system language. The menus and the desktop shell follow at the next login, so it offers to log out too.
|
||||||
|
|
||||||
|
Neither switch touches the other, the input methods or the CJK fonts. `omarchy update` never changes the region or the language.
|
||||||
|
|
||||||
|
A machine prepared for another owner keeps the language picked in the installer but installs Global, since its timezone is only set on first boot. Switch it under _Setup > Region_ once it is online.
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
echo "Enable approval for newly connected USB devices"
|
|
||||||
|
|
||||||
omarchy-setup-security-usb-authorization --yes
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
echo "Require approval for new Thunderbolt accessories"
|
|
||||||
omarchy-setup-security-thunderbolt-authorization --quiet
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
echo "Repair USB approval prompts and enable portable device approvals"
|
|
||||||
|
|
||||||
# Preserve an explicit opt-out. Existing users with an active/enabled daemon
|
|
||||||
# need a working watcher even if their old checkout link has become dangling.
|
|
||||||
if systemctl is-enabled --quiet usbguard.service || systemctl is-active --quiet usbguard.service; then
|
|
||||||
omarchy-setup-security-usb-authorization --yes
|
|
||||||
fi
|
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
echo "Remove approval for new USB and Thunderbolt devices"
|
||||||
|
|
||||||
|
# Approval for new accessories (#11874) shipped briefly to edge and dev. The
|
||||||
|
# package update has already removed its commands, units and Polkit rules; this
|
||||||
|
# undoes what they left on the machine so devices connect as they did before.
|
||||||
|
|
||||||
|
usb_state=$HOME/.local/state/omarchy/usb-authorization
|
||||||
|
tb_dir=/var/lib/omarchy/thunderbolt-authorization
|
||||||
|
usb_boot_drop_in=/etc/limine-entry-tool.d/usb-authorization.conf
|
||||||
|
usb_boot_pending=/var/lib/omarchy/usb-authorization-boot.removing
|
||||||
|
usb_boot_parameter=usbcore.authorized_default=0
|
||||||
|
usbguard_shipped=1791573373 # when usbguard joined the default packages
|
||||||
|
# The access every Omarchy path granted with `usbguard add-user`.
|
||||||
|
usbguard_grant=$'Devices=list,modify,listen\nPolicy=list\nExceptions=listen'
|
||||||
|
|
||||||
|
# The approval watchers. Their unit files are gone, so systemctl disable can't
|
||||||
|
# find them; drop the enablement links directly.
|
||||||
|
for unit in omarchy-usb-authorization.service omarchy-thunderbolt-authorization.service; do
|
||||||
|
systemctl --user stop "$unit" 2>/dev/null || true
|
||||||
|
rm -f "$HOME"/.config/systemd/user/*.wants/"$unit"
|
||||||
|
done
|
||||||
|
rm -f "$HOME/.config/systemd/user/omarchy-usb-authorization.service"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
|
bolt_list() {
|
||||||
|
sudo busctl --system --json=short call org.freedesktop.bolt /org/freedesktop/bolt org.freedesktop.bolt1.Manager "$1" | jq -r '.data[0][]'
|
||||||
|
}
|
||||||
|
|
||||||
|
# A device or controller missing from Bolt's inventory has nothing to restore.
|
||||||
|
# Any other failed read stops the migration, so it is retried.
|
||||||
|
bolt_has() {
|
||||||
|
grep -qxF "/org/freedesktop/bolt/$1/${2//-/_}" <<<"$bolt_known"
|
||||||
|
}
|
||||||
|
|
||||||
|
bolt_get() {
|
||||||
|
sudo busctl --system --json=short get-property org.freedesktop.bolt "/org/freedesktop/bolt/$1/${2//-/_}" "org.freedesktop.bolt1.$3" "$4" | jq -c .data
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -d $tb_dir || -e /etc/omarchy/thunderbolt-authorization.enabled || -e /etc/omarchy/thunderbolt-authorization.pending ]]; then
|
||||||
|
sudo systemctl stop omarchy-thunderbolt-authorization.service 2>/dev/null || true
|
||||||
|
sudo rm -f /etc/systemd/system/multi-user.target.wants/omarchy-thunderbolt-authorization.service \
|
||||||
|
/etc/omarchy/thunderbolt-authorization.enabled /etc/omarchy/thunderbolt-authorization.pending
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
state=$(sudo cat "$tb_dir/policy.json" 2>/dev/null || echo '{}')
|
||||||
|
recovery=$(sudo cat "$tb_dir/boot-recovery.json" 2>/dev/null || echo '{}')
|
||||||
|
|
||||||
|
# Thunderbolt Boot replaced device policies and emptied the firmware boot
|
||||||
|
# allowlists. The originals were saved when it was turned on, or only in the
|
||||||
|
# recovery journal if turning it on was interrupted.
|
||||||
|
firmware=$(jq -c --argjson recovery "$recovery" \
|
||||||
|
'.boot_original // (if $recovery.policies then $recovery | {policies, acls} else {} end)' <<<"$state")
|
||||||
|
|
||||||
|
bolt_devices=$(bolt_list ListDevices)
|
||||||
|
bolt_domains=$(bolt_list ListDomains)
|
||||||
|
bolt_known=$bolt_devices$'\n'$bolt_domains
|
||||||
|
|
||||||
|
while read -r uid policy; do
|
||||||
|
if bolt_has devices "$uid"; then
|
||||||
|
current=$(bolt_get devices "$uid" Device Policy)
|
||||||
|
if [[ $current != "\"$policy\"" ]]; then
|
||||||
|
sudo boltctl config device.policy "$uid" "$policy"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < <(jq -r '.policies // {} | to_entries[] | "\(.key) \(.value)"' <<<"$firmware")
|
||||||
|
|
||||||
|
# Bolt rejects a BootACL write that changes nothing, so compare first.
|
||||||
|
unrestored=0
|
||||||
|
while read -r uid; do
|
||||||
|
acl=$(jq -c --arg uid "$uid" '.acls[$uid]' <<<"$firmware")
|
||||||
|
if ! bolt_has domains "$uid"; then
|
||||||
|
echo "Thunderbolt controller $uid is not present; its firmware boot allowlist was not restored."
|
||||||
|
unrestored=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
current=$(bolt_get domains "$uid" Domain BootACL)
|
||||||
|
if [[ $current != "$acl" ]]; then
|
||||||
|
mapfile -t entries < <(jq -r '.[]' <<<"$acl")
|
||||||
|
sudo busctl --system set-property org.freedesktop.bolt "/org/freedesktop/bolt/domains/${uid//-/_}" \
|
||||||
|
org.freedesktop.bolt1.Domain BootACL as "${#entries[@]}" "${entries[@]}"
|
||||||
|
fi
|
||||||
|
done < <(jq -r '.acls // {} | keys[]' <<<"$firmware")
|
||||||
|
|
||||||
|
# Accessories approved here were stored with Bolt's manual policy, which
|
||||||
|
# Bolt won't authorize on its own. Hand back the ones Omarchy stored, as the
|
||||||
|
# removal command did, never a manual policy somebody else set.
|
||||||
|
while read -r uid store_time; do
|
||||||
|
if bolt_has devices "$uid"; then
|
||||||
|
current=$(bolt_get devices "$uid" Device Policy)
|
||||||
|
stored=$(bolt_get devices "$uid" Device StoreTime)
|
||||||
|
if [[ $current == '"manual"' && $stored == "$store_time" ]]; then
|
||||||
|
sudo boltctl config device.policy "$uid" auto
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < <(jq -r '(.trusted // {}) as $trusted | .enrolled // {} | to_entries[] | select($trusted[.key]) | "\(.key) \(.value.StoreTime)"' <<<"$state")
|
||||||
|
|
||||||
|
# A first setup that failed and then failed to undo itself can lose
|
||||||
|
# policy.json after switching Bolt off; Bolt's original config is then only
|
||||||
|
# in the setup journal. A deferred install that never finished setup left
|
||||||
|
# Bolt's mode untouched and has neither.
|
||||||
|
setup=$(sudo cat "$tb_dir/setup-recovery.json" 2>/dev/null || echo '{}')
|
||||||
|
config=$(jq -c '[.files // {} | to_entries[] | select(.key | endswith("/boltd.conf")) | .value]' <<<"$setup")
|
||||||
|
mode=$(jq -r '.original_authmode // empty' <<<"$state")
|
||||||
|
if [[ -z $mode && $config != "[]" ]]; then
|
||||||
|
mode=$(jq -r '.[0].data // ""' <<<"$config" | base64 -d | awk '
|
||||||
|
/^[[:space:]]*\[/ { section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/) }
|
||||||
|
section && /^[[:space:]]*AuthMode[[:space:]]*=/ { sub(/^[^=]*=[[:space:]]*/, ""); sub(/[[:space:]]*$/, ""); mode = $0 }
|
||||||
|
END { print mode == "" ? "enabled" : mode }
|
||||||
|
')
|
||||||
|
fi
|
||||||
|
if [[ -n $mode ]]; then
|
||||||
|
sudo boltctl config global.auth-mode "$mode"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( unrestored )) && [[ -d $tb_dir ]]; then
|
||||||
|
sudo mv -T "$tb_dir" "$tb_dir.unrestored"
|
||||||
|
echo "The saved Thunderbolt settings are kept in $tb_dir.unrestored."
|
||||||
|
else
|
||||||
|
sudo rm -rf "$tb_dir"
|
||||||
|
fi
|
||||||
|
sudo rm -rf /run/omarchy-thunderbolt-authorization
|
||||||
|
fi
|
||||||
|
|
||||||
|
# limine-mkinitcpio reports success even when a kernel image fails to build, so
|
||||||
|
# check every current kernel entry. Limine's command line wins, except under
|
||||||
|
# Secure Boot, where the signed image's embedded one does.
|
||||||
|
usb_boot_cleared() {
|
||||||
|
local entries key value esp secure_boot=0 found=0
|
||||||
|
local efivar=/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c
|
||||||
|
|
||||||
|
if [[ -r $efivar && $(od -An -t u1 -j 4 -N 1 "$efivar") == *1 ]]; then
|
||||||
|
secure_boot=1
|
||||||
|
fi
|
||||||
|
esp=$(sed -n 's/^ESP_PATH=["'\'']\?\([^"'\'']*\).*/\1/p' /etc/default/limine | tail -1)
|
||||||
|
esp=${esp:-/boot}
|
||||||
|
entries=$(sudo awk '
|
||||||
|
/^ \/\/[^\/]/ { entry = 1; next }
|
||||||
|
/^ *\// { entry = 0 }
|
||||||
|
entry && /^ (path|cmdline):/ { key = $1; sub(/^ [a-z]+:[[:space:]]*/, ""); print key "\t" $0 }
|
||||||
|
' "$esp/limine.conf") || return 1
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r key value; do
|
||||||
|
if [[ $key == "cmdline:" ]]; then
|
||||||
|
[[ " $value " != *" $usb_boot_parameter "* ]] || return 1
|
||||||
|
elif [[ $key == "path:" ]]; then
|
||||||
|
found=1
|
||||||
|
if (( secure_boot )); then
|
||||||
|
value=${value#'boot():'}
|
||||||
|
value=$esp${value%%#*}
|
||||||
|
sudo test -r "$value" || return 1
|
||||||
|
if sudo grep -qaF "$usb_boot_parameter" "$value"; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<<"$entries"
|
||||||
|
(( found ))
|
||||||
|
}
|
||||||
|
|
||||||
|
# The opt-in USB at Boot setting: the parameter on the kernel command line, for
|
||||||
|
# new boot entries and new snapshots. Keep a marker until the rebuilt entries
|
||||||
|
# are verified, so an interrupted rebuild is retried rather than skipped.
|
||||||
|
if [[ -e $usb_boot_drop_in || -e $usb_boot_pending ]] || grep -Fqx '# Omarchy USB authorization begin' /etc/default/limine 2>/dev/null; then
|
||||||
|
sudo touch "$usb_boot_pending"
|
||||||
|
sudo rm -f "$usb_boot_drop_in"
|
||||||
|
sudo sed -i '/^# Omarchy USB authorization begin$/,/^# Omarchy USB authorization end$/d' /etc/default/limine
|
||||||
|
sudo limine-mkinitcpio
|
||||||
|
if ! usb_boot_cleared; then
|
||||||
|
echo "A boot entry still has $usb_boot_parameter, so USBGuard stays until it is rebuilt. Run omarchy-migrate to retry."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sudo rm -f "$usb_boot_pending"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Leave a USBGuard the user set up before Omarchy shipped it. pacman resets the
|
||||||
|
# install date on every upgrade, so ask its log when usbguard first arrived.
|
||||||
|
usbguard_from_omarchy() {
|
||||||
|
local first
|
||||||
|
|
||||||
|
if [[ -f $usb_state/policy-generated-by-omarchy ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
first=$(grep -m1 -oP '^\[\K[^]]+(?=\] \[ALPM\] installed usbguard )' /var/log/pacman.log) || return 1
|
||||||
|
(( $(date -d "$first" +%s) >= usbguard_shipped )) || return 1
|
||||||
|
[[ $(sudo cat "/etc/usbguard/IPCAccessControl.d/$USER" 2>/dev/null) == "$usbguard_grant" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
if omarchy-pkg-present usbguard && usbguard_from_omarchy; then
|
||||||
|
sudo systemctl disable --now usbguard.service
|
||||||
|
# USBGuard leaves the root hubs denying new devices when it exits, until
|
||||||
|
# the next boot. Restore the kernel default and authorize what it blocked.
|
||||||
|
shopt -s nullglob
|
||||||
|
echo 1 | sudo tee /sys/bus/usb/devices/usb*/authorized_default /sys/bus/usb/devices/*/authorized >/dev/null
|
||||||
|
shopt -u nullglob
|
||||||
|
omarchy-pkg-drop usbguard
|
||||||
|
|
||||||
|
# The approval access granted to each user outlives the package.
|
||||||
|
while IFS= read -r -d '' grant; do
|
||||||
|
if [[ $(sudo cat "$grant") == "$usbguard_grant" ]]; then
|
||||||
|
sudo rm -f "$grant"
|
||||||
|
fi
|
||||||
|
done < <(sudo find /etc/usbguard/IPCAccessControl.d -maxdepth 1 -type f -print0 2>/dev/null)
|
||||||
|
sudo rmdir /etc/usbguard/IPCAccessControl.d /etc/usbguard 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
for action in /usr/share/polkit-1/actions/org.omarchy.usb.policy /usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy; do
|
||||||
|
if [[ -e $action ]] && ! pacman -Qo "$action" &>/dev/null; then
|
||||||
|
sudo rm -f "$action"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
rm -rf "$usb_state"
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# OmarchyCN task list
|
||||||
|
|
||||||
|
Progress source of truth for the `omarchycn` branches of `omarchy`, `omarchy-iso` and `omarchy-pkgs`. Work top to bottom, one item at a time. Each item closes only after a codex review (GPT 6.1 Sol, xhigh, in a herdr split) passes; at most 5 reviews per item.
|
||||||
|
|
||||||
|
## Decisions (confirmed with the owner, 2026-10-05)
|
||||||
|
|
||||||
|
- Region is machine state in `/etc/omarchy/region` (`china`); absent means Global.
|
||||||
|
- Arch and Omarchy repos follow the domain plan: every `*.omarchy.org` server gets a `*.omarchy.cn` twin listed first. `pkgs.omarchy.cn` is live; `stable-mirror`, `rc-mirror` and `mirror.omarchy.cn` do not resolve yet, so pacman falls through to `.org`.
|
||||||
|
- China means a Chinese UI: `LANG=zh_CN.UTF-8` and Chinese menu labels. Global means `en_US.UTF-8`. `omarchy update` never touches the locale.
|
||||||
|
- Input method: rime-ice-installer (omarchy-pkgs PR 265, not merged yet). The ISO bundles the package, the Rime Ice `full.zip` and the Wanxiang grammar model; the install finishes with Rime and US keyboard ready, Ctrl+Space toggles. The installer gets a local patch: `--asset-dir` and no sudo when its packages are present.
|
||||||
|
- China to Global: switch sources and language, keep CJK fonts and the input method, remove the Claude Code third-party provider entry and the env it wrote, hide the China software entry. Dim and Kimi stay for every region.
|
||||||
|
- Deferred provisioning and factory reset get the region's system setup but no offline input method (deferred).
|
||||||
|
|
||||||
|
## Decisions (owner and maintainers, 2026-10-08)
|
||||||
|
|
||||||
|
These replace the 2026-10-05 decisions where they disagree.
|
||||||
|
|
||||||
|
- Region and language are separate. Region comes from the timezone the user picks (omarchy #13690, omarchy-iso #241); the installer adds no region screen. Language is the first installer screen.
|
||||||
|
- The `.cn` pacman mirrors are #13690's `mirrorlist-<channel>.prepend` files. Our `servers.sed` twins and the USTC Arch fallback go; `[omarchy]` keeps only `pkgs.omarchy.org`, as in #13690.
|
||||||
|
- Input methods come from dhh's #14433 and omarchy-iso #237. Rime, `rime-ice-installer` and its bundled assets go.
|
||||||
|
- The language picker offers English and Simplified Chinese. Chinese reruns the installer in cage + foot with fonts unpacked from the offline repository's `noto-fonts-cjk`; the target gets `LANG=zh_CN.UTF-8`.
|
||||||
|
- Setup > Region switches mirrors, developer mirrors, the China apps entry and the Claude Code API entry. Setup > Language switches `LANG` and the Chinese UI. Neither touches the other.
|
||||||
|
|
||||||
|
## Decisions (owner, 2026-10-11, after the maintainers tested the ISO)
|
||||||
|
|
||||||
|
- Drop _Setup > Claude Code API_ (`omarchy-setup-claude-api`) and the Dim/Kimi agent roster and migration. `omarchy region global` no longer touches Claude settings.
|
||||||
|
- Drop the ISO's local-build pin (`pin_local_packages`, `IgnorePkg` and its hooks).
|
||||||
|
- Drop the `omarchy-pkg-add` change; it served Rime.
|
||||||
|
- PRs carry only our changes and never overlap Yunjie Dai's (#13690, iso #241) or David's (#14433, iso #237). Region work stacks on #13690; ISO work stacks on iso #241.
|
||||||
|
|
||||||
|
## Shipping prerequisites
|
||||||
|
|
||||||
|
- The region profile and timezone mapping wait on omarchy #13690 and omarchy-iso #241; input methods (omarchy #14433, omarchy-iso #237) are merged. The language screen, CJK console, Chinese UI, developer mirrors and China apps exist only on the `omarchycn` branches: build a test ISO with `./bin/omarchy-iso-make --local-source ../omarchy ../omarchy-pkgs` from `omarchy-iso/`.
|
||||||
|
- A local-source ISO builds `omarchy-dev` and `omarchy-settings-dev` under the published version number and does not pin them, so the next published release replaces them on `omarchy update`. Local test ISOs are for testing only.
|
||||||
|
- `stable-mirror.omarchy.cn`, `rc-mirror.omarchy.cn` and `mirror.omarchy.cn` resolve and serve `core.db` (checked 2026-10-09).
|
||||||
|
|
||||||
|
## PR branches (T32, 2026-10-12)
|
||||||
|
|
||||||
|
omarchy, each a fork branch:
|
||||||
|
|
||||||
|
- `cjk-variant-selection` on upstream `quattro`: the Noto CJK variant fontconfig.
|
||||||
|
- `chinese-language` on upstream `quattro`: `omarchy language`, Setup > Language, the installer's language prompt, the Chinese menu overlay, shell and notification text.
|
||||||
|
- `cn-developer-mirrors` on #13690: the `cn` developer mirrors at install.
|
||||||
|
- `cn-region-switch` on `cn-developer-mirrors`: `omarchy region`, Setup > Region, China Apps, `manual/52-china.md`; English only.
|
||||||
|
|
||||||
|
omarchy-iso: `chinese-installer` on iso #241 (the language screen, the CJK console, the translated installer, the target locale, the summary's keyboard label); `omarchycn` merges it onto iso `quattro`. omarchy-pkgs: `omarchycn` is upstream `master`.
|
||||||
|
|
||||||
|
omarchy `omarchycn` merges them onto `quattro` and adds what no PR can carry yet: the Chinese wording for Region and China Apps and the language half of the China manual (needs both language and region), and, until #13690 rebases onto upstream's ARM templates, an integration-only pacman resolution and a guard that refuses `omarchy region cn` off x86. Known failure there: `pacman-templates-test` expects x86 refresh to copy templates directly, which #13690 replaces with a render.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] T0 Branches and this list
|
||||||
|
- [x] T1 China package sources (PRD 1): region state, `omarchy-region` reader, `default/region/china/pacman.sed`, applied by `omarchy-refresh-pacman` and `install/post-install/pacman.sh`; tests
|
||||||
|
- [x] T2 Region choice in the installer (PRD 2): `omarchy_prompt_region` in `setup-form.sh`, ISO configurator screen, orchestrator passes `OMARCHY_REGION`, `install/config/region.sh` records it
|
||||||
|
- [x] T3 Input method (PRD 3): rime-ice-installer patch and PKGBUILD in omarchy-pkgs, IME packages in `omarchy-other.packages`, install-time package add, user step runs the installer from bundled assets, ISO build bundles assets and the package
|
||||||
|
- [x] T4 CJK fontconfig fix (PRD 4): rebuild PR 9693 on the current base
|
||||||
|
- [x] T5 AI (PRD 5): Dim and Kimi Code for every region (rebuild PR 9694), Claude Code third-party provider setup shown only in China
|
||||||
|
- [x] T6 Developer mirrors (PRD 6): npm, PyPI/uv, Go, rustup, crates.io, RubyGems, Maven via system drop-ins under `default/region/china/`
|
||||||
|
- [x] T7 China software (PRD 7): Install menu entry shown only in China
|
||||||
|
- [x] T8 Region switching and Chinese UI (PRD 8): `omarchy-region china|global`, Setup > Region, locale, Chinese menu overlay chosen by `LANG`
|
||||||
|
- [x] T9 Logo (PRD 9): stock Omarchy logo everywhere
|
||||||
|
- [x] T10 Docs: `AGENTS.md`, manual
|
||||||
|
- [x] T11 Test ISO build (local source)
|
||||||
|
- [x] T12 QEMU install and verification: China and Global installs, region switch, `omarchy update` keeps region and language
|
||||||
|
- [x] T13 Acceptance checklist
|
||||||
|
- [x] T14 Chinese installer (owner request, parity with the old OmarchyCN ISO): choosing China on the VT reruns the installer in cage + foot with CJK fonts and a US keyboard, falling back to the English VT; there the region screen is bilingual and Global returns to the VT; every later installer screen, the shared setup form and the install dashboard read in Chinese
|
||||||
|
- [x] T15 Global to China fails on local-source installs (owner report): `rime-ice-installer` lives only in the ISO's offline mirror, so a Global install cannot fetch it later; local-source installs install every pinned local build, and `install/config/region.sh` installs the China packages before it writes any region state
|
||||||
|
- [x] T16 A region switch offers to log out at once, since the language and the developer mirrors only reach a new session (owner request)
|
||||||
|
- [x] T17 The desktop shell reads in Chinese under `zh_CN` (owner report): notifications, the bar panels (agents, network, display and the rest), the menu's search prompt — review cap reached: five codex rounds; the round-5 findings (user text translated at display, custom reminder list lines, reminder tooltips and errors) are fixed by wording every notification at its sender and verified by tests and in the China VM, without a sixth review
|
||||||
|
- [x] T18 Merge the official region-profile PRs (omarchy #13690, #14399; omarchy-iso #206, #236) and align to them: region code `cn` in `/etc/omarchy/region`, China files under `default/regions/cn/`, pacman rendered by `omarchy-apply-pacman` (`.cn` twins first, USTC fallback, ArchLinuxCN with its keyring), offline timezone `Asia/Shanghai`; the ISO keeps the first-screen region choice and bundles the China profile, `--region cn` preselects China
|
||||||
|
|
||||||
|
- [x] T19 Merge the reworked #13690 (timezone-derived region) and align: `mirrorlist-<channel>.prepend` and `timezones` replace `servers.sed`, `mirrorlist.append` and `timezone`; drop #14399's timezone preselection (closed upstream); `install/config/region.sh` reads `/etc/omarchy/region` instead of `OMARCHY_REGION`; drop the dead `china` rename migration
|
||||||
|
- [x] T20 Merge #14433 (input methods) and remove Rime: packages, `install/user/region.sh`, the Rime steps in `install/config/region.sh` and `omarchy-region`
|
||||||
|
- [x] T21 Split language from region: `omarchy-language en|zh` and Setup > Language own `LANG`; `omarchy-region` and `install/config/region.sh` stop touching the locale
|
||||||
|
- [x] T22 omarchy-pkgs: revert the `rime-ice-installer` package
|
||||||
|
- [x] T23 omarchy-iso: merge upstream `quattro` (#237) and #241; remove `--region`, `prepare-region.sh`, the region skel, the region preselected timezone, Rime bundling and its pin
|
||||||
|
- [x] T24 omarchy-iso: the language screen replaces the region screen; Chinese reruns in cage + foot with `noto-fonts-cjk` unpacked from the offline repository (no font packages added to the ISO); the CJK console lists only US-keymap keyboards and preselects Chinese (Simplified, Pinyin); the target gets `zh_CN.UTF-8` through `locale_config`; the harness takes `--language zh`
|
||||||
|
- [x] T25 Docs: `AGENTS.md`, `docs/regions.md`, `manual/52-china.md`, omarchy-iso README
|
||||||
|
- [x] T26 Build the local-source ISO and test in QEMU: Chinese + `Asia/Shanghai`, English + `America/New_York`, runtime region and language switches
|
||||||
|
- [x] T27 Acceptance checklist for T19–T26
|
||||||
|
|
||||||
|
- [x] T28 Drop the Claude Code API setup, the Dim/Kimi roster and migration, and the `omarchy-pkg-add` change (omarchy)
|
||||||
|
- [x] T29 Drop `pin_local_packages` and the pin list (omarchy-iso)
|
||||||
|
- [x] T30 The CJK console skips the second language screen; the summary's keyboard row shows the chosen layout, not its keymap (omarchy-iso)
|
||||||
|
- [x] T31 The CJK console fills the screen and the summary keeps the logo in view (omarchy-iso)
|
||||||
|
- [x] T32 Rebuild omarchy as PR branches on current upstream: CJK fontconfig, language and menus, shell text, cn developer mirrors (on #13690), Setup > Region and China Apps; `omarchycn` becomes their merge
|
||||||
|
- [x] T33 Rebuild omarchy-iso as one PR branch on iso #241 and current upstream; `omarchycn` becomes it; omarchy-pkgs follows upstream master
|
||||||
|
- [x] T34 Build the local-source ISO and test in QEMU: Chinese offline, English
|
||||||
|
- [x] T35 Acceptance checklist for T28–T34
|
||||||
|
- [x] T36 Open the PRs one at a time from Raiden-MeixElysia
|
||||||
|
|
||||||
|
## Open PRs (T36, 2026-10-12, from Raiden-MeixElysia)
|
||||||
|
|
||||||
|
- omacom/omarchy#15116 `cjk-variant-selection`: Noto CJK variant fontconfig. Mergeable.
|
||||||
|
- omacom/omarchy#15117 `chinese-language`: Simplified Chinese as a system language. Mergeable; rebased onto #15091, which removed USB and Thunderbolt approval and with it their Chinese text.
|
||||||
|
- omacom/omarchy-iso#263 `chinese-installer`: language first, CJK console, Chinese installer. Stacked on iso #241, upstream merged in; needs #15117.
|
||||||
|
- omacom/omarchy#15118 `cn-developer-mirrors`: stacked on #13690; shows #13690's conflicts until it rebases.
|
||||||
|
- omacom/omarchy#15119 `cn-region-switch`: stacked on #15118.
|
||||||
|
- Not yet opened: the Chinese wording for Region and China Apps (commit in `omarchycn`), once #15117 and #15119 land.
|
||||||
|
|
||||||
|
## Acceptance checklist for T28–T34 (maintainers' ISO test, 2026-10-11)
|
||||||
|
|
||||||
|
| # | Request | Result | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Drop `pin_local_packages` and its hooks | Pass | omarchy-iso 2a589e9; installed `pacman.conf` has no `IgnorePkg`, `/etc/pacman.d/hooks` holds only upstream hooks (30-state). |
|
||||||
|
| 2 | Drop `omarchy-setup-claude-api` and the Dim/Kimi roster and migration | Pass | omarchy f2af41bb; nothing of them in any PR branch; `omarchy region global` touches no Claude settings. |
|
||||||
|
| 3 | No second language screen in the CJK console | Pass | omarchy-iso 2c523af; cage + foot opens on the keyboard screen (04-keyboard). |
|
||||||
|
| 4 | No black strip; logo whole on the summary; keyboard row shows Pinyin | Pass | omarchy-iso 411acd6 (`[csd] preferred=none`, `line-height=22px`) and 2c523af (`keyboard_label`); 04-keyboard, 14-summary. |
|
||||||
|
| 5 | PRs carry only our changes, stacked on #13690; ISO bundles David's #14433 | Pass | T32/T33 branches; #14433 and iso #237 come from upstream `quattro`; region branches stack on #13690, the ISO branch on iso #241. |
|
||||||
|
| - | Rebuilt ISO, Chinese offline and English | Pass | T34 evidence below. |
|
||||||
|
| - | Automated tests | No new failures | Per-branch shell tests pass on their bases; `fontconfig-cjk-test` needs the installed font set and passes in full on the T34 VM (48-fontconfig-test); the integration suite fails only where upstream fails, plus `pacman-templates-test` until #13690 rebases; ISO unit tests fail only `test_keyboard` (no `localectl`), as upstream. |
|
||||||
|
|
||||||
|
Open: `pacman-templates-test` and the ARM guard wait on #13690's rebase; the Chinese wording for Region and China Apps waits on the language and region PRs; the upstream menu acceptance test still walks Style > Menu Bar.
|
||||||
|
|
||||||
|
## T34 evidence (2026-10-12)
|
||||||
|
|
||||||
|
ISO `release/omarchy-2026.10.12-x86_64-local.iso` from omarchy `omarchycn`, omarchy-iso `omarchycn` (c407101), omarchy-pkgs upstream master (d47199f2). Screenshots in `~/omarchy-vm/shots/`.
|
||||||
|
|
||||||
|
- Chinese, offline (`OFFLINE=on`): the VT language screen, then cage + foot opens straight on the keyboard screen with Pinyin preselected and no strip (04-keyboard); the summary keeps the whole logo and shows `键盘 Chinese (Simplified, Pinyin)` (14-summary); installed in 4m 0s (18-progress). First boot: Chinese desktop and notifications (21-desktop); `LANG=zh_CN.UTF-8`, region `cn`, `mirror.omarchy.cn` first, `[archlinuxcn]`, no `IgnorePkg` or pin hooks, developer mirror links and `NPM_CONFIG_REGISTRY`/`GOPROXY` in the session, Fcitx5 profile with Pinyin (30-state); `Super + I`, `nihao`, Space typed 你好 (31-pinyin); Setup shows 语言 and 地区 (34-setup); `fc-match` gives SC for untagged Han under zh_CN and TC, HK, JP, KR for tagged text, Liberation Sans for tagged Latin (36-fonts).
|
||||||
|
- Runtime, online: `omarchy region global` → Global, no marker, `.org` mirror, no ArchLinuxCN, links gone, `LANG` kept, Chinese logout prompt (42, 43); `omarchy language en` → `en_US.UTF-8`, English logout prompt (44); `omarchy region cn` → China, `.cn` first, ArchLinuxCN, links back, `LANG` kept (45, 46); `omarchy language zh` → `zh_CN.UTF-8` (47). The `.cn` edge mirror lagged `.org` by one sqlite release, which the cn refresh downgraded.
|
||||||
|
- English, harness: install and first boot pass; four acceptance failures, all upstream: Mozc Qt, weather OCR, `omarchy-bore` vs `linux-omarchy`, and the menu test still walking Style > Menu Bar, which upstream moved to Trigger > Appearance.
|
||||||
|
|
||||||
|
## Acceptance checklist
|
||||||
|
|
||||||
|
Evidence comes from the T26 runs of `omarchy-iso/release/omarchy-2026.10.09-x86_64-local.iso` (omarchy fcd681c0, omarchy-iso 30f63b6, omarchy-pkgs a4cc3234): run A in host QEMU with the guest cut off from the network during install, run B in the ISO test harness.
|
||||||
|
|
||||||
|
| # | Requirement (discussion of 2026-10-08) | Result | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Language picker on the first screen; Chinese gets cage + foot | Pass | Run A: the VT screen offers English and Chinese; Chinese reruns in cage + foot on a bilingual language screen, and every later screen and the install dashboard read in Chinese. |
|
||||||
|
| 2 | CJK font from the offline repository, not added to the ISO | Pass | `unpack_cjk_font` extracts `NotoSansCJK-Regular.ttc` from the offline mirror's `noto-fonts-cjk`; the ISO package list drops `ttf-jetbrains-mono` and `wqy-microhei` and keeps only `cage` and `foot`. |
|
||||||
|
| 3 | Translated installer strings and the target locale | Pass | Run A: summary, finish screen and dashboard in Chinese; installed `LANG=zh_CN.UTF-8` through `locale_config`. Run B: English throughout, `LANG=en_US.UTF-8`. |
|
||||||
|
| 4 | Region from the timezone, no extra step (#13690, #241) | Pass | Run A: `Asia/Shanghai` shows 区域 cn on the summary and writes `/etc/omarchy/region` = `cn`. Run B: `America/New_York` installs Global with no marker. |
|
||||||
|
| 5 | Offline install ready without network | Pass | Run A installed in 8m13s with the guest network cut off; ArchLinuxCN keyring trusted, `mirror.omarchy.cn` first, Pinyin ready at first login. |
|
||||||
|
| 6 | `.cn` pacman mirrors from #13690; ours dropped | Pass | China: `mirror.omarchy.cn` before `mirror.omarchy.org`, `[archlinuxcn]` from USTC. Global: mirrorlist equals the channel template; `pacman.conf` differs only by the local-build `IgnorePkg` pin. |
|
||||||
|
| 7 | Developer mirrors on the cn profile | Pass | China links `/etc/environment.d/90-omarchy-china.conf`, `/.cargo/config.toml`, `/etc/gemrc`; Global removes them and `NPM_CONFIG_REGISTRY` is gone after relogin. |
|
||||||
|
| 8 | Input methods from dhh's #14433; Rime removed | Pass | Chinese preselects Chinese (Simplified, Pinyin); `INPUT_METHOD=pinyin`; `Ctrl + Shift + Space` then `nihao` typed 你好. No Rime package, asset or pin remains. |
|
||||||
|
| 9 | Region and language switch separately at runtime | Pass | `omarchy region global` and `cn` leave `LANG` alone; `omarchy language en` and `zh` leave the region alone; each offers logout in the right language; Claude Code API and China Apps follow the region. |
|
||||||
|
| 10 | `omarchy update` keeps region and language | Pass | After `omarchy update -y`: `LANG=zh_CN.UTF-8`, region `cn`, `mirror.omarchy.cn` first, `[archlinuxcn]` present. |
|
||||||
|
| - | Automated tests | Pass, no new regressions | omarchy shell suite in an Arch container: every failure is environmental and predates these tasks; some sit in branch-only tests (`fontconfig-cjk-test` needs the Omarchy font set, `hyprland-jis-bindings-test` needs `lua`, `menu-plugin-test` and `tailscale-receive-test` chmod a symlink on the read-only mount). omarchy-iso `./test/all` passes apart from `test_keyboard` (needs systemd's `localectl`). Harness acceptance: four failures outside this branch (Mozc Qt; weather and reminder prompts on screen but missed by OCR; `linux-omarchy` vs `omarchy-bore` kernel). |
|
||||||
|
|
||||||
|
Open items outside this branch: the shipping prerequisites above; the four acceptance failures listed; `.automated_script.sh` exits 1 when its dashboard returns without rebooting after the mirror warm-up has finished, from upstream's `EXIT` trap under `set -e`; each `omarchy region` switch asks for the sudo password several times through refresh's command-scoped `sudo` calls.
|
||||||
@@ -502,15 +502,6 @@ var table = {
|
|||||||
"Opaque windows": "窗口不透明",
|
"Opaque windows": "窗口不透明",
|
||||||
"Transparent windows": "窗口透明",
|
"Transparent windows": "窗口透明",
|
||||||
"Glassy windows": "窗口毛玻璃",
|
"Glassy windows": "窗口毛玻璃",
|
||||||
"USB accessory blocked": "已拦截 USB 配件",
|
|
||||||
"Click to review it. Keep it blocked unless you recognize the device.": "点击查看。除非认得这个设备,否则保持拦截。",
|
|
||||||
"Thunderbolt accessory blocked": "已拦截雷电接口配件",
|
|
||||||
"Thunderbolt approval needs retry": "雷电接口授权需要重试",
|
|
||||||
"The last approval could not be confirmed. Click to retry it.": "上次授权未能确认。点击重试。",
|
|
||||||
"Thunderbolt protection needs attention": "雷电接口保护需要处理",
|
|
||||||
"The approval or firmware policy could not be confirmed. Click for details.": "授权或固件策略未能确认。点击查看详情。",
|
|
||||||
"Finish Thunderbolt protection setup": "完成雷电接口保护设置",
|
|
||||||
"A connected accessory needs secure enrollment. Protection is not enabled. Click to finish safely.": "已连接的配件需要安全登记,保护尚未开启。点击完成设置。",
|
|
||||||
"Input Methods": "输入法",
|
"Input Methods": "输入法",
|
||||||
"Keyboard Layouts": "键盘布局",
|
"Keyboard Layouts": "键盘布局",
|
||||||
"Input methods could not be updated": "无法更新输入法",
|
"Input methods could not be updated": "无法更新输入法",
|
||||||
@@ -643,6 +634,8 @@ var table = {
|
|||||||
"Later": "稍后",
|
"Later": "稍后",
|
||||||
"Log out now to switch the language?": "立即注销以切换语言?",
|
"Log out now to switch the language?": "立即注销以切换语言?",
|
||||||
"Log back in to switch the language.": "重新登录后切换语言。",
|
"Log back in to switch the language.": "重新登录后切换语言。",
|
||||||
|
"Log out now to switch the developer mirrors?": "立即注销以切换开发工具镜像?",
|
||||||
|
"Log back in to switch the developer mirrors.": "重新登录后切换开发工具镜像。",
|
||||||
"Superwhisper Dictation Ready": "Superwhisper 语音输入就绪",
|
"Superwhisper Dictation Ready": "Superwhisper 语音输入就绪",
|
||||||
"Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X).": "按住右 Alt 或 F9 说话(或用 Super + Ctrl + X 切换)。",
|
"Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X).": "按住右 Alt 或 F9 说话(或用 Super + Ctrl + X 切换)。",
|
||||||
"Background Intros": "背景开场动画",
|
"Background Intros": "背景开场动画",
|
||||||
|
|||||||
@@ -422,7 +422,8 @@ var GUARD_READERS = [
|
|||||||
"omarchy-hyprland-corners",
|
"omarchy-hyprland-corners",
|
||||||
"omarchy-hyprland-window-transparency",
|
"omarchy-hyprland-window-transparency",
|
||||||
"omarchy-hyprland-workspace-layout-default",
|
"omarchy-hyprland-workspace-layout-default",
|
||||||
"omarchy-language"
|
"omarchy-language",
|
||||||
|
"omarchy-region"
|
||||||
]
|
]
|
||||||
|
|
||||||
// Package and command presence account for most of what the guards ask, and
|
// Package and command presence account for most of what the guards ask, and
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|||||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||||
copy_boundary_file bin/omarchy-channel-set
|
copy_boundary_file bin/omarchy-channel-set
|
||||||
copy_boundary_file bin/omarchy-refresh-pacman
|
copy_boundary_file bin/omarchy-refresh-pacman
|
||||||
|
copy_boundary_file bin/omarchy-apply-pacman
|
||||||
copy_boundary_file bin/omarchy-update
|
copy_boundary_file bin/omarchy-update
|
||||||
export OMARCHY_UPDATE_LOGGED=1
|
export OMARCHY_UPDATE_LOGGED=1
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ run_channel() {
|
|||||||
: >"$log_file"
|
: >"$log_file"
|
||||||
OMARCHY_CHANNEL_TEST_LOG="$log_file" \
|
OMARCHY_CHANNEL_TEST_LOG="$log_file" \
|
||||||
OMARCHY_PATH="${OMARCHY_TEST_PATH:-$package_root}" \
|
OMARCHY_PATH="${OMARCHY_TEST_PATH:-$package_root}" \
|
||||||
|
OMARCHY_REGION_FILE="${OMARCHY_TEST_REGION_FILE:-$test_tmp/no-region}" \
|
||||||
HOME="$test_tmp/home" \
|
HOME="$test_tmp/home" \
|
||||||
PATH="$stub_bin:$ROOT/bin:$PATH" \
|
PATH="$stub_bin:$ROOT/bin:$PATH" \
|
||||||
"${OMARCHY_TEST_PATH:-$package_root}/bin/omarchy-channel-set" "$@"
|
"${OMARCHY_TEST_PATH:-$package_root}/bin/omarchy-channel-set" "$@"
|
||||||
@@ -190,6 +191,58 @@ fi
|
|||||||
assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "switching back to dev links ~/omarchy"
|
assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "switching back to dev links ~/omarchy"
|
||||||
pass "switching back to dev reuses the existing ~/omarchy checkout"
|
pass "switching back to dev reuses the existing ~/omarchy checkout"
|
||||||
|
|
||||||
|
printf 'cn\n' >"$test_tmp/region"
|
||||||
|
if OMARCHY_TEST_REGION_FILE="$test_tmp/region" run_channel dev >"$test_tmp/region.out" 2>"$test_tmp/region.err"; then
|
||||||
|
fail "dev refuses a checkout without this machine's region"
|
||||||
|
fi
|
||||||
|
grep -q "does not support the 'cn' region" "$test_tmp/region.err" || fail "dev explains the missing region" "$(cat "$test_tmp/region.err")"
|
||||||
|
if grep -Eq $'^(link|refresh)\t' "$log_file"; then
|
||||||
|
fail "dev refuses before linking or refreshing pacman" "$(cat "$log_file")"
|
||||||
|
fi
|
||||||
|
pass "dev refuses a checkout without the region before touching pacman"
|
||||||
|
|
||||||
|
# A directory with the region's name, or a helper that cannot run, is not
|
||||||
|
# support: the refresh after linking would fail with dev already linked.
|
||||||
|
mkdir -p "$checkout/default/regions/cn"
|
||||||
|
touch "$checkout/bin/omarchy-apply-pacman"
|
||||||
|
chmod +x "$checkout/bin/omarchy-apply-pacman"
|
||||||
|
if OMARCHY_TEST_REGION_FILE="$test_tmp/region" run_channel dev >"$test_tmp/region.out" 2>"$test_tmp/region.err"; then
|
||||||
|
fail "dev refuses an empty region profile"
|
||||||
|
fi
|
||||||
|
if grep -Eq $'^(link|refresh)\t' "$log_file"; then
|
||||||
|
fail "dev refuses an empty region profile before linking or refreshing pacman" "$(cat "$log_file")"
|
||||||
|
fi
|
||||||
|
mkdir -p "$checkout/default/regions/cn/pacman"
|
||||||
|
cn_fragments=(pacman.conf.append mirrorlist-stable.prepend mirrorlist-rc.prepend mirrorlist-edge.prepend)
|
||||||
|
for missing in "${cn_fragments[@]}"; do
|
||||||
|
for fragment in "${cn_fragments[@]}"; do
|
||||||
|
if [[ $fragment == "$missing" ]]; then
|
||||||
|
rm -f "$checkout/default/regions/cn/pacman/$fragment"
|
||||||
|
else
|
||||||
|
touch "$checkout/default/regions/cn/pacman/$fragment"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if OMARCHY_TEST_REGION_FILE="$test_tmp/region" run_channel dev >"$test_tmp/region.out" 2>"$test_tmp/region.err"; then
|
||||||
|
fail "dev refuses a region profile without $missing"
|
||||||
|
fi
|
||||||
|
if grep -Eq $'^(link|refresh)\t' "$log_file"; then
|
||||||
|
fail "dev refuses a profile without $missing before linking or refreshing pacman" "$(cat "$log_file")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
touch "${cn_fragments[@]/#/$checkout/default/regions/cn/pacman/}"
|
||||||
|
chmod -x "$checkout/bin/omarchy-apply-pacman"
|
||||||
|
if OMARCHY_TEST_REGION_FILE="$test_tmp/region" run_channel dev >"$test_tmp/region.out" 2>"$test_tmp/region.err"; then
|
||||||
|
fail "dev refuses a checkout whose pacman helper cannot run"
|
||||||
|
fi
|
||||||
|
if grep -Eq $'^(link|refresh)\t' "$log_file"; then
|
||||||
|
fail "dev refuses incomplete region support before linking or refreshing pacman" "$(cat "$log_file")"
|
||||||
|
fi
|
||||||
|
pass "dev refuses a region profile missing any fragment or a helper that cannot run"
|
||||||
|
|
||||||
|
chmod +x "$checkout/bin/omarchy-apply-pacman"
|
||||||
|
OMARCHY_TEST_REGION_FILE="$test_tmp/region" run_channel dev
|
||||||
|
assert_log_line $'refresh\tedge' "dev proceeds with a checkout that supports the region"
|
||||||
|
|
||||||
current_channel() {
|
current_channel() {
|
||||||
OMARCHY_TEST_VERSION_CHANNEL="$1" \
|
OMARCHY_TEST_VERSION_CHANNEL="$1" \
|
||||||
OMARCHY_TEST_PACKAGES="$2" \
|
OMARCHY_TEST_PACKAGES="$2" \
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||||
|
|
||||||
|
require_command node
|
||||||
|
|
||||||
|
node - "$ROOT" <<'JS'
|
||||||
|
const fs = require('fs')
|
||||||
|
const root = process.argv[2]
|
||||||
|
const model = require(`${root}/shell/plugins/menu/MenuModel.js`)
|
||||||
|
const items = model.parseMenuJsonc(fs.readFileSync(`${root}/default/omarchy/omarchy-menu.jsonc`, 'utf8'))
|
||||||
|
const china = items.filter(item => item.id === 'install.china' || item.id.startsWith('install.china.'))
|
||||||
|
const guard = '[[ "$(omarchy-region)" == "China" ]]'
|
||||||
|
const fail = message => { console.error(`not ok - ${message}`); process.exit(1) }
|
||||||
|
|
||||||
|
const aur = {
|
||||||
|
wechat: 'wechat-universal-bwrap', qq: 'linuxqq', feishu: 'feishu-bin',
|
||||||
|
dingtalk: 'dingtalk-bin', wemeet: 'wemeet-bin', wps: 'wps-office-cn',
|
||||||
|
}
|
||||||
|
const web = {
|
||||||
|
'tencent-docs': ['腾讯文档', 'https://docs.qq.com'],
|
||||||
|
yuque: ['语雀', 'https://www.yuque.com/dashboard'],
|
||||||
|
shimo: ['石墨文档', 'https://shimo.im/desktop'],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (china.length !== 1 + Object.keys(aur).length + Object.keys(web).length) fail(`China Apps has exactly the expected entries: ${china.map(i => i.id)}`)
|
||||||
|
if (!china.every(item => item.when === guard)) fail('every China Apps entry, including search hits, shows only in China')
|
||||||
|
for (const [id, pkg] of Object.entries(aur)) {
|
||||||
|
const item = china.find(i => i.id === `install.china.${id}`)
|
||||||
|
if (!item || item.action !== `omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add ${pkg}'` || item.disabled !== `omarchy-pkg-present ${pkg}`)
|
||||||
|
fail(`${id} installs and checks the AUR package ${pkg}`)
|
||||||
|
}
|
||||||
|
for (const [id, [name, url]] of Object.entries(web)) {
|
||||||
|
const item = china.find(i => i.id === `install.china.${id}`)
|
||||||
|
if (!item || item.action !== `omarchy-launch-floating-terminal-with-presentation 'omarchy-webapp-install ${name} ${url} ""'` || item.disabled !== `[[ -f "$HOME/.local/share/applications/${name}.desktop" ]]`)
|
||||||
|
fail(`${id} installs and checks the ${name} web app`)
|
||||||
|
}
|
||||||
|
console.log('ok - China Apps lists the AUR packages and web apps, shown only in China')
|
||||||
|
JS
|
||||||
|
|
||||||
|
aur_names="wechat-universal-bwrap linuxqq feishu-bin dingtalk-bin wemeet-bin wps-office-cn"
|
||||||
|
if [[ ${OMARCHY_TEST_NETWORK:-} == "1" ]]; then
|
||||||
|
query=$(printf '&arg[]=%s' $aur_names)
|
||||||
|
found=$(curl -fsSL "https://aur.archlinux.org/rpc/v5/info?${query#&}" | jq -r '.results[].Name' | sort | tr '\n' ' ')
|
||||||
|
[[ $found == "$(printf '%s\n' $aur_names | sort | tr '\n' ' ')" ]] || fail "every China Apps package exists in the AUR" "$found"
|
||||||
|
pass "every China Apps package exists in the AUR"
|
||||||
|
else
|
||||||
|
skip "AUR existence check needs OMARCHY_TEST_NETWORK=1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
test_tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$test_tmp"' EXIT
|
||||||
|
mkdir -p "$test_tmp/bin" "$test_tmp/home"
|
||||||
|
cat >"$test_tmp/bin/curl" <<'SH'
|
||||||
|
#!/bin/bash
|
||||||
|
while (($#)); do [[ $1 == "-o" ]] && out=$2; shift; done
|
||||||
|
[[ -n ${out:-} ]] && printf '\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01\x08\x06\0\0\0\x1f\x15\xc4\x89' >"$out"
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
printf '#!/bin/bash\necho "the web app wizard opened" >&2\nexit 1\n' >"$test_tmp/bin/gum"
|
||||||
|
chmod +x "$test_tmp/bin/curl" "$test_tmp/bin/gum"
|
||||||
|
|
||||||
|
grep -o "omarchy-launch-floating-terminal-with-presentation 'omarchy-webapp-install [^']*'" "$ROOT/default/omarchy/omarchy-menu.jsonc" |
|
||||||
|
sed -e "s/^[^']*'//" -e "s/'$//" -e 's/\\"/"/g' >"$test_tmp/actions"
|
||||||
|
(($(wc -l <"$test_tmp/actions") == 3)) || fail "the menu has three China web app actions"
|
||||||
|
while read -r action; do
|
||||||
|
HOME="$test_tmp/home" PATH="$test_tmp/bin:$ROOT/bin:$PATH" bash -c "$action" >/dev/null ||
|
||||||
|
fail "the menu action installs its web app: $action"
|
||||||
|
done <"$test_tmp/actions"
|
||||||
|
for app in 腾讯文档 语雀 石墨文档; do
|
||||||
|
[[ -f $test_tmp/home/.local/share/applications/$app.desktop ]] || fail "$app gets a launcher"
|
||||||
|
done
|
||||||
|
pass "each China web app action creates its launcher without the wizard"
|
||||||
+341
@@ -0,0 +1,341 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(dirname "$0")/base-test.sh"
|
||||||
|
|
||||||
|
shipped_migration="$ROOT/migrations/1791769486.sh"
|
||||||
|
[[ -f $shipped_migration ]] || fail "the device authorization removal migration exists"
|
||||||
|
|
||||||
|
test_dir=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$test_dir"' EXIT
|
||||||
|
|
||||||
|
root="$test_dir/root"
|
||||||
|
home="$test_dir/home"
|
||||||
|
migration="$test_dir/migration.sh"
|
||||||
|
export CALLS="$test_dir/calls" FAKE_ROOT="$root"
|
||||||
|
|
||||||
|
# Run a scratch copy whose machine paths all live under the fake root.
|
||||||
|
sed -e "s#/etc/#$root/etc/#g" -e "s#/var/#$root/var/#g" -e "s#/run/#$root/run/#g" \
|
||||||
|
-e "s#/sys/#$root/sys/#g" -e "s#:-/boot}#:-$root/boot}#" -e "s#/usr/share/polkit-1/#$root/usr/share/polkit-1/#g" \
|
||||||
|
"$shipped_migration" >"$migration"
|
||||||
|
|
||||||
|
mkdir -p "$test_dir/bin"
|
||||||
|
|
||||||
|
cat >"$test_dir/bin/sudo" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'sudo %s\n' "$*" >>"$CALLS"
|
||||||
|
exec "$@"
|
||||||
|
STUB
|
||||||
|
|
||||||
|
cat >"$test_dir/bin/systemctl" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'systemctl %s\n' "$*" >>"$CALLS"
|
||||||
|
STUB
|
||||||
|
|
||||||
|
# Bolt: devices/<uid>/<Property> and domains/<uid>/BootACL hold JSON values.
|
||||||
|
cat >"$test_dir/bin/busctl" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
args=()
|
||||||
|
for arg in "$@"; do [[ $arg == --* ]] || args+=("$arg"); done
|
||||||
|
object=${args[2]#/org/freedesktop/bolt/}
|
||||||
|
uid=${object#*/}
|
||||||
|
file="$FAKE_ROOT/bolt/${object%%/*}/${uid//_/-}/${args[4]}"
|
||||||
|
case ${args[0]} in
|
||||||
|
call)
|
||||||
|
kind=devices
|
||||||
|
[[ ${args[4]} == "ListDevices" ]] || kind=domains
|
||||||
|
for dir in "$FAKE_ROOT/bolt/$kind"/*/; do
|
||||||
|
dir=${dir%/}
|
||||||
|
uid=${dir##*/}
|
||||||
|
printf '/org/freedesktop/bolt/%s/%s\n' "$kind" "${uid//-/_}"
|
||||||
|
done | jq -Rnc '{type:"ao",data:[[inputs]]}'
|
||||||
|
;;
|
||||||
|
get-property)
|
||||||
|
[[ -f $file && -z ${BOLT_READ_FAILS:-} ]] || exit 1
|
||||||
|
printf '{"type":"x","data":%s}\n' "$(<"$file")"
|
||||||
|
;;
|
||||||
|
set-property)
|
||||||
|
[[ -f $file ]] || exit 1
|
||||||
|
value=$(printf '%s\n' "${args[@]:7}" | jq -Rnc '[inputs]')
|
||||||
|
(( args[6] > 0 )) || value='[]'
|
||||||
|
# Like Bolt, refuse a BootACL write that changes nothing.
|
||||||
|
[[ $value != "$(jq -c . "$file")" ]] || exit 1
|
||||||
|
printf '%s\n' "$value" >"$file"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
STUB
|
||||||
|
|
||||||
|
cat >"$test_dir/bin/boltctl" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'boltctl %s\n' "$*" >>"$CALLS"
|
||||||
|
case $2 in
|
||||||
|
device.policy) printf '"%s"\n' "$4" >"$FAKE_ROOT/bolt/devices/$3/Policy" ;;
|
||||||
|
global.auth-mode) printf '%s\n' "$3" >"$FAKE_ROOT/bolt/authmode" ;;
|
||||||
|
esac
|
||||||
|
STUB
|
||||||
|
|
||||||
|
# Rebuilds the current entries from the drop-in. Old snapshot entries keep the
|
||||||
|
# parameter. MKINITCPIO=fail fails; MKINITCPIO=stale "succeeds" without
|
||||||
|
# rebuilding, like a kernel image that failed inside the Limine hook.
|
||||||
|
cat >"$test_dir/bin/limine-mkinitcpio" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'limine-mkinitcpio\n' >>"$CALLS"
|
||||||
|
[[ ${MKINITCPIO:-} != "fail" ]] || exit 1
|
||||||
|
[[ ${MKINITCPIO:-} != "stale" ]] || exit 0
|
||||||
|
parameter=""
|
||||||
|
[[ ! -e $FAKE_ROOT/etc/limine-entry-tool.d/usb-authorization.conf ]] || parameter=" usbcore.authorized_default=0"
|
||||||
|
esp=$(sed -n 's/^ESP_PATH="\(.*\)"$/\1/p' "$FAKE_ROOT/etc/default/limine")
|
||||||
|
esp=${esp:-$FAKE_ROOT/boot}
|
||||||
|
mkdir -p "$esp/EFI/Linux"
|
||||||
|
printf 'MZ...cmdline root=x%s...initrd' "$parameter" >"$esp/EFI/Linux/omarchy_linux.efi"
|
||||||
|
cat >"$esp/limine.conf" <<EOF
|
||||||
|
/+Omarchy
|
||||||
|
comment: machine-id=abc
|
||||||
|
//linux
|
||||||
|
comment: kernel-id=linux
|
||||||
|
path: boot():/EFI/Linux/omarchy_linux.efi#0123
|
||||||
|
cmdline: root=x quiet$parameter
|
||||||
|
//Snapshots
|
||||||
|
///1
|
||||||
|
////linux
|
||||||
|
path: boot():/abc/limine_history/omarchy_linux.efi#4567
|
||||||
|
cmdline: root=x usbcore.authorized_default=0
|
||||||
|
EOF
|
||||||
|
STUB
|
||||||
|
|
||||||
|
cat >"$test_dir/bin/omarchy-pkg-present" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
[[ -e $FAKE_ROOT/packages/$1 ]]
|
||||||
|
STUB
|
||||||
|
|
||||||
|
cat >"$test_dir/bin/omarchy-pkg-drop" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
printf 'omarchy-pkg-drop %s\n' "$*" >>"$CALLS"
|
||||||
|
rm -f "$FAKE_ROOT/packages/$1" "$FAKE_ROOT/etc/usbguard/rules.conf"
|
||||||
|
STUB
|
||||||
|
|
||||||
|
cat >"$test_dir/bin/pacman" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
exit 1
|
||||||
|
STUB
|
||||||
|
|
||||||
|
chmod +x "$test_dir/bin/"*
|
||||||
|
|
||||||
|
grant=$'Devices=list,modify,listen\nPolicy=list\nExceptions=listen'
|
||||||
|
dock=b8f58780-002a-50c4-ffff-ffffffffffff
|
||||||
|
other=aaaaaaaa-0000-0000-0000-000000000001
|
||||||
|
domain=895a5509-000a-3075-ffff-ffffffffffff
|
||||||
|
gone_domain=895a5509-010a-3075-ffff-ffffffffffff
|
||||||
|
|
||||||
|
# A machine that never had the feature, with Bolt in its default mode.
|
||||||
|
reset_machine() {
|
||||||
|
rm -rf "$root" "$home"
|
||||||
|
mkdir -p "$root"/{etc/default,var/log,var/lib/omarchy,run,packages,usr/share/polkit-1/actions} \
|
||||||
|
"$root/sys/bus/usb/devices"/{usb1,usb2,1-1} "$root/bolt/devices"/{$dock,$other} "$root/bolt/domains/$domain" \
|
||||||
|
"$home/.config/systemd/user/graphical-session.target.wants" "$home/.local/state/omarchy"
|
||||||
|
echo 'KERNEL_CMDLINE[default]+="quiet"' >"$root/etc/default/limine"
|
||||||
|
echo enabled >"$root/bolt/authmode"
|
||||||
|
echo '"auto"' >"$root/bolt/devices/$dock/Policy"
|
||||||
|
echo 1791668776 >"$root/bolt/devices/$dock/StoreTime"
|
||||||
|
echo '"manual"' >"$root/bolt/devices/$other/Policy"
|
||||||
|
echo 1700000000 >"$root/bolt/devices/$other/StoreTime"
|
||||||
|
echo '["",""]' >"$root/bolt/domains/$domain/BootACL"
|
||||||
|
echo 1 | tee "$root"/sys/bus/usb/devices/{usb1,usb2}/authorized_default "$root"/sys/bus/usb/devices/{usb1,usb2,1-1}/authorized >/dev/null
|
||||||
|
echo '[2026-01-02T03:04:05-0500] [ALPM] installed jq (1.8)' >"$root/var/log/pacman.log"
|
||||||
|
MKINITCPIO=ok "$test_dir/bin/limine-mkinitcpio"
|
||||||
|
}
|
||||||
|
|
||||||
|
# What #11874's default setup left behind on the author's machine.
|
||||||
|
enable_feature() {
|
||||||
|
touch "$root/packages/usbguard"
|
||||||
|
echo '[2026-10-10T17:44:55-0400] [ALPM] installed usbguard (1.1.4-11)' >>"$root/var/log/pacman.log"
|
||||||
|
mkdir -p "$root/etc/usbguard/IPCAccessControl.d" "$root/etc/omarchy" "$root/var/lib/omarchy/thunderbolt-authorization" \
|
||||||
|
"$root/etc/systemd/system/multi-user.target.wants" "$home/.local/state/omarchy/usb-authorization/requests"
|
||||||
|
echo 'allow id 1d6b:0002' >"$root/etc/usbguard/rules.conf"
|
||||||
|
printf '%s\n' "$grant" >"$root/etc/usbguard/IPCAccessControl.d/tester"
|
||||||
|
echo 0 | tee "$root"/sys/bus/usb/devices/{usb1,usb2}/authorized_default >/dev/null
|
||||||
|
echo 0 >"$root/sys/bus/usb/devices/1-1/authorized"
|
||||||
|
touch "$home/.local/state/omarchy/usb-authorization/policy-generated-by-omarchy"
|
||||||
|
touch "$root/usr/share/polkit-1/actions"/org.omarchy.{usb,thunderbolt}.policy
|
||||||
|
touch "$home/.config/systemd/user/omarchy-usb-authorization.service"
|
||||||
|
ln -s ../omarchy-usb-authorization.service "$home/.config/systemd/user/graphical-session.target.wants/omarchy-usb-authorization.service"
|
||||||
|
ln -s /usr/lib/systemd/user/omarchy-thunderbolt-authorization.service "$home/.config/systemd/user/graphical-session.target.wants/omarchy-thunderbolt-authorization.service"
|
||||||
|
ln -s /etc/systemd/system/omarchy-thunderbolt-authorization.service "$root/etc/systemd/system/multi-user.target.wants/omarchy-thunderbolt-authorization.service"
|
||||||
|
touch "$root/etc/omarchy/thunderbolt-authorization.enabled"
|
||||||
|
echo disabled >"$root/bolt/authmode"
|
||||||
|
echo '"manual"' >"$root/bolt/devices/$dock/Policy"
|
||||||
|
cat >"$root/var/lib/omarchy/thunderbolt-authorization/policy.json" <<EOF
|
||||||
|
{"enabled":true,"original_authmode":"enabled","version":1,
|
||||||
|
"enrolled":{"$dock":{"Name":"TS4","StoreTime":1791668776,"Uid":"$dock","Vendor":"CalDigit, Inc."}},
|
||||||
|
"trusted":{"$dock":{"Name":"TS4","Uid":"$dock","Vendor":"CalDigit, Inc."}}}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_usb_at_boot() {
|
||||||
|
mkdir -p "$root/etc/limine-entry-tool.d"
|
||||||
|
echo 'KERNEL_CMDLINE[default]+=" usbcore.authorized_default=0"' >"$root/etc/limine-entry-tool.d/usb-authorization.conf"
|
||||||
|
printf '%s\n' '# Omarchy USB authorization begin' 'SNAPSHOT_KERNEL_PARAMETERS+=usbcore.authorized_default=0' \
|
||||||
|
'# Omarchy USB authorization end' >>"$root/etc/default/limine"
|
||||||
|
MKINITCPIO=ok "$test_dir/bin/limine-mkinitcpio"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_migration() {
|
||||||
|
: >"$CALLS"
|
||||||
|
HOME="$home" USER=tester PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >"$test_dir/output" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
value() { tr -d '\n' <"$root/bolt/$1"; }
|
||||||
|
|
||||||
|
reset_machine
|
||||||
|
run_migration || fail "migration fails on a machine that never had the feature" "$(cat "$test_dir/output")"
|
||||||
|
! grep -q '^sudo' "$CALLS" || fail "migration asks for sudo on a machine that never had the feature" "$(cat "$CALLS")"
|
||||||
|
pass "a machine that never had the feature is left alone without sudo"
|
||||||
|
|
||||||
|
reset_machine
|
||||||
|
touch "$root/packages/usbguard"
|
||||||
|
printf '%s\n' '[2025-03-01T10:00:00-0500] [ALPM] installed usbguard (1.1.3-1)' \
|
||||||
|
'[2026-10-10T17:44:55-0400] [ALPM] upgraded usbguard (1.1.3-1 -> 1.1.4-11)' >>"$root/var/log/pacman.log"
|
||||||
|
run_migration || fail "migration fails beside the user's own USBGuard" "$(cat "$test_dir/output")"
|
||||||
|
[[ -e $root/packages/usbguard ]] || fail "migration removes a USBGuard installed before Omarchy shipped it"
|
||||||
|
! grep -q '^sudo' "$CALLS" || fail "migration asks for sudo for a USBGuard it does not own" "$(cat "$CALLS")"
|
||||||
|
pass "a USBGuard installed before Omarchy shipped it is kept, even after an upgrade"
|
||||||
|
|
||||||
|
reset_machine
|
||||||
|
touch "$root/packages/usbguard"
|
||||||
|
echo '[2026-10-10T09:00:00-0400] [ALPM] installed usbguard (1.1.4-11)' >>"$root/var/log/pacman.log"
|
||||||
|
run_migration || fail "migration fails beside a USBGuard without Omarchy's grant" "$(cat "$test_dir/output")"
|
||||||
|
[[ -e $root/packages/usbguard ]] || fail "migration removes a USBGuard without Omarchy's ownership evidence"
|
||||||
|
pass "a USBGuard without Omarchy's marker or grant is kept"
|
||||||
|
|
||||||
|
reset_machine
|
||||||
|
enable_feature
|
||||||
|
run_migration || fail "migration fails on a machine with the feature" "$(cat "$test_dir/output")"
|
||||||
|
[[ ! -e $root/packages/usbguard ]] || fail "migration keeps the USBGuard Omarchy installed"
|
||||||
|
[[ $(cat "$root"/sys/bus/usb/devices/*/authorized_default "$root"/sys/bus/usb/devices/*/authorized | sort -u) == "1" ]] ||
|
||||||
|
fail "migration leaves USB devices denied after USBGuard exits"
|
||||||
|
[[ ! -e $root/etc/usbguard ]] || fail "migration leaves Omarchy's USBGuard grants behind" "$(find "$root/etc/usbguard")"
|
||||||
|
[[ $(value authmode) == "enabled" && $(value devices/$dock/Policy) == '"auto"' ]] ||
|
||||||
|
fail "migration does not hand Thunderbolt back to Bolt" "$(value authmode) $(value devices/$dock/Policy)"
|
||||||
|
[[ $(value devices/$other/Policy) == '"manual"' ]] || fail "migration changes a manual policy Omarchy did not set"
|
||||||
|
for path in "$root/var/lib/omarchy/thunderbolt-authorization" "$root/etc/omarchy/thunderbolt-authorization.enabled" \
|
||||||
|
"$root/etc/systemd/system/multi-user.target.wants/omarchy-thunderbolt-authorization.service" \
|
||||||
|
"$root/usr/share/polkit-1/actions/org.omarchy.usb.policy" "$root/usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy" \
|
||||||
|
"$home/.local/state/omarchy/usb-authorization" "$home/.config/systemd/user/omarchy-usb-authorization.service" \
|
||||||
|
"$home/.config/systemd/user/graphical-session.target.wants/omarchy-usb-authorization.service" \
|
||||||
|
"$home/.config/systemd/user/graphical-session.target.wants/omarchy-thunderbolt-authorization.service"; do
|
||||||
|
[[ ! -e $path && ! -L $path ]] || fail "migration leaves $path behind"
|
||||||
|
done
|
||||||
|
pass "the default feature is removed and devices connect as before"
|
||||||
|
|
||||||
|
run_migration || fail "a second run fails" "$(cat "$test_dir/output")"
|
||||||
|
! grep -q '^sudo' "$CALLS" || fail "a second run asks for sudo" "$(cat "$CALLS")"
|
||||||
|
pass "a second run is a no-op"
|
||||||
|
|
||||||
|
# A deferred install that never finished setup left only the pending marker,
|
||||||
|
# without changing Bolt's mode; an administrator's own setting stays.
|
||||||
|
reset_machine
|
||||||
|
mkdir -p "$root/etc/omarchy"
|
||||||
|
touch "$root/etc/omarchy/thunderbolt-authorization.pending"
|
||||||
|
echo disabled >"$root/bolt/authmode"
|
||||||
|
run_migration || fail "migration fails with only the pending marker" "$(cat "$test_dir/output")"
|
||||||
|
[[ $(value authmode) == "disabled" ]] || fail "migration enables Bolt authorization it never changed"
|
||||||
|
[[ ! -e $root/etc/omarchy/thunderbolt-authorization.pending ]] || fail "migration leaves the pending marker"
|
||||||
|
pass "Bolt's mode is untouched when Omarchy never saved one"
|
||||||
|
|
||||||
|
# A first setup failed and its undo deleted policy.json before restoring
|
||||||
|
# boltd.conf: Bolt's original config survives only in the setup journal.
|
||||||
|
for original in "$(printf '[config]\nAuthMode = enabled\n' | base64 -w0)" ""; do
|
||||||
|
reset_machine
|
||||||
|
mkdir -p "$root/etc/omarchy" "$root/var/lib/omarchy/thunderbolt-authorization"
|
||||||
|
touch "$root/etc/omarchy/thunderbolt-authorization.enabled"
|
||||||
|
echo disabled >"$root/bolt/authmode"
|
||||||
|
if [[ -n $original ]]; then
|
||||||
|
saved="{\"data\":\"$original\",\"mode\":\"600\"}"
|
||||||
|
else
|
||||||
|
saved=null
|
||||||
|
fi
|
||||||
|
echo "{\"files\":{\"/var/lib/boltd/boltd.conf\":$saved},\"units\":{}}" \
|
||||||
|
>"$root/var/lib/omarchy/thunderbolt-authorization/setup-recovery.json"
|
||||||
|
run_migration || fail "migration fails with only the setup journal" "$(cat "$test_dir/output")"
|
||||||
|
[[ $(value authmode) == "enabled" ]] || fail "migration leaves Bolt off when its original config is only in the setup journal"
|
||||||
|
done
|
||||||
|
pass "Bolt's original mode is recovered from the setup journal"
|
||||||
|
|
||||||
|
# Turning on Thunderbolt Boot was interrupted: the originals are only in the
|
||||||
|
# recovery journal. One controller's list already matches; another is gone.
|
||||||
|
reset_machine
|
||||||
|
enable_feature
|
||||||
|
echo '"manual"' >"$root/bolt/devices/$other/Policy"
|
||||||
|
echo '["",""]' >"$root/bolt/domains/$domain/BootACL"
|
||||||
|
cat >"$root/var/lib/omarchy/thunderbolt-authorization/boot-recovery.json" <<EOF
|
||||||
|
{"policies":{"$other":"iommu","$dock":"manual"},"acls":{"$domain":["$dock",""]},"state":{}}
|
||||||
|
EOF
|
||||||
|
run_migration || fail "migration fails while recovering Thunderbolt Boot" "$(cat "$test_dir/output")"
|
||||||
|
[[ $(value devices/$other/Policy) == '"iommu"' ]] || fail "migration ignores the Thunderbolt Boot recovery journal"
|
||||||
|
[[ $(value domains/$domain/BootACL) == "[\"$dock\",\"\"]" ]] || fail "migration does not restore the firmware boot allowlist"
|
||||||
|
[[ $(value devices/$dock/Policy) == '"auto"' ]] || fail "migration does not hand the enrolled dock back after recovery"
|
||||||
|
[[ ! -e $root/var/lib/omarchy/thunderbolt-authorization ]] || fail "migration keeps fully restored Thunderbolt state"
|
||||||
|
pass "an interrupted Thunderbolt Boot is recovered from its journal"
|
||||||
|
|
||||||
|
reset_machine
|
||||||
|
enable_feature
|
||||||
|
jq --arg d "$domain" --arg g "$gone_domain" --arg dock "$dock" \
|
||||||
|
'.boot_protection=true | .boot_original={policies:{($dock):"manual"},acls:{($d):["",""],($g):[""]}}' \
|
||||||
|
"$root/var/lib/omarchy/thunderbolt-authorization/policy.json" >"$test_dir/policy.json"
|
||||||
|
mv "$test_dir/policy.json" "$root/var/lib/omarchy/thunderbolt-authorization/policy.json"
|
||||||
|
run_migration || fail "migration fails when a list already matches or a controller is gone" "$(cat "$test_dir/output")"
|
||||||
|
[[ -f $root/var/lib/omarchy/thunderbolt-authorization.unrestored/policy.json ]] ||
|
||||||
|
fail "migration discards settings it could not restore"
|
||||||
|
grep -q "$gone_domain is not present" "$test_dir/output" || fail "migration does not say which controller was skipped"
|
||||||
|
[[ $(value devices/$dock/Policy) == '"auto"' ]] || fail "migration stops before handing back the dock"
|
||||||
|
pass "unrestorable firmware settings are kept aside, and matching lists are not rewritten"
|
||||||
|
|
||||||
|
# A Bolt read that fails, unlike a record Bolt no longer has, must leave the
|
||||||
|
# saved settings in place and the migration pending.
|
||||||
|
reset_machine
|
||||||
|
enable_feature
|
||||||
|
BOLT_READ_FAILS=1 run_migration && fail "migration succeeds although Bolt could not be read"
|
||||||
|
[[ -f $root/var/lib/omarchy/thunderbolt-authorization/policy.json ]] || fail "a failed Bolt read discards the saved settings"
|
||||||
|
run_migration || fail "migration does not finish once Bolt can be read" "$(cat "$test_dir/output")"
|
||||||
|
[[ $(value devices/$dock/Policy) == '"auto"' ]] || fail "the retry does not hand back the dock"
|
||||||
|
pass "a failed Bolt read keeps the saved settings and retries"
|
||||||
|
|
||||||
|
# USB at Boot: a failed rebuild, then one that silently leaves the parameter,
|
||||||
|
# must both keep USBGuard and retry, even though the settings are already gone.
|
||||||
|
reset_machine
|
||||||
|
enable_feature
|
||||||
|
enable_usb_at_boot
|
||||||
|
MKINITCPIO=fail run_migration && fail "migration succeeds although the boot rebuild failed"
|
||||||
|
[[ -e $root/packages/usbguard && -e $root/var/lib/omarchy/usb-authorization-boot.removing ]] ||
|
||||||
|
fail "a failed rebuild removes USBGuard or forgets to retry"
|
||||||
|
MKINITCPIO=stale run_migration && fail "migration trusts a rebuild that left the parameter" "$(cat "$test_dir/output")"
|
||||||
|
[[ -e $root/packages/usbguard ]] || fail "migration removes USBGuard while a boot entry still denies USB"
|
||||||
|
grep -q 'limine-mkinitcpio' "$CALLS" || fail "a retry skips the rebuild once the settings are gone"
|
||||||
|
MKINITCPIO=ok run_migration || fail "migration fails once the rebuild succeeds" "$(cat "$test_dir/output")"
|
||||||
|
[[ ! -e $root/packages/usbguard && ! -e $root/var/lib/omarchy/usb-authorization-boot.removing ]] ||
|
||||||
|
fail "migration does not finish after a successful rebuild"
|
||||||
|
grep -q 'usbcore' "$root/etc/default/limine" && fail "migration leaves the snapshot parameter in /etc/default/limine"
|
||||||
|
pass "USB at Boot is removed only once the rebuilt entries are verified"
|
||||||
|
|
||||||
|
# A boot partition mounted somewhere other than /boot, named by ESP_PATH.
|
||||||
|
reset_machine
|
||||||
|
rm -rf "$root/boot"
|
||||||
|
echo "ESP_PATH=\"$root/efi\"" >>"$root/etc/default/limine"
|
||||||
|
enable_feature
|
||||||
|
enable_usb_at_boot
|
||||||
|
MKINITCPIO=stale run_migration && fail "migration trusts a stale rebuild on a boot partition outside /boot"
|
||||||
|
MKINITCPIO=ok run_migration || fail "migration does not check the boot partition named by ESP_PATH" "$(cat "$test_dir/output")"
|
||||||
|
[[ ! -e $root/packages/usbguard ]] || fail "migration keeps USBGuard after a verified rebuild outside /boot"
|
||||||
|
pass "boot entries are verified on the boot partition named by ESP_PATH"
|
||||||
|
|
||||||
|
# Under Secure Boot the signed image's own command line is what boots.
|
||||||
|
reset_machine
|
||||||
|
enable_feature
|
||||||
|
enable_usb_at_boot
|
||||||
|
mkdir -p "$root/sys/firmware/efi/efivars"
|
||||||
|
printf '\x06\x00\x00\x00\x01' >"$root/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c"
|
||||||
|
sed -i 's/ usbcore.authorized_default=0$//' "$root/boot/limine.conf"
|
||||||
|
MKINITCPIO=stale run_migration && fail "migration trusts Limine's command line over the signed image's"
|
||||||
|
MKINITCPIO=ok run_migration || fail "migration fails under Secure Boot after a clean rebuild" "$(cat "$test_dir/output")"
|
||||||
|
pass "Secure Boot images are checked for the parameter too"
|
||||||
@@ -9,7 +9,11 @@ export SUDO_TEST_LOG="$boundary_tmp/events"
|
|||||||
export SUDO_TEST_CACHE="$boundary_tmp/cache"
|
export SUDO_TEST_CACHE="$boundary_tmp/cache"
|
||||||
export OMARCHY_PATH="$SUDO_TEST_ROOT"
|
export OMARCHY_PATH="$SUDO_TEST_ROOT"
|
||||||
export SUDO_TEST_HOME="$boundary_tmp/home"
|
export SUDO_TEST_HOME="$boundary_tmp/home"
|
||||||
mkdir -p "$SUDO_TEST_HOME"
|
# The machine the render reads its region marker from. It starts global; a test
|
||||||
|
# writes etc/omarchy/region here to model a regional install, never the host's.
|
||||||
|
export SUDO_TEST_TARGET="$boundary_tmp/target"
|
||||||
|
export OMARCHY_REGION_FILE="$SUDO_TEST_TARGET/etc/omarchy/region"
|
||||||
|
mkdir -p "$SUDO_TEST_HOME" "$SUDO_TEST_TARGET/etc"
|
||||||
mkdir -p "$SUDO_TEST_ROOT/bin" "$SUDO_TEST_ROOT/mock" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update"
|
mkdir -p "$SUDO_TEST_ROOT/bin" "$SUDO_TEST_ROOT/mock" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update"
|
||||||
: >"$SUDO_TEST_LOG"
|
: >"$SUDO_TEST_LOG"
|
||||||
|
|
||||||
@@ -24,6 +28,10 @@ s=(source/name).read_text().replace('$HOME', '$SUDO_TEST_HOME')
|
|||||||
for command in ['sudo','pkexec','pacman','omarchy-pkg-missing','systemd-inhibit','setpriv','snapper']:
|
for command in ['sudo','pkexec','pacman','omarchy-pkg-missing','systemd-inhibit','setpriv','snapper']:
|
||||||
s=s.replace('/usr/bin/'+command, str(target/'mock'/command))
|
s=s.replace('/usr/bin/'+command, str(target/'mock'/command))
|
||||||
s=s.replace('PATH=/usr/bin:/usr/sbin:/bin:/sbin', 'PATH="'+str(target/'bin')+':/usr/bin:/usr/sbin:/bin:/sbin"')
|
s=s.replace('PATH=/usr/bin:/usr/sbin:/bin:/sbin', 'PATH="'+str(target/'bin')+':/usr/bin:/usr/sbin:/bin:/sbin"')
|
||||||
|
if name.name == 'omarchy-apply-pacman':
|
||||||
|
render_etc='etc="${4:-/}/etc"'
|
||||||
|
assert s.count(render_etc)==1, 'render target lookup moved'
|
||||||
|
s=s.replace(render_etc, 'etc="${4:-$SUDO_TEST_TARGET}/etc"')
|
||||||
p.write_text(s)
|
p.write_text(s)
|
||||||
p.chmod((source/name).stat().st_mode & 0o777)
|
p.chmod((source/name).stat().st_mode & 0o777)
|
||||||
PY
|
PY
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Runs against the private real boltd created by bolt-integration.py. No policy,
|
|
||||||
# busctl, firmware reads, or writes are mocked here.
|
|
||||||
set -euo pipefail
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
|
||||||
TB_STATE="$TB_TEST_DIR/policy.json"
|
|
||||||
TB_RUNTIME="$TB_TEST_DIR/runtime"
|
|
||||||
mkdir -p "$TB_RUNTIME"
|
|
||||||
check() { jq -e "$1" "$2" >/dev/null; }
|
|
||||||
reject() { if "$@"; then echo 'Unexpected approval success' >&2; exit 1; fi; }
|
|
||||||
case "$1" in
|
|
||||||
approve)
|
|
||||||
echo '{"version":1,"enabled":true,"trusted":{}}' | tb_json_write "$TB_STATE"
|
|
||||||
cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation"
|
|
||||||
tb_snapshot > "$TB_TEST_DIR/before.json"
|
|
||||||
capture=$(tb_capture)
|
|
||||||
tb_inventory | jq -e --arg uid "$TB_TEST_UID" --argjson capture "$capture" \
|
|
||||||
'.devices[] | select(.Uid==$uid) | $capture[$uid]=={Uid,Name,Vendor}' >/dev/null
|
|
||||||
check 'all(.devices[]; .status=="connected" and (.stored|not))' "$TB_TEST_DIR/before.json"
|
|
||||||
id=$(jq -c --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .identity' "$TB_TEST_DIR/before.json")
|
|
||||||
printf '%s\n' "$id" > "$TB_TEST_DIR/identity.json"
|
|
||||||
tb_approve "$id" true true > "$TB_TEST_DIR/after.json"
|
|
||||||
jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .trusted and .status=="authorized" and .policy=="manual"' "$TB_TEST_DIR/after.json" >/dev/null
|
|
||||||
jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="connected"' "$TB_TEST_DIR/after.json" >/dev/null
|
|
||||||
check 'all(.domains[0].BootACL[]; .=="")' "$TB_TEST_DIR/after.json"
|
|
||||||
inventory=$(tb_inventory)
|
|
||||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
|
||||||
jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.Uid==$uid) | .Stored and .Key=="have"' <<< "$inventory" >/dev/null
|
|
||||||
[[ -s $TB_TEST_KEYS/$TB_TEST_UID ]]
|
|
||||||
sha256sum "$TB_TEST_KEYS/$TB_TEST_UID" > "$TB_TEST_DIR/key-checksum"
|
|
||||||
fi
|
|
||||||
owner=$(jq -r .owner <<< "$inventory")
|
|
||||||
device=$(jq -c --arg uid "$TB_TEST_UID" '.stored[] | select(.Uid==$uid)' <<< "$inventory")
|
|
||||||
domain=$(jq -c '.domains[0]' <<< "$inventory")
|
|
||||||
device_path=$(jq -r .path <<< "$device"); domain_path=$(jq -r .path <<< "$domain")
|
|
||||||
tb_set "$owner" "$device_path" "$TB_DEVICE" Policy '"auto"'
|
|
||||||
acl=$(jq -c --arg uid "$TB_TEST_UID" '.BootACL | map("") | .[0]=$uid' <<< "$domain")
|
|
||||||
tb_set "$owner" "$domain_path" "$TB_DOMAIN" BootACL "$acl" as
|
|
||||||
tb_boot_change true
|
|
||||||
check .boot_protection "$TB_STATE"
|
|
||||||
tb_inventory > "$TB_TEST_DIR/inventory.json"
|
|
||||||
check 'all(.domains[0].BootACL[]; .=="") and .stored[0].Policy=="manual"' "$TB_TEST_DIR/inventory.json"
|
|
||||||
tb_boot_change false
|
|
||||||
check '.boot_protection==false' "$TB_STATE"
|
|
||||||
tb_inventory > "$TB_TEST_DIR/inventory.json"
|
|
||||||
jq -e --argjson acl "$acl" '.domains[0].BootACL==$acl and .stored[0].Policy=="auto"' "$TB_TEST_DIR/inventory.json" >/dev/null
|
|
||||||
jq --arg other "$TB_TEST_OTHER" 'del(.trusted[$other])' "$TB_STATE" | tb_json_write "$TB_STATE"
|
|
||||||
tb_set "$owner" "$device_path" "$TB_DEVICE" Policy '"manual"'
|
|
||||||
tb_set "$owner" "$domain_path" "$TB_DOMAIN" BootACL "$(jq -c 'map("")' <<< "$acl")" as
|
|
||||||
;;
|
|
||||||
restart)
|
|
||||||
cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation"
|
|
||||||
reject tb_approve "$(cat "$TB_TEST_DIR/identity.json")" true
|
|
||||||
tb_reconcile > "$TB_TEST_DIR/after.json"
|
|
||||||
jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .trusted and .status=="authorized"' "$TB_TEST_DIR/after.json" >/dev/null
|
|
||||||
jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="connected"' "$TB_TEST_DIR/after.json" >/dev/null
|
|
||||||
tb_inventory | jq -e '.manager.AuthMode=="disabled"' >/dev/null
|
|
||||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
|
||||||
# Bolt must challenge with the originally saved key, not provision a new
|
|
||||||
# key merely because a reconnecting device advertises the same identity.
|
|
||||||
tb_inventory | jq -e --arg uid "$TB_TEST_UID" \
|
|
||||||
'.devices[] | select(.Uid==$uid) | .Key=="have" and (.AuthFlags | contains("secure"))' >/dev/null
|
|
||||||
sha256sum --status -c "$TB_TEST_DIR/key-checksum"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
daemon)
|
|
||||||
TB_MARKER="$TB_TEST_DIR/enabled" TB_LOCK="$TB_TEST_DIR/root.lock"
|
|
||||||
touch "$TB_MARKER"
|
|
||||||
tb_daemon
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
snapshot=$(tb_public_snapshot)
|
|
||||||
jq -e --arg owner "$(tb_owner)" '.owner==$owner' <<< "$snapshot" >/dev/null
|
|
||||||
printf '%s\n' "$snapshot"
|
|
||||||
;;
|
|
||||||
once)
|
|
||||||
snapshot=$(tb_public_snapshot)
|
|
||||||
id=$(jq -c --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .identity' <<< "$snapshot")
|
|
||||||
exec 9> "$TB_TEST_DIR/root.lock"
|
|
||||||
flock -x 9
|
|
||||||
tb_approve "$id" false > "$TB_TEST_DIR/once.json"
|
|
||||||
jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="authorized" and (.trusted|not)' "$TB_TEST_DIR/once.json" >/dev/null
|
|
||||||
;;
|
|
||||||
*) exit 64 ;;
|
|
||||||
esac
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
"""Only the hardware fixture is Python: upstream Bolt uses UMockdev/GObject.
|
|
||||||
|
|
||||||
All Omarchy behavior and assertions run in bolt-exercise.sh against real boltd,
|
|
||||||
real busctl, a private system bus, and disposable sysfs/firmware/store state.
|
|
||||||
"""
|
|
||||||
import importlib.machinery
|
|
||||||
import importlib.util
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
root = Path(sys.argv.pop(1))
|
|
||||||
loader = importlib.machinery.SourceFileLoader('bolt_upstream', str(Path(os.environ['BOLT_TEST_SOURCE']) / 'tests/test-integration'))
|
|
||||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
|
||||||
u = importlib.util.module_from_spec(spec)
|
|
||||||
loader.exec_module(u)
|
|
||||||
|
|
||||||
|
|
||||||
class ApprovalIntegration(u.BoltTest):
|
|
||||||
def exercise(self, security, numeric=False):
|
|
||||||
self.user_config(AuthMode='disabled', DefaultPolicy='manual')
|
|
||||||
_, host = self.add_domain_host(security=security, iommu='1')
|
|
||||||
path, uid = self.add_device(host, 1, 'Dock', 'Example', authorized=0, key='' if security == 'secure' else None, boot='0')
|
|
||||||
_, other = self.add_device(host, 2, 'Unknown', 'Example', authorized=0, key=None, boot='0')
|
|
||||||
if numeric:
|
|
||||||
# Linux omits these files when the peripheral has no descriptive names.
|
|
||||||
for attribute in ('device_name', 'vendor_name'):
|
|
||||||
Path(self.testbed.get_root_dir(), path.lstrip('/'), attribute).unlink()
|
|
||||||
self.daemon_start()
|
|
||||||
self.polkitd_start()
|
|
||||||
self.polkitd.SetAllowed(['org.freedesktop.bolt.authorize', 'org.freedesktop.bolt.enroll', 'org.freedesktop.bolt.manage'])
|
|
||||||
env = os.environ | {'ROOT': str(root), 'UMOCKDEV_DIR': self.testbed.get_root_dir(),
|
|
||||||
'TB_TEST_DIR': self.rundir, 'TB_TEST_UID': uid, 'TB_TEST_OTHER': other,
|
|
||||||
'TB_TEST_KEYS': str(Path(self.dbpath, 'keys')), 'TB_TEST_SECURITY': security}
|
|
||||||
command = ['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-exercise.sh')]
|
|
||||||
subprocess.run(command + ['approve'], env=env, check=True)
|
|
||||||
self.daemon_stop()
|
|
||||||
self.testbed.set_attribute(path, 'authorized', '0')
|
|
||||||
if security == 'secure':
|
|
||||||
self.testbed.set_attribute(path, 'key', '\n')
|
|
||||||
self.daemon_start()
|
|
||||||
subprocess.run(command + ['restart'], env=env, check=True)
|
|
||||||
# The real Bash daemon publishes a fresh readable snapshot and reconnects
|
|
||||||
# to Bolt after a restart. No custom Omarchy D-Bus server is involved.
|
|
||||||
child = subprocess.Popen(command + ['daemon'], env=env)
|
|
||||||
try:
|
|
||||||
for _ in range(100):
|
|
||||||
result = subprocess.run(command + ['status'], env=env, capture_output=True)
|
|
||||||
if result.returncode == 0:
|
|
||||||
break
|
|
||||||
time.sleep(0.1)
|
|
||||||
else:
|
|
||||||
raise RuntimeError('Bash daemon did not publish a valid snapshot')
|
|
||||||
subprocess.run(command + ['once'], env=env, check=True)
|
|
||||||
self.daemon_stop()
|
|
||||||
self.daemon_start()
|
|
||||||
for _ in range(100):
|
|
||||||
if subprocess.run(command + ['status'], env=env, capture_output=True).returncode == 0:
|
|
||||||
break
|
|
||||||
time.sleep(0.1)
|
|
||||||
else:
|
|
||||||
raise RuntimeError('Bash daemon did not publish the new Bolt generation')
|
|
||||||
finally:
|
|
||||||
child.terminate()
|
|
||||||
child.wait(timeout=15)
|
|
||||||
self.daemon_stop()
|
|
||||||
|
|
||||||
def test_user_approval(self):
|
|
||||||
self.exercise('user')
|
|
||||||
|
|
||||||
def test_secure_approval(self):
|
|
||||||
self.exercise('secure')
|
|
||||||
|
|
||||||
def test_numeric_identity(self):
|
|
||||||
self.exercise('user', numeric=True)
|
|
||||||
|
|
||||||
def removal(self, security, boot=False, disconnected=False):
|
|
||||||
self.user_config(AuthMode='disabled')
|
|
||||||
_, host = self.add_domain_host(security=security, iommu='1')
|
|
||||||
path, uid = self.add_device(host, 1, 'Dock', 'Example', authorized=0, key='' if security == 'secure' else None, boot='0')
|
|
||||||
other_path, other = self.add_device(host, 2, 'Existing', 'Example', authorized=0, key=None, boot='0')
|
|
||||||
self.daemon_start()
|
|
||||||
self.polkitd_start()
|
|
||||||
self.polkitd.SetAllowed(['org.freedesktop.bolt.authorize', 'org.freedesktop.bolt.enroll', 'org.freedesktop.bolt.manage'])
|
|
||||||
env = os.environ | {'ROOT': str(root), 'UMOCKDEV_DIR': self.testbed.get_root_dir(),
|
|
||||||
'TB_TEST_DIR': self.rundir, 'TB_TEST_UID': uid, 'TB_TEST_OTHER': other,
|
|
||||||
'TB_TEST_STORE': self.dbpath, 'TB_TEST_SECURITY': security}
|
|
||||||
command = ['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-removal.sh')]
|
|
||||||
service = 'omarchy-thunderbolt-authorization.service'
|
|
||||||
child = None
|
|
||||||
|
|
||||||
def operation(action, unit):
|
|
||||||
nonlocal child
|
|
||||||
if unit == 'bolt.service':
|
|
||||||
if action == 'stop':
|
|
||||||
self.daemon_stop()
|
|
||||||
elif self.daemon is None:
|
|
||||||
self.daemon_start()
|
|
||||||
elif unit == service:
|
|
||||||
if action == 'stop' and child is not None:
|
|
||||||
child.terminate()
|
|
||||||
child.wait(timeout=15)
|
|
||||||
child = None
|
|
||||||
elif action == 'start' and child is None:
|
|
||||||
child = subprocess.Popen(['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-exercise.sh'), 'daemon'], env=env)
|
|
||||||
else:
|
|
||||||
raise RuntimeError('Unexpected private service: ' + unit)
|
|
||||||
marker = Path(self.rundir, unit + '.active')
|
|
||||||
if action == 'start':
|
|
||||||
marker.touch()
|
|
||||||
else:
|
|
||||||
marker.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
def run(action):
|
|
||||||
process = subprocess.Popen(command + [action], env=env)
|
|
||||||
control = Path(self.rundir, 'control')
|
|
||||||
deadline = time.monotonic() + 90
|
|
||||||
try:
|
|
||||||
while process.poll() is None:
|
|
||||||
if time.monotonic() > deadline:
|
|
||||||
raise RuntimeError('Private setup timed out: ' + action)
|
|
||||||
if control.exists():
|
|
||||||
operation(*control.read_text().strip().split())
|
|
||||||
control.unlink()
|
|
||||||
time.sleep(0.05)
|
|
||||||
if process.returncode:
|
|
||||||
raise subprocess.CalledProcessError(process.returncode, command + [action])
|
|
||||||
finally:
|
|
||||||
if process.poll() is None:
|
|
||||||
process.terminate()
|
|
||||||
process.wait(timeout=15)
|
|
||||||
|
|
||||||
def wait_for(action):
|
|
||||||
for _ in range(50):
|
|
||||||
result = subprocess.run(command + [action], env=env, capture_output=True, text=True)
|
|
||||||
if result.returncode == 0:
|
|
||||||
print(result.stdout, end='', flush=True)
|
|
||||||
return
|
|
||||||
time.sleep(0.1)
|
|
||||||
raise RuntimeError(action + ': ' + result.stdout + result.stderr)
|
|
||||||
|
|
||||||
def unplug():
|
|
||||||
for device_path in (path, other_path):
|
|
||||||
self.testbed.uevent(device_path, 'remove')
|
|
||||||
self.testbed.remove_device(device_path)
|
|
||||||
wait_for('disconnected')
|
|
||||||
|
|
||||||
try:
|
|
||||||
Path(self.rundir, 'bolt.service.active').touch()
|
|
||||||
run('approve')
|
|
||||||
operation('start', service)
|
|
||||||
Path(self.rundir, service + '.enabled').touch()
|
|
||||||
if boot:
|
|
||||||
run('boot-enable')
|
|
||||||
run('rollback')
|
|
||||||
if disconnected:
|
|
||||||
unplug()
|
|
||||||
run('disable')
|
|
||||||
if not disconnected:
|
|
||||||
unplug()
|
|
||||||
for number, device_uid, name in ((1, uid, 'Dock'), (2, other, 'Existing')):
|
|
||||||
attributes = ['device_name', name, 'device', '0x23', 'vendor_name', 'Example',
|
|
||||||
'vendor', '0x23', 'authorized', '0', 'unique_id', device_uid, 'boot', '0']
|
|
||||||
if number == 1 and security == 'secure':
|
|
||||||
attributes += ['key', '\n']
|
|
||||||
self.testbed.add_device('thunderbolt', '0-' + str(number), host, attributes, ['DEVTYPE', 'thunderbolt_device'])
|
|
||||||
wait_for('reconnected')
|
|
||||||
finally:
|
|
||||||
operation('stop', service)
|
|
||||||
self.daemon_stop()
|
|
||||||
|
|
||||||
def test_user_removal(self):
|
|
||||||
self.removal('user')
|
|
||||||
|
|
||||||
def test_secure_removal(self):
|
|
||||||
self.removal('secure', boot=True, disconnected=True)
|
|
||||||
|
|
||||||
|
|
||||||
names = sys.argv[1:] or ('test_user_approval', 'test_secure_approval', 'test_numeric_identity', 'test_user_removal', 'test_secure_removal')
|
|
||||||
suite = unittest.TestSuite(ApprovalIntegration(name) for name in names)
|
|
||||||
result = unittest.TextTestRunner(verbosity=2).run(suite)
|
|
||||||
sys.exit(not result.wasSuccessful())
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Real Bolt policy/approval/setup calls. Only systemd is bridged to the private
|
|
||||||
# daemon processes owned by bolt-integration.py; never contact host systemd.
|
|
||||||
set -euo pipefail
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
|
||||||
TB_STATE="$TB_TEST_DIR/policy.json" TB_CONFIG="$TB_TEST_STORE/boltd.conf"
|
|
||||||
TB_MARKER="$TB_TEST_DIR/enabled" TB_PENDING="$TB_TEST_DIR/pending"
|
|
||||||
TB_RUNTIME="$TB_TEST_DIR/runtime" TB_LOCK="$TB_TEST_DIR/root.lock"
|
|
||||||
mkdir -p "$TB_RUNTIME"
|
|
||||||
|
|
||||||
service_request() {
|
|
||||||
printf '%s\n' "$*" > "$TB_TEST_DIR/control.tmp"
|
|
||||||
mv "$TB_TEST_DIR/control.tmp" "$TB_TEST_DIR/control"
|
|
||||||
for (( attempt=0; attempt<300; attempt++ )); do
|
|
||||||
[[ -f $TB_TEST_DIR/control ]] || return 0
|
|
||||||
sleep 0.05
|
|
||||||
done
|
|
||||||
echo "Private service operation timed out: $*" >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
systemctl() {
|
|
||||||
local action=$1 unit
|
|
||||||
shift
|
|
||||||
printf '%s\n' "$action $*" >> "$TB_TEST_DIR/services.log"
|
|
||||||
case "$action" in
|
|
||||||
show)
|
|
||||||
unit=${!#}
|
|
||||||
if [[ -f $TB_TEST_DIR/$unit.active ]]; then echo ActiveState=active; else echo ActiveState=inactive; fi
|
|
||||||
if [[ -f $TB_TEST_DIR/$unit.enabled ]]; then echo UnitFileState=enabled; else echo UnitFileState=disabled; fi ;;
|
|
||||||
start|stop)
|
|
||||||
for unit in "$@"; do service_request "$action" "$unit" || return 1; done ;;
|
|
||||||
enable)
|
|
||||||
unit=${!#}
|
|
||||||
touch "$TB_TEST_DIR/$unit.enabled"
|
|
||||||
if [[ $1 == "--now" ]]; then service_request start "$unit"; fi ;;
|
|
||||||
disable)
|
|
||||||
if [[ -f $TB_TEST_DIR/fail-disable ]]; then rm "$TB_TEST_DIR/fail-disable"; return 1; fi
|
|
||||||
rm -f -- "$TB_TEST_DIR/$1.enabled" ;;
|
|
||||||
daemon-reload) ;;
|
|
||||||
*) echo "Unexpected systemctl action: $action $*" >&2; return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
check_inventory() { tb_inventory | jq -e --arg uid "$TB_TEST_UID" --arg other "$TB_TEST_OTHER" "$1" >/dev/null; }
|
|
||||||
case "$1" in
|
|
||||||
approve)
|
|
||||||
jq -cn '{version:1,enabled:true,trusted:{},original_authmode:"enabled"}' > "$TB_STATE"
|
|
||||||
echo removal-test > "$TB_RUNTIME/generation"
|
|
||||||
touch "$TB_MARKER"
|
|
||||||
identity=$(tb_snapshot | jq -c --arg uid "$TB_TEST_UID" '.devices[]|select(.identity.Uid==$uid)|.identity')
|
|
||||||
tb_approve "$identity" true true >/dev/null
|
|
||||||
# An existing manual record, even explicitly trusted through Omarchy,
|
|
||||||
# must keep the policy its administrator chose before enrollment.
|
|
||||||
inventory=$(tb_inventory)
|
|
||||||
owner=$(jq -r .owner <<< "$inventory")
|
|
||||||
path=$(jq -r --arg uid "$TB_TEST_OTHER" '.devices[]|select(.Uid==$uid)|.path' <<< "$inventory")
|
|
||||||
tb_bus call "$owner" "$path" "$TB_DEVICE" Authorize s '' >/dev/null
|
|
||||||
tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" EnrollDevice sss "$TB_TEST_OTHER" manual '' >/dev/null
|
|
||||||
identity=$(tb_snapshot | jq -c --arg uid "$TB_TEST_OTHER" '.devices[]|select(.identity.Uid==$uid)|.identity')
|
|
||||||
tb_approve "$identity" true true >/dev/null
|
|
||||||
jq -e --arg uid "$TB_TEST_UID" --arg other "$TB_TEST_OTHER" \
|
|
||||||
'.enrolled[$uid].StoreTime>0 and .enrolled[$other]==null and .trusted[$other]!=null' "$TB_STATE" >/dev/null
|
|
||||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
|
||||||
sha256sum "$TB_TEST_STORE/keys/$TB_TEST_UID" > "$TB_TEST_DIR/key-checksum"
|
|
||||||
fi ;;
|
|
||||||
boot-enable) tb_admin boot-enable ;;
|
|
||||||
rollback)
|
|
||||||
cp "$TB_STATE" "$TB_TEST_DIR/old-policy.json"
|
|
||||||
tb_inventory | tb_boot_saved > "$TB_TEST_DIR/old-firmware.json"
|
|
||||||
touch "$TB_TEST_DIR/fail-disable"
|
|
||||||
if tb_admin disable; then echo 'Failed removal unexpectedly succeeded' >&2; exit 1; fi
|
|
||||||
cmp "$TB_STATE" "$TB_TEST_DIR/old-policy.json"
|
|
||||||
cmp <(tb_inventory | tb_boot_saved | jq -cS .) <(jq -cS . "$TB_TEST_DIR/old-firmware.json")
|
|
||||||
[[ -f $TB_MARKER && -f $TB_TEST_DIR/$TB_SERVICE.active && -f $TB_TEST_DIR/$TB_SERVICE.enabled && ! -f $TB_TEST_DIR/setup-recovery.json ]]
|
|
||||||
check_inventory '.manager.AuthMode=="disabled"'
|
|
||||||
echo 'Removal failure restored policy, firmware ACLs and approval service' ;;
|
|
||||||
disable)
|
|
||||||
tb_admin disable
|
|
||||||
[[ ! -f $TB_MARKER && ! -f $TB_TEST_DIR/$TB_SERVICE.active && ! -f $TB_TEST_DIR/$TB_SERVICE.enabled ]]
|
|
||||||
jq -e '.enabled==false and .enrolled==null' "$TB_STATE" >/dev/null
|
|
||||||
check_inventory '.manager.AuthMode=="enabled" and any(.stored[]; .Uid==$uid and .Policy=="auto") and any(.stored[]; .Uid==$other and .Policy=="manual")'
|
|
||||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then sha256sum --status -c "$TB_TEST_DIR/key-checksum"; fi
|
|
||||||
echo 'Removal handed our record back to Bolt and preserved existing manual policy' ;;
|
|
||||||
disconnected)
|
|
||||||
check_inventory 'any(.stored[]; .Uid==$uid and .Status=="disconnected") and any(.stored[]; .Uid==$other and .Status=="disconnected")' ;;
|
|
||||||
reconnected)
|
|
||||||
check_inventory 'any(.devices[]; .Uid==$uid and .Policy=="auto" and (.Status|startswith("authorized"))) and any(.devices[]; .Uid==$other and .Policy=="manual" and .Status=="connected")'
|
|
||||||
if [[ $TB_TEST_SECURITY == "secure" ]]; then
|
|
||||||
check_inventory 'any(.devices[]; .Uid==$uid and .Key=="have" and (.AuthFlags|contains("secure")))'
|
|
||||||
sha256sum --status -c "$TB_TEST_DIR/key-checksum"
|
|
||||||
fi
|
|
||||||
echo "Automatic reconnect succeeded in $TB_TEST_SECURITY mode; pre-existing manual device stayed blocked" ;;
|
|
||||||
*) exit 64 ;;
|
|
||||||
esac
|
|
||||||
@@ -1,728 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# These behavior cases were first run as the existing Python assertions against
|
|
||||||
# the Bash implementation. Fixtures replace only Bolt, systemd and desktop I/O.
|
|
||||||
set -euo pipefail
|
|
||||||
source "$ROOT/test/shell.d/base-test.sh"
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-review.sh"
|
|
||||||
|
|
||||||
fixture() {
|
|
||||||
T=$(mktemp -d)
|
|
||||||
trap 'rm -rf -- "$T"' EXIT
|
|
||||||
export HOME="$T/home"
|
|
||||||
TB_STATE="$T/policy.json" TB_CONFIG="$T/boltd.conf" TB_MARKER="$T/enabled"
|
|
||||||
TB_PENDING="$T/pending" TB_SYSFS="$T/sysfs" TB_KEYS="$T/keys"
|
|
||||||
TB_RUNTIME="$T/runtime" TB_LOCK="$T/root.lock"
|
|
||||||
mkdir -p "$TB_RUNTIME" "$HOME" "$TB_SYSFS" "$TB_KEYS"
|
|
||||||
echo generation-a > "$TB_RUNTIME/generation"
|
|
||||||
echo '{"version":1,"enabled":true,"trusted":{},"original_authmode":"enabled"}' > "$TB_STATE"
|
|
||||||
cat > "$T/inventory" <<'JSON'
|
|
||||||
{"owner":":1.5","manager":{"AuthMode":"disabled"},"domains":[{"Uid":"host","SecurityLevel":"user","IOMMU":true,"SysfsPath":"/sys/domain","path":"/domain","BootACL":["device-1",""]}],"devices":[{"Uid":"device-1","Name":"Dock","Vendor":"Vendor","Parent":"host","SysfsPath":"/sys/devices/test/0-1","ConnectTime":1,"Status":"connected","Stored":false,"Policy":"default","AuthFlags":"","path":"/devices/1","inode":"1"}]}
|
|
||||||
JSON
|
|
||||||
: > "$T/operations"; : > "$T/units"; : > "$T/notifications"
|
|
||||||
echo '{}' > "$T/capture"
|
|
||||||
tb_requests
|
|
||||||
}
|
|
||||||
|
|
||||||
edit_inventory() { jq "$@" "$T/inventory" > "$T/next"; mv "$T/next" "$T/inventory"; }
|
|
||||||
edit_state() { jq "$@" "$TB_STATE" | tb_json_write "$TB_STATE"; }
|
|
||||||
check() { jq -e "$1" "$2" >/dev/null || fail "$1" "$(cat "$2")"; }
|
|
||||||
reject() { if ("$@") > "$T/output" 2> "$T/error"; then fail "unexpected success: $*"; fi; }
|
|
||||||
identity() { tb_snapshot | jq -c '.devices[0].identity'; }
|
|
||||||
|
|
||||||
tb_inventory() {
|
|
||||||
[[ ! -f $T/inventory-failure ]] || { tb_fail 'inventory unavailable'; return 1; }
|
|
||||||
jq '. + {stored:[.devices[] | select(.Stored)]}' "$T/inventory"
|
|
||||||
}
|
|
||||||
tb_properties() { jq -c --arg path "$2" '(.devices + .domains)[] | select(.path==$path)' "$T/inventory"; }
|
|
||||||
tb_bus() {
|
|
||||||
local command=$1 path=$3 member=$5 value
|
|
||||||
printf '%s\n' "$member" >> "$T/operations"
|
|
||||||
case "$member" in
|
|
||||||
Authorize)
|
|
||||||
[[ ! -f $T/authorize-failure ]] || return 1
|
|
||||||
if [[ ! -f $T/authorize-noop ]]; then edit_inventory --arg path "$path" '(.devices[] | select(.path==$path)).Status="authorized"'; fi ;;
|
|
||||||
EnrollDevice)
|
|
||||||
if [[ ! -f $T/enroll-noop ]]; then
|
|
||||||
edit_inventory '(.devices[0] | .Stored=true | .Policy="manual" | .StoreTime=100) as $d | .devices[0]=$d'
|
|
||||||
if [[ -f $TB_SYSFS/0-1/key && $(cat "$TB_SYSFS/0-1/key") =~ ^[0-9a-f]{64}$ && ! -f $T/key-store-failure ]]; then
|
|
||||||
edit_inventory '.devices[0].Key="have"'
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [[ -f $T/enroll-lost-reply ]]; then rm "$T/enroll-lost-reply"; return 1; fi ;;
|
|
||||||
Policy|BootACL)
|
|
||||||
if [[ $member == "BootACL" && -f $T/boot-failure ]] || [[ $member == "Policy" && ${7:-} == "auto" && -f $T/restore-failure ]]; then
|
|
||||||
tb_fail 'firmware rejected write'; return 1
|
|
||||||
fi
|
|
||||||
[[ $member != "Policy" || ! -f $T/policy-noop ]] || return 0
|
|
||||||
if [[ $member == "BootACL" ]]; then
|
|
||||||
shift 7
|
|
||||||
value=$(jq -cn '$ARGS.positional' --args "$@")
|
|
||||||
else
|
|
||||||
value=$(jq -cn --arg value "$7" '$value')
|
|
||||||
fi
|
|
||||||
edit_inventory --arg path "$path" --arg key "$member" --argjson value "$value" \
|
|
||||||
'((.devices[],.domains[]) | select(.path==$path))[$key]=$value'
|
|
||||||
if [[ $member == "Policy" ]]; then
|
|
||||||
# Real Bolt changes firmware ACLs when a stored policy changes.
|
|
||||||
edit_inventory --arg path "$path" --arg value "$7" '
|
|
||||||
(.devices[] | select(.path==$path) | .Uid) as $uid |
|
|
||||||
.domains[].BootACL |= (if $value=="auto" and (index($uid)==null) then
|
|
||||||
(index("") // 0) as $slot | .[$slot]=$uid
|
|
||||||
elif $value!="auto" then map(if .==$uid then "" else . end) else . end)'
|
|
||||||
fi ;;
|
|
||||||
*) tb_fail "Unexpected Bolt call: $*"; return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
tb_capture() { [[ ! -f $T/capture-failure ]] && cat "$T/capture"; }
|
|
||||||
tb_unit_state() { jq -cn --arg unit "$1" '{active:($unit=="bolt.service"),enabled:false}'; }
|
|
||||||
systemctl() {
|
|
||||||
if [[ $1 == --root=* ]]; then command systemctl "$@"; return; fi
|
|
||||||
printf '%s\n' "$*" >> "$T/units"
|
|
||||||
if [[ -f $T/unit-failure && $* == "$(cat "$T/unit-failure")" ]]; then
|
|
||||||
[[ -f $T/repeat-failure ]] || rm "$T/unit-failure"
|
|
||||||
tb_fail 'systemd failed'; return 1
|
|
||||||
fi
|
|
||||||
if [[ $* == "start bolt.service" && -f $TB_CONFIG ]]; then
|
|
||||||
edit_inventory --arg mode "$(tb_config_authmode "$TB_CONFIG")" '.manager.AuthMode=$mode'
|
|
||||||
if [[ ! -f $TB_MARKER && -f $T/restart-policy-failure ]]; then
|
|
||||||
rm "$T/restart-policy-failure"
|
|
||||||
edit_inventory '.devices[0].Policy="manual"'
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
tb_public_snapshot() { cat "$T/public"; }
|
|
||||||
tb_notify() {
|
|
||||||
echo "$1" >> "$T/notifications"
|
|
||||||
if [[ -f $T/notify-failure ]]; then rm "$T/notify-failure"; return 1; fi
|
|
||||||
}
|
|
||||||
tb_attention() { echo attention >> "$T/notifications"; }
|
|
||||||
gum() { if [[ $1 == "choose" ]]; then cat "$T/choice"; fi; }
|
|
||||||
tb_request_apply() {
|
|
||||||
jq --argjson identity "$2" --argjson permanent "$1" \
|
|
||||||
'(.devices[] | select(.identity==$identity)) |= (.status="authorized" | .trusted=$permanent | .stored=$permanent)' "$T/public" > "$T/next"
|
|
||||||
mv "$T/next" "$T/public"
|
|
||||||
[[ ! -f $T/lost-reply ]] || { tb_fail 'reply lost'; return 1; }
|
|
||||||
cat "$T/public"
|
|
||||||
}
|
|
||||||
request() { local paths=("$TB_REQUESTS"/request-*.json); printf '%s\n' "${paths[0]}"; }
|
|
||||||
request_count() { local paths; shopt -s nullglob; paths=("$TB_REQUESTS"/request-*.json); echo "${#paths[@]}"; }
|
|
||||||
public_fixture() { tb_snapshot > "$T/public"; }
|
|
||||||
boot_fixture() { edit_inventory '.devices[0] |= (.Stored=true | .Policy="auto")'; }
|
|
||||||
review_tick() {
|
|
||||||
local path watch
|
|
||||||
path=$(request); watch=$(jq -r .watch "$path")
|
|
||||||
tb_scan "$watch" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
test_iommu_is_not_consent() {
|
|
||||||
edit_inventory '.devices[0] |= (.Stored=true | .Policy="iommu")'
|
|
||||||
tb_reconcile > "$T/result"
|
|
||||||
[[ ! -s $T/operations ]]; check '.devices[0].trusted==false' "$T/result"
|
|
||||||
}
|
|
||||||
test_once_is_not_saved() {
|
|
||||||
tb_approve "$(identity)" false > "$T/result"
|
|
||||||
check '.warnings==[]' "$T/result"; check '.trusted=={}' "$TB_STATE"
|
|
||||||
edit_inventory '.devices[0].Status="connected"'
|
|
||||||
tb_reconcile >/dev/null
|
|
||||||
[[ $(cat "$T/operations") == "Authorize" ]]
|
|
||||||
}
|
|
||||||
test_always_survives_restart() {
|
|
||||||
tb_approve "$(identity)" true >/dev/null
|
|
||||||
edit_inventory '.owner=":1.9" | .devices[0] |= (.Status="connected" | .inode="2")'
|
|
||||||
echo generation-b > "$TB_RUNTIME/generation"
|
|
||||||
tb_reconcile > "$T/result"
|
|
||||||
check '.devices[0].status=="authorized" and .devices[0].trusted' "$T/result"
|
|
||||||
check '.manager.AuthMode=="disabled"' "$T/inventory"
|
|
||||||
}
|
|
||||||
test_enrollment_lost_reply_retains_ownership() {
|
|
||||||
touch "$T/enroll-lost-reply"
|
|
||||||
reject tb_approve "$(identity)" true
|
|
||||||
check '.trusted=={} and .enrolled["device-1"].StoreTime==null' "$TB_STATE"
|
|
||||||
tb_approve "$(identity)" true >/dev/null
|
|
||||||
check '.enrolled["device-1"].StoreTime==100 and .trusted["device-1"]!=null' "$TB_STATE"
|
|
||||||
}
|
|
||||||
secure_fixture() {
|
|
||||||
mkdir -p "$TB_SYSFS/0-1"
|
|
||||||
echo 11111111-2222-3333-4444-555555555555 > "$TB_SYSFS/0-1/unique_id"
|
|
||||||
echo 0 > "$TB_SYSFS/0-1/authorized"
|
|
||||||
: > "$TB_SYSFS/0-1/key"
|
|
||||||
edit_inventory --arg path "$TB_SYSFS/0-1" --arg inode "$(stat -Lc %i "$TB_SYSFS/0-1")" \
|
|
||||||
'.domains[0].SecurityLevel="secure" | .devices[0] |= (.Secure=true | .Key="missing" |
|
|
||||||
.Uid="11111111-2222-3333-4444-555555555555" | .SysfsPath=$path | .inode=$inode)'
|
|
||||||
}
|
|
||||||
test_secure_approval_initializes_before_trust() {
|
|
||||||
secure_fixture
|
|
||||||
tb_approve "$(identity)" true true > "$T/result"
|
|
||||||
[[ $(cat "$TB_SYSFS/0-1/key") =~ ^[0-9a-f]{64}$ ]]
|
|
||||||
check '.devices[0].trusted and .devices[0].stored' "$T/result"
|
|
||||||
cp "$TB_SYSFS/0-1/key" "$T/original-key"
|
|
||||||
tb_approve "$(identity)" true true >/dev/null
|
|
||||||
cmp "$TB_SYSFS/0-1/key" "$T/original-key"
|
|
||||||
}
|
|
||||||
test_secure_automatic_restore_cannot_initialize_keys() {
|
|
||||||
secure_fixture
|
|
||||||
jq '.devices[0] | {(.Uid):{Uid,Name,Vendor}}' "$T/inventory" > "$T/trusted"
|
|
||||||
edit_state --slurpfile trusted "$T/trusted" '.trusted=$trusted[0]'
|
|
||||||
tb_reconcile > "$T/result"
|
|
||||||
check '.devices[0].trusted==false' "$T/result"
|
|
||||||
[[ ! -s $T/operations && ! -s $TB_SYSFS/0-1/key ]]
|
|
||||||
reject tb_approve "$(identity)" true
|
|
||||||
[[ ! -s $T/operations ]]
|
|
||||||
}
|
|
||||||
test_secure_missing_saved_key_cannot_claim_success() {
|
|
||||||
secure_fixture
|
|
||||||
touch "$T/key-store-failure"
|
|
||||||
reject tb_approve "$(identity)" true true
|
|
||||||
check '.trusted=={}' "$TB_STATE"
|
|
||||||
tb_snapshot > "$T/result"; check '.devices[0].trusted==false' "$T/result"
|
|
||||||
}
|
|
||||||
test_secure_key_failure_does_not_authorize() {
|
|
||||||
secure_fixture
|
|
||||||
openssl() { return 1; }
|
|
||||||
reject tb_approve "$(identity)" true true
|
|
||||||
[[ ! -s $T/operations && ! -s $TB_SYSFS/0-1/key ]]
|
|
||||||
unset -f openssl
|
|
||||||
echo invalid > "$TB_SYSFS/0-1/key"
|
|
||||||
reject tb_approve "$(identity)" true true
|
|
||||||
[[ ! -s $T/operations ]]
|
|
||||||
}
|
|
||||||
test_authorized_keyless_device_requires_explicit_reconnect() {
|
|
||||||
secure_fixture
|
|
||||||
edit_inventory '.devices[0].Status="authorized"'
|
|
||||||
echo 1 > "$TB_SYSFS/0-1/authorized"
|
|
||||||
reject tb_approve "$(identity)" true true
|
|
||||||
rg -q 'disconnect, reconnect' "$T/error"
|
|
||||||
[[ ! -s $TB_SYSFS/0-1/key ]]; check '.trusted=={}' "$TB_STATE"
|
|
||||||
edit_inventory '.devices[0].Status="connected"'
|
|
||||||
tb_reconcile >/dev/null
|
|
||||||
[[ $(cat "$T/operations") == "EnrollDevice" ]]
|
|
||||||
}
|
|
||||||
test_stale_identity_rejected() {
|
|
||||||
local id field
|
|
||||||
id=$(identity)
|
|
||||||
for field in inode Name ConnectTime; do
|
|
||||||
cp "$T/inventory" "$T/original"
|
|
||||||
edit_inventory --arg field "$field" '.devices[0][$field]="replacement"'
|
|
||||||
reject tb_approve "$id" true
|
|
||||||
mv "$T/original" "$T/inventory"
|
|
||||||
done
|
|
||||||
edit_inventory '.owner=":1.9"'; reject tb_approve "$id" true
|
|
||||||
[[ ! -s $T/operations ]]
|
|
||||||
}
|
|
||||||
test_false_success_rejected() {
|
|
||||||
local id; id=$(identity)
|
|
||||||
touch "$T/authorize-noop"; reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE"
|
|
||||||
rm "$T/authorize-noop"; touch "$T/enroll-noop"
|
|
||||||
reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE"
|
|
||||||
rm "$T/enroll-noop"; tb_approve "$id" true > "$T/result"
|
|
||||||
check '.devices[0].trusted' "$T/result"
|
|
||||||
[[ $(rg -c '^Authorize$' "$T/operations") == 2 ]]
|
|
||||||
}
|
|
||||||
test_write_failure_retains_retry() {
|
|
||||||
local id; id=$(identity)
|
|
||||||
eval "$(declare -f tb_json_write | sed '1s/tb_json_write/disk_write/')"
|
|
||||||
tb_json_write() { [[ ! -f $T/disk-full ]] && disk_write "$@"; }
|
|
||||||
touch "$T/disk-full"; reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE"
|
|
||||||
rm "$T/disk-full"; tb_approve "$id" true > "$T/result"
|
|
||||||
check '.devices[0].trusted' "$T/result"
|
|
||||||
[[ $(rg -c '^Authorize$' "$T/operations") == 1 ]]
|
|
||||||
}
|
|
||||||
test_firmware_bypass_warns() {
|
|
||||||
edit_inventory '.domains[0].SecurityLevel="none" | .devices[0] |= (.Status="authorized" | .AuthFlags="boot")'
|
|
||||||
tb_snapshot > "$T/result"; check '.warnings|length==2' "$T/result"; [[ ! -s $T/operations ]]
|
|
||||||
}
|
|
||||||
test_global_enable_not_protected() {
|
|
||||||
edit_inventory '.manager.AuthMode="enabled"'
|
|
||||||
reject tb_snapshot; rg -q 'not active' "$T/error"
|
|
||||||
reject tb_reconcile
|
|
||||||
}
|
|
||||||
test_display_only_needs_no_approval() {
|
|
||||||
edit_state '.trusted["device-1"]={Uid:"device-1",Name:"Dock",Vendor:"Vendor"}'
|
|
||||||
edit_inventory '.devices[0].AuthFlags="nopcie"'
|
|
||||||
tb_reconcile > "$T/result"; check '.error==""' "$T/result"; [[ ! -s $T/operations ]]
|
|
||||||
}
|
|
||||||
test_empty_capture_preserved() {
|
|
||||||
rm "$TB_STATE"; tb_prepare
|
|
||||||
touch "$T/capture-failure"; tb_prepare
|
|
||||||
check '.trusted=={}' "$TB_STATE"; [[ $(tb_config_authmode "$TB_CONFIG") == "disabled" ]]
|
|
||||||
}
|
|
||||||
test_capture_failure_does_not_publish() {
|
|
||||||
rm "$TB_STATE"; touch "$T/capture-failure"; reject tb_prepare
|
|
||||||
[[ ! -e $TB_STATE && ! -e $TB_MARKER ]]
|
|
||||||
}
|
|
||||||
test_capture_optional_names_match_numeric_identity() {
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
|
||||||
mkdir -p "$TB_SYSFS/0-1"
|
|
||||||
echo device-1 > "$TB_SYSFS/0-1/unique_id"
|
|
||||||
echo 0x1234 > "$TB_SYSFS/0-1/device"
|
|
||||||
echo 0xab > "$TB_SYSFS/0-1/vendor"
|
|
||||||
tb_capture > "$T/result"
|
|
||||||
check '.["device-1"]=={Uid:"device-1",Name:"0x1234",Vendor:"0xab"}' "$T/result"
|
|
||||||
echo Dock > "$TB_SYSFS/0-1/device_name"
|
|
||||||
tb_capture > "$T/result"; check '.["device-1"].Name=="Dock" and .["device-1"].Vendor=="0xab"' "$T/result"
|
|
||||||
rm "$TB_SYSFS/0-1/device_name"; echo Vendor > "$TB_SYSFS/0-1/vendor_name"
|
|
||||||
tb_capture > "$T/result"; check '.["device-1"].Name=="0x1234" and .["device-1"].Vendor=="Vendor"' "$T/result"
|
|
||||||
mkdir "$TB_SYSFS/0-1/device_name"
|
|
||||||
reject tb_capture
|
|
||||||
}
|
|
||||||
test_first_activation_defers_without_touching_bolt() {
|
|
||||||
secure_fixture; rm "$TB_STATE"
|
|
||||||
printf '[config]\nAuthMode=enabled\n' > "$TB_CONFIG"; cp "$TB_CONFIG" "$T/original"
|
|
||||||
tb_admin owner
|
|
||||||
[[ -f $TB_PENDING && ! -f $TB_STATE && ! -f $TB_MARKER && ! -s $T/units ]]
|
|
||||||
cmp "$TB_CONFIG" "$T/original"
|
|
||||||
rm -rf "$TB_SYSFS/0-1"
|
|
||||||
tb_admin migrate
|
|
||||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
|
||||||
tb_admin enable
|
|
||||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
|
||||||
}
|
|
||||||
test_saved_secure_binding_allows_initial_capture() {
|
|
||||||
secure_fixture; rm "$TB_STATE"
|
|
||||||
edit_inventory '.devices[0] |= (.Stored=true | .Key="have")'
|
|
||||||
tb_admin owner
|
|
||||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
|
||||||
}
|
|
||||||
test_key_without_record_defers_initial_capture() {
|
|
||||||
secure_fixture; rm "$TB_STATE"
|
|
||||||
edit_inventory '.devices[0].Key="have"'
|
|
||||||
tb_admin owner
|
|
||||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
|
||||||
}
|
|
||||||
test_offline_prepare_cannot_trust_installers_bolt() {
|
|
||||||
secure_fixture; rm "$TB_STATE"
|
|
||||||
edit_inventory '.devices[0] |= (.Stored=true | .Key="have")'
|
|
||||||
tb_admin prepare
|
|
||||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
|
||||||
}
|
|
||||||
test_deferred_opt_out_retains_initial_enrollment() {
|
|
||||||
secure_fixture; rm "$TB_STATE"
|
|
||||||
tb_admin owner
|
|
||||||
tb_admin disable
|
|
||||||
[[ ! -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
|
||||||
check '.enabled==false and .enrollment_pending==true' "$TB_STATE"
|
|
||||||
tb_admin migrate
|
|
||||||
[[ ! -f $TB_PENDING && ! -s $T/units ]]
|
|
||||||
tb_admin enable
|
|
||||||
[[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]]
|
|
||||||
rm -rf "$TB_SYSFS/0-1"
|
|
||||||
echo '{"new-device":{}}' > "$T/capture"
|
|
||||||
tb_admin enable
|
|
||||||
check '.enabled and .enrollment_pending==null and .trusted=={"new-device":{}}' "$TB_STATE"
|
|
||||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
|
||||||
}
|
|
||||||
test_pending_notice_retries_without_claiming_protection() {
|
|
||||||
touch "$TB_PENDING"
|
|
||||||
local count=0 notices=0
|
|
||||||
tb_wait() { count=$((count + 1)); (( count < 3 )) || exit 99; }
|
|
||||||
tb_setup_notice() { notices=$((notices + 1)); (( notices > 1 )) && echo pending >> "$T/notifications"; }
|
|
||||||
reject tb_watch
|
|
||||||
[[ $(cat "$T/notifications") == "pending" ]]
|
|
||||||
tb_status > "$T/result"; rg -q 'protection is deferred' "$T/result"
|
|
||||||
}
|
|
||||||
test_active_policy_is_not_deferred() {
|
|
||||||
secure_fixture; touch "$TB_MARKER"
|
|
||||||
tb_admin owner
|
|
||||||
[[ ! -f $TB_PENDING && -f $TB_MARKER ]]
|
|
||||||
}
|
|
||||||
test_pending_only_factory_reset_reaches_both_roots() {
|
|
||||||
local function_name root="$T/top/@factory"
|
|
||||||
local TOP_MNT="$T/top" NEXT_NAME=@next PROVISIONING_DIR=/var/lib/omarchy/provisioning LOG_FILE="$T/reset.log" swap_done=0
|
|
||||||
mkdir -p "$root/etc/omarchy" "$root/etc/systemd/system" "$root/usr/bin" \
|
|
||||||
"$root/usr/share/omarchy/install/provisioning" "$T/top/@"
|
|
||||||
touch "$root/etc/omarchy/thunderbolt-authorization.pending" "$root/usr/bin/omarchy-provision-owner"
|
|
||||||
chmod +x "$root/usr/bin/omarchy-provision-owner"
|
|
||||||
echo 'root:x:0:0:root:/root:/bin/bash' > "$root/etc/passwd"
|
|
||||||
cp "$ROOT/install/provisioning/omarchy-provision-owner.service" \
|
|
||||||
"$ROOT/install/provisioning/omarchy-system-factory-reset-finish.service" "$root/usr/share/omarchy/install/provisioning/"
|
|
||||||
cp "$ROOT/etc/systemd/system/omarchy-thunderbolt-authorization.service" "$root/etc/systemd/system/"
|
|
||||||
for function_name in install_provisioning_units scrub_factory_accounts sanitize_factory_baseline stage_full_reset; do
|
|
||||||
eval "$(sed -n "/^$function_name() {$/,/^}/p" "$ROOT/bin/omarchy-system-factory-reset")"
|
|
||||||
done
|
|
||||||
log() { :; }
|
|
||||||
btrfs() {
|
|
||||||
if [[ $1 == "subvolume" && $2 == "snapshot" ]]; then cp -a "$3" "$4"
|
|
||||||
elif [[ $1 == "property" ]]; then :
|
|
||||||
else return 1; fi
|
|
||||||
}
|
|
||||||
usermod() { [[ $1 == "--root" && $2 == "$TOP_MNT/"* && $3 == "--password" && $4 == "!" && $5 == "root" ]]; }
|
|
||||||
systemd-id128() { echo new-machine-id; }
|
|
||||||
encrypted_install() { return 1; }
|
|
||||||
rebuild_next_boot() { :; }
|
|
||||||
sync() { :; }
|
|
||||||
systemctl() {
|
|
||||||
[[ ( $1 == "--root=$TOP_MNT/@next" || $1 == "--root=$TOP_MNT/@factory" ) && $2 == "disable" && $3 == "$TB_SERVICE" ]] || return 1
|
|
||||||
command systemctl "$@"
|
|
||||||
}
|
|
||||||
function /usr/bin/omarchy-thunderbolt-authorization-admin() {
|
|
||||||
[[ $1 == "reset-root" ]] || return 1
|
|
||||||
printf '%s\n' "$2" >> "$T/reset-roots"
|
|
||||||
(
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-setup.sh"
|
|
||||||
tb_reset_root "$2"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
stage_full_reset
|
|
||||||
[[ $(wc -l < "$T/reset-roots") == 2 && $swap_done == 1 ]]
|
|
||||||
[[ ! -f $TOP_MNT/@/etc/omarchy/thunderbolt-authorization.pending &&
|
|
||||||
! -f $TOP_MNT/@factory/etc/omarchy/thunderbolt-authorization.pending ]]
|
|
||||||
[[ -f $TOP_MNT/@/var/lib/omarchy/provisioning/pending ]]
|
|
||||||
}
|
|
||||||
test_config_preserves_settings() {
|
|
||||||
rm "$TB_STATE"; printf '[config]\nAuthMode=enabled\nDefaultPolicy=manual\n' > "$TB_CONFIG"
|
|
||||||
tb_prepare; rg -q '^DefaultPolicy=manual$' "$TB_CONFIG"
|
|
||||||
check '.original_authmode=="enabled"' "$TB_STATE"
|
|
||||||
}
|
|
||||||
test_fresh_owner_replaces_trust() {
|
|
||||||
rm "$TB_STATE"; echo '{"old":{}}' > "$T/capture"; tb_prepare
|
|
||||||
echo '{"new":{}}' > "$T/capture"; tb_prepare true
|
|
||||||
check '.trusted=={"new":{}} and .original_authmode=="enabled"' "$TB_STATE"
|
|
||||||
}
|
|
||||||
test_enable_rollback() {
|
|
||||||
rm "$TB_STATE"; printf '[config]\nAuthMode=enabled\nDefaultPolicy=manual\n' > "$TB_CONFIG"
|
|
||||||
cp "$TB_CONFIG" "$T/original"; echo 'start bolt.service' > "$T/unit-failure"
|
|
||||||
reject tb_transaction tb_enable
|
|
||||||
cmp "$TB_CONFIG" "$T/original"; [[ ! -e $TB_STATE && ! -e $TB_MARKER && ! -e $T/setup-recovery.json ]]
|
|
||||||
[[ $(tail -2 "$T/units") == $'start bolt.service\nstop omarchy-thunderbolt-authorization.service' ]]
|
|
||||||
}
|
|
||||||
test_failed_restore_keeps_checkpoint() {
|
|
||||||
rm "$TB_STATE"; echo '[config]' > "$TB_CONFIG"; cp "$TB_CONFIG" "$T/original"
|
|
||||||
echo 'stop omarchy-thunderbolt-authorization.service bolt.service' > "$T/unit-failure"; touch "$T/repeat-failure"
|
|
||||||
reject tb_transaction tb_enable; rg -q 'restoration is incomplete' "$T/error"
|
|
||||||
[[ -f $T/setup-recovery.json ]]; reject tb_transaction tb_enable; rg -q 'needs recovery' "$T/error"
|
|
||||||
echo partial > "$TB_CONFIG"; rm "$T/unit-failure"
|
|
||||||
tb_config_restore; cmp "$TB_CONFIG" "$T/original"
|
|
||||||
}
|
|
||||||
test_disable_restores_matching_boot_state() {
|
|
||||||
boot_fixture; tb_prepare; tb_boot_change true; cp "$TB_STATE" "$T/original"
|
|
||||||
echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure"
|
|
||||||
reject tb_transaction tb_disable
|
|
||||||
cmp "$TB_STATE" "$T/original"; [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
|
||||||
check '.devices[0].Policy=="manual" and .domains[0].BootACL==["",""]' "$T/inventory"
|
|
||||||
}
|
|
||||||
test_disable_hands_off_only_our_records() {
|
|
||||||
tb_prepare
|
|
||||||
tb_approve "$(identity)" true >/dev/null
|
|
||||||
edit_inventory '.devices += [(.devices[0] | .Uid="existing" | .path="/devices/2" | .Policy="manual")]'
|
|
||||||
tb_approve "$(tb_snapshot | jq -c '.devices[1].identity')" true >/dev/null
|
|
||||||
tb_admin disable
|
|
||||||
check '.enabled==false and .enrolled==null and (.trusted|length)==2' "$TB_STATE"
|
|
||||||
check '.manager.AuthMode=="enabled" and .devices[0].Policy=="auto" and .devices[1].Policy=="manual"' "$T/inventory"
|
|
||||||
[[ ! -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
|
||||||
# After a completed handoff, an administrator may choose manual themselves.
|
|
||||||
edit_inventory '.devices[0].Policy="manual"'
|
|
||||||
tb_admin enable
|
|
||||||
tb_admin disable
|
|
||||||
check 'all(.devices[]; .Policy=="manual")' "$T/inventory"
|
|
||||||
}
|
|
||||||
test_disable_hands_off_disconnected_device() {
|
|
||||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
|
||||||
edit_inventory '.devices[0].Status="disconnected"'
|
|
||||||
tb_admin disable
|
|
||||||
check '.devices[0].Policy=="auto"' "$T/inventory"
|
|
||||||
}
|
|
||||||
test_disable_preserves_recreated_manual_record() {
|
|
||||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
|
||||||
edit_inventory '.devices[0].StoreTime=200'
|
|
||||||
tb_admin disable
|
|
||||||
check '.devices[0].Policy=="manual"' "$T/inventory"
|
|
||||||
}
|
|
||||||
test_disable_preserves_original_disabled_mode() {
|
|
||||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
|
||||||
edit_state '.original_authmode="disabled"'
|
|
||||||
tb_admin disable
|
|
||||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="auto"' "$T/inventory"
|
|
||||||
}
|
|
||||||
test_disable_handoff_after_boot_protection() {
|
|
||||||
tb_prepare; tb_boot_change true
|
|
||||||
tb_approve "$(identity)" true >/dev/null
|
|
||||||
tb_admin disable
|
|
||||||
check '.enabled==false and .boot_protection==false' "$TB_STATE"
|
|
||||||
check '.devices[0].Policy=="auto" and (.domains[0].BootACL|index("device-1"))!=null' "$T/inventory"
|
|
||||||
}
|
|
||||||
test_disable_handoff_rolls_back_policies_and_acl() {
|
|
||||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
|
||||||
edit_inventory '.domains[0].BootACL=["unrelated",""]'
|
|
||||||
cp "$TB_STATE" "$T/original"
|
|
||||||
echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure"
|
|
||||||
reject tb_admin disable
|
|
||||||
cmp "$TB_STATE" "$T/original"
|
|
||||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual" and .domains[0].BootACL==["unrelated",""]' "$T/inventory"
|
|
||||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
|
||||||
}
|
|
||||||
test_disable_handoff_failure_keeps_protection() {
|
|
||||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
|
||||||
tb_unit_state() { echo '{"active":true,"enabled":true}'; }
|
|
||||||
touch "$T/restore-failure"
|
|
||||||
reject tb_admin disable
|
|
||||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
|
||||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory"
|
|
||||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
|
||||||
! rg -q '^disable ' "$T/units"
|
|
||||||
[[ $(tail -1 "$T/units") == "start omarchy-thunderbolt-authorization.service" ]]
|
|
||||||
}
|
|
||||||
test_disable_handoff_verifies_policy_writes() {
|
|
||||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
|
||||||
touch "$T/policy-noop"
|
|
||||||
reject tb_admin disable
|
|
||||||
rg -q 'did not retain the requested Policy' "$T/error"
|
|
||||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
|
||||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory"
|
|
||||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
|
||||||
}
|
|
||||||
test_disable_handoff_verifies_policy_after_restart() {
|
|
||||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
|
||||||
touch "$T/restart-policy-failure"
|
|
||||||
reject tb_admin disable
|
|
||||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
|
||||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory"
|
|
||||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
|
||||||
}
|
|
||||||
test_disable_handoff_incomplete_rollback_is_recoverable() {
|
|
||||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
|
||||||
echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure"
|
|
||||||
touch "$T/boot-failure"
|
|
||||||
reject tb_admin disable
|
|
||||||
rg -q 'restoration is incomplete' "$T/error"
|
|
||||||
[[ -f $T/setup-recovery.json ]]
|
|
||||||
rm "$T/boot-failure"
|
|
||||||
tb_admin recover
|
|
||||||
check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE"
|
|
||||||
check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual" and .domains[0].BootACL==["device-1",""]' "$T/inventory"
|
|
||||||
[[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]]
|
|
||||||
}
|
|
||||||
test_disable_offline_controller_is_unchanged() {
|
|
||||||
tb_prepare; tb_approve "$(identity)" true >/dev/null
|
|
||||||
edit_inventory '.domains[0].SysfsPath=""'
|
|
||||||
reject tb_admin disable
|
|
||||||
rg -q 'Reconnect every Thunderbolt controller' "$T/error"
|
|
||||||
check '.enabled' "$TB_STATE"
|
|
||||||
[[ ! -s $T/units && ! -f $T/setup-recovery.json ]]
|
|
||||||
}
|
|
||||||
test_boot_policy_reconnection_and_restore() {
|
|
||||||
boot_fixture; tb_boot_change true
|
|
||||||
check '.devices[0].Policy=="manual" and .domains[0].BootACL==["",""]' "$T/inventory"
|
|
||||||
check '.boot_protection and .trusted["device-1"]!=null' "$TB_STATE"
|
|
||||||
edit_inventory '.devices[0].Policy="auto" | .domains[0].BootACL=["device-1",""]'
|
|
||||||
tb_snapshot > "$T/result"; check '.warnings|length>0' "$T/result"
|
|
||||||
tb_reconcile > "$T/result"; check '.error==""' "$T/result"
|
|
||||||
check '.domains[0].BootACL==["",""]' "$T/inventory"
|
|
||||||
tb_boot_change false
|
|
||||||
check '.devices[0].Policy=="auto" and .domains[0].BootACL==["device-1",""]' "$T/inventory"
|
|
||||||
}
|
|
||||||
test_boot_change_rollback() {
|
|
||||||
boot_fixture; edit_inventory '.domains[0].BootACL=["device-1","other"]'
|
|
||||||
cp "$TB_STATE" "$T/original"; touch "$T/boot-failure"
|
|
||||||
reject tb_boot_change true
|
|
||||||
check '.devices[0].Policy=="auto" and .domains[0].BootACL==["device-1","other"]' "$T/inventory"
|
|
||||||
cmp <(jq -cS . "$TB_STATE") <(jq -cS . "$T/original"); [[ ! -e $T/boot-recovery.json ]]
|
|
||||||
}
|
|
||||||
test_boot_recovery_retained() {
|
|
||||||
boot_fixture; edit_inventory '.domains[0].BootACL=["device-1","other"]'
|
|
||||||
cp "$TB_STATE" "$T/original"; touch "$T/boot-failure" "$T/restore-failure"
|
|
||||||
reject tb_boot_change true; rg -q 'recovery is incomplete' "$T/error"; [[ -f $T/boot-recovery.json ]]
|
|
||||||
rm "$T/boot-failure" "$T/restore-failure"; tb_boot_recover
|
|
||||||
cmp <(jq -cS . "$TB_STATE") <(jq -cS . "$T/original"); [[ ! -e $T/boot-recovery.json ]]
|
|
||||||
}
|
|
||||||
test_unsupported_boot_cannot_succeed() {
|
|
||||||
boot_fixture; cp "$T/inventory" "$T/original"
|
|
||||||
local expression
|
|
||||||
for expression in '.SysfsPath=""' '.SecurityLevel="none"' '.BootACL=[]'; do
|
|
||||||
cp "$T/original" "$T/inventory"; edit_inventory ".domains[0] |= ($expression)"
|
|
||||||
reject tb_boot_change true
|
|
||||||
done
|
|
||||||
[[ ! -s $T/operations ]]
|
|
||||||
}
|
|
||||||
test_install_defers_owner() {
|
|
||||||
install() { :; }
|
|
||||||
function /usr/bin/omarchy-thunderbolt-authorization-admin() { echo "helper $*"; }
|
|
||||||
export OMARCHY_PATH=$ROOT OMARCHY_INSTALL_USER=''
|
|
||||||
source "$ROOT/install/config/thunderbolt-authorization.sh" > "$T/output"
|
|
||||||
! rg -q helper "$T/output"; [[ ! -s $T/units ]]
|
|
||||||
OMARCHY_INSTALL_USER=lab
|
|
||||||
source "$ROOT/install/config/thunderbolt-authorization.sh" > "$T/output"
|
|
||||||
rg -q 'helper prepare' "$T/output"; rg -q '^enable omarchy-thunderbolt-authorization.service$' "$T/units"
|
|
||||||
}
|
|
||||||
test_offline_reset_scrubs_private_state() {
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
|
||||||
local root="$T/clone" unit="$TB_SERVICE"
|
|
||||||
mkdir -p "$root/etc/systemd/system" "$root${TB_STATE%/*}" "$root${TB_MARKER%/*}"
|
|
||||||
cp "$ROOT/etc/systemd/system/$unit" "$root/etc/systemd/system/"
|
|
||||||
command systemctl --root="$root" enable "$unit" >/dev/null 2>&1
|
|
||||||
touch "$root$TB_MARKER" "$root$TB_STATE" "$root${TB_STATE%/*}/setup-recovery.json"
|
|
||||||
local kind
|
|
||||||
for kind in devices keys domains; do mkdir -p "$root/var/lib/boltd/$kind"; touch "$root/var/lib/boltd/$kind/old"; done
|
|
||||||
tb_reset_root "$root"
|
|
||||||
[[ ! -e $root$TB_MARKER && ! -e $root${TB_STATE%/*} && ! -e $root/etc/systemd/system/multi-user.target.wants/$unit ]]
|
|
||||||
[[ $(ls "$root/var/lib/boltd") == "boltd.conf" && $(tb_config_authmode "$root/var/lib/boltd/boltd.conf") == "enabled" ]]
|
|
||||||
}
|
|
||||||
test_live_reset_refused() { reject tb_reset_root /; }
|
|
||||||
test_trusted_reconnect_no_alert() {
|
|
||||||
edit_state '.trusted["device-1"]={Uid:"device-1",Name:"Dock",Vendor:"Vendor"}'
|
|
||||||
public_fixture; tb_scan watch1 >/dev/null; [[ ! -s $T/notifications ]]
|
|
||||||
}
|
|
||||||
test_unavailable_warns_once() {
|
|
||||||
tb_scan() { return 1; }
|
|
||||||
tb_wait() { count=$((count+1)); ((count<4)) || exit 99; }
|
|
||||||
local count=0
|
|
||||||
reject tb_watch
|
|
||||||
[[ $(cat "$T/notifications") == "attention" ]]
|
|
||||||
}
|
|
||||||
test_failed_delivery_retries_other_devices() {
|
|
||||||
edit_inventory '.devices += [(.devices[0] | .Uid="device-2")]'; public_fixture; touch "$T/notify-failure"
|
|
||||||
tb_scan watch1 >/dev/null; [[ $(request_count) == 1 ]]
|
|
||||||
tb_scan watch1 >/dev/null; [[ $(request_count) == 2 && $(wc -l < "$T/notifications") == 3 ]]
|
|
||||||
}
|
|
||||||
test_restarts_restore_requests() {
|
|
||||||
public_fixture; tb_scan watch1 >/dev/null; local old; old=$(request)
|
|
||||||
tb_scan watch1 >/dev/null; [[ $(wc -l < "$T/notifications") == 1 ]]
|
|
||||||
edit_inventory '.owner=":1.9"'; public_fixture
|
|
||||||
tb_scan watch1 >/dev/null; [[ $(request) != "$old" ]]
|
|
||||||
tb_scan watch2 >/dev/null; [[ $(request_count) == 1 && $(wc -l < "$T/notifications") == 3 ]]
|
|
||||||
}
|
|
||||||
test_inventory_failure_preserves_request() {
|
|
||||||
public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request)
|
|
||||||
rm "$T/public"; reject tb_scan watch1; [[ -f $path ]]
|
|
||||||
}
|
|
||||||
test_replacement_during_dialog_rejected() {
|
|
||||||
public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request)
|
|
||||||
gum() {
|
|
||||||
if [[ $1 == "choose" ]]; then
|
|
||||||
jq '.devices[0].identity.Uid="replacement"' "$T/public" > "$T/next"; mv "$T/next" "$T/public"
|
|
||||||
echo 'Always allow this device'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
reject tb_review "${path##*/}" # A filename is not a valid token.
|
|
||||||
reject tb_review "$(basename "$path" .json)"
|
|
||||||
check '.devices[0].status=="connected"' "$T/public"
|
|
||||||
}
|
|
||||||
test_lost_reply_keeps_approval_intent() {
|
|
||||||
public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request)
|
|
||||||
echo 'Always allow this device' > "$T/choice"; touch "$T/lost-reply"
|
|
||||||
sleep() { review_tick; }
|
|
||||||
reject tb_review "$(basename "$path" .json)"
|
|
||||||
check '.approval=="Always allow this device"' "$path"
|
|
||||||
tb_scan watch1 >/dev/null; [[ -f $path && $(wc -l < "$T/notifications") == 2 ]]
|
|
||||||
tb_scan watch2 >/dev/null; [[ ! -f $path && $(wc -l < "$T/notifications") == 3 ]]; path=$(request)
|
|
||||||
rm "$T/lost-reply"; gum() { fail 'Saved approval must not ask again'; }
|
|
||||||
tb_review "$(basename "$path" .json)" >/dev/null
|
|
||||||
[[ ! -f $path ]]
|
|
||||||
}
|
|
||||||
test_disconnect_and_untrusted_text() {
|
|
||||||
edit_inventory '.devices[0].Name="$(touch /tmp/untrusted)\u001b[2J"'
|
|
||||||
public_fixture; tb_scan watch1 >/dev/null
|
|
||||||
[[ $(basename "$(request)") =~ ^request-[0-9a-f]{64}.json$ ]]
|
|
||||||
[[ $(jq '.devices[0].Name' "$T/inventory" | tb_safe_text) != *$'\e'* ]]
|
|
||||||
edit_inventory '.devices=[]'; public_fixture; tb_scan watch1 >/dev/null; [[ $(request_count) == 0 ]]
|
|
||||||
}
|
|
||||||
test_snapshot_freshness_and_disabled_state() {
|
|
||||||
# Exercise the real public reader, including freshness after daemon failure.
|
|
||||||
source "$ROOT/install/helpers/thunderbolt-policy.sh"
|
|
||||||
TB_RUNTIME="$T/runtime"
|
|
||||||
jq -cn --argjson now "$(date +%s)" '{available:true,time:$now,generation:"generation-a"}' > "$TB_RUNTIME/snapshot.json"
|
|
||||||
tb_public_snapshot >/dev/null
|
|
||||||
jq '.time-=30' "$TB_RUNTIME/snapshot.json" > "$T/next"; mv "$T/next" "$TB_RUNTIME/snapshot.json"
|
|
||||||
reject tb_public_snapshot
|
|
||||||
echo generation-b > "$TB_RUNTIME/generation"; reject tb_public_snapshot
|
|
||||||
}
|
|
||||||
test_wait_times_out_without_input() {
|
|
||||||
local start=$EPOCHSECONDS
|
|
||||||
TB_TICK='' tb_wait 1
|
|
||||||
(( EPOCHSECONDS - start >= 1 && EPOCHSECONDS - start < 10 ))
|
|
||||||
}
|
|
||||||
test_daemon_rechecks_controller_free_bolt_less_often() {
|
|
||||||
touch "$TB_MARKER"; TB_IDLE_RECHECK=600
|
|
||||||
local tick=0 extra='{}'
|
|
||||||
expect() { [[ $(wc -l < "$T/reconciles") == "$1" ]] || fail "tick $tick: expected $1 reconciles" "$(wc -l < "$T/reconciles")"; }
|
|
||||||
tb_reconcile() {
|
|
||||||
echo >> "$T/reconciles"
|
|
||||||
jq -cn --argjson time "$EPOCHSECONDS" --argjson extra "$extra" \
|
|
||||||
'{available:true,time:$time,generation:"g",domains:[],devices:[],warnings:[],error:"",boot_protection:false} + $extra'
|
|
||||||
}
|
|
||||||
tb_wait() {
|
|
||||||
tick=$((tick + 1))
|
|
||||||
case $tick in
|
|
||||||
1) expect 1 ;;
|
|
||||||
2) expect 1; mkdir "$TB_SYSFS/domain0" ;; # idle: no reconcile
|
|
||||||
3) expect 2; rmdir "$TB_SYSFS/domain0" ;; # a controller appeared
|
|
||||||
4) expect 2; edit_state '.trusted.x={}' ;;
|
|
||||||
5) expect 3; touch "${TB_STATE%/*}/boot-recovery.json" ;; # the policy changed
|
|
||||||
6) expect 4; rm "${TB_STATE%/*}/boot-recovery.json" # a recovery checkpoint
|
|
||||||
extra='{"devices":[{"Uid":"device-1"}]}'; edit_state '.trusted.y={}' ;;
|
|
||||||
7) expect 5 ;; # devices from here on
|
|
||||||
8) expect 6; rm "$TB_MARKER" ;; # so every tick reconciles
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
tb_daemon
|
|
||||||
[[ $tick == 8 ]]
|
|
||||||
}
|
|
||||||
test_daemon_rechecks_after_the_idle_interval() {
|
|
||||||
touch "$TB_MARKER"; TB_IDLE_RECHECK=2
|
|
||||||
local tick=0
|
|
||||||
tb_reconcile() {
|
|
||||||
echo >> "$T/reconciles"
|
|
||||||
jq -cn --argjson time "$EPOCHSECONDS" '{available:true,time:$time,generation:"g",domains:[],devices:[],warnings:[],error:"",boot_protection:false}'
|
|
||||||
}
|
|
||||||
tb_wait() {
|
|
||||||
tick=$((tick + 1))
|
|
||||||
case $tick in
|
|
||||||
1) sleep 2.1 ;;
|
|
||||||
2) [[ $(wc -l < "$T/reconciles") == 2 ]] || fail "the idle check did not expire"; rm "$TB_MARKER" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
tb_daemon
|
|
||||||
}
|
|
||||||
test_daemon_failed_reconcile_keeps_every_tick() {
|
|
||||||
touch "$TB_MARKER"; TB_IDLE_RECHECK=600
|
|
||||||
local tick=0
|
|
||||||
tb_reconcile() { echo >> "$T/reconciles"; tb_fail 'Bolt unavailable'; }
|
|
||||||
tb_wait() { tick=$((tick + 1)); (( tick < 3 )) || rm "$TB_MARKER"; }
|
|
||||||
tb_daemon 2>/dev/null
|
|
||||||
[[ $(wc -l < "$T/reconciles") == 3 ]]
|
|
||||||
check '.available==false' "$TB_RUNTIME/snapshot.json"
|
|
||||||
}
|
|
||||||
test_watch_skips_only_unchanged_clean_snapshots() {
|
|
||||||
local tick=0 start=$EPOCHSECONDS
|
|
||||||
publish() { jq -cnS --argjson time "$1" --arg generation generation-a --argjson devices "${2:-[]}" \
|
|
||||||
'{available:true,time:$time,generation:$generation,devices:$devices,warnings:[],error:""}' > "$TB_RUNTIME/snapshot.json"; }
|
|
||||||
expect() { [[ $(wc -l < "$T/scans") == "$1" ]] || fail "tick $tick: expected $1 scans" "$(wc -l < "$T/scans")"; }
|
|
||||||
tb_scan() { echo >> "$T/scans"; cat "$TB_RUNTIME/snapshot.json"; }
|
|
||||||
publish "$start"
|
|
||||||
tb_wait() {
|
|
||||||
tick=$((tick + 1))
|
|
||||||
case $tick in
|
|
||||||
1) expect 1 ;;
|
|
||||||
2) expect 1; touch "$TB_REQUESTS/request-x.json" ;; # unchanged: skipped
|
|
||||||
3) expect 2; rm "$TB_REQUESTS/request-x.json" ;; # a request in flight
|
|
||||||
4) expect 2; publish "$((start - 5))" ;;
|
|
||||||
5) expect 3; echo generation-b > "$TB_RUNTIME/generation" ;; # a new snapshot
|
|
||||||
6) expect 4; echo generation-a > "$TB_RUNTIME/generation" # another daemon
|
|
||||||
publish "$start" '[{"identity":{}}]' ;;
|
|
||||||
7) expect 5 ;; # devices from here on
|
|
||||||
8) expect 6; exit 0 ;; # so every tick scans
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
(tb_watch)
|
|
||||||
}
|
|
||||||
test_watch_rescans_a_stale_snapshot() {
|
|
||||||
local tick=0
|
|
||||||
jq -cnS --argjson time "$((EPOCHSECONDS - 30))" \
|
|
||||||
'{available:true,time:$time,generation:"generation-a",devices:[],warnings:[],error:""}' > "$TB_RUNTIME/snapshot.json"
|
|
||||||
tb_scan() { echo >> "$T/scans"; cat "$TB_RUNTIME/snapshot.json"; }
|
|
||||||
tb_wait() { tick=$((tick + 1)); (( tick < 3 )) || exit 0; }
|
|
||||||
(tb_watch)
|
|
||||||
[[ $(wc -l < "$T/scans") == 3 ]]
|
|
||||||
}
|
|
||||||
test_invalid_state_not_skipped_by_migration() {
|
|
||||||
echo '{"enabled":false}' > "$TB_STATE"; reject tb_admin migrate
|
|
||||||
[[ ! -s $T/units ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
for case in $(declare -F | awk '$3 ~ /^test_/ {print $3}'); do
|
|
||||||
(fixture; "$case")
|
|
||||||
pass "Thunderbolt ${case#test_}"
|
|
||||||
done
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
source "$ROOT/test/shell.d/base-test.sh"
|
|
||||||
fixture=$(mktemp -d)
|
|
||||||
trap 'rm -rf -- "$fixture"' EXIT
|
|
||||||
for name in admin approve daemon; do
|
|
||||||
# Map the security library and stop before service work, including when the
|
|
||||||
# suite itself runs as root. Startup checks remain the production code.
|
|
||||||
sed -e "s|source /usr/bin/omarchy-security-functions|source $ROOT/bin/omarchy-security-functions|" \
|
|
||||||
-e 's|source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh|exit 126|' \
|
|
||||||
"$ROOT/bin/omarchy-thunderbolt-authorization-$name" > "$fixture/entry"
|
|
||||||
chmod 755 "$fixture/entry"
|
|
||||||
printf 'set -p\n' > "$fixture/decoy"
|
|
||||||
if BASH_ENV="$fixture/decoy" /usr/bin/bash "$fixture/entry" -p; then
|
|
||||||
fail "$name accepts ordinary Bash with a decoy -p"
|
|
||||||
fi
|
|
||||||
printf 'touch "%s"\n' "$fixture/injected" > "$fixture/startup"
|
|
||||||
# The entrypoint must suppress startup files and exported functions before
|
|
||||||
# checking EUID or reaching the fixture's service-work boundary.
|
|
||||||
if BASH_ENV="$fixture/startup" "$fixture/entry" >/dev/null 2>&1; then
|
|
||||||
fail "$name passed the fixture service-work boundary"
|
|
||||||
fi
|
|
||||||
[[ ! -e $fixture/injected ]] || fail "$name executed BASH_ENV"
|
|
||||||
if /usr/bin/env 'BASH_FUNC_source%%=() { echo injected > "$TB_TEST_INJECTED"; }' \
|
|
||||||
TB_TEST_INJECTED="$fixture/injected" "$fixture/entry" >/dev/null 2>&1; then
|
|
||||||
fail "$name passed the fixture boundary with an exported function"
|
|
||||||
fi
|
|
||||||
[[ ! -e $fixture/injected ]] || fail "$name imported an exported function"
|
|
||||||
pass "Thunderbolt $name rejects unsafe Bash startup"
|
|
||||||
done
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
#include <usbguard/Rule.hpp>
|
|
||||||
#include <fstream>
|
|
||||||
#include <iostream>
|
|
||||||
#include <stdexcept>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
static std::string read(const char* path) {
|
|
||||||
std::ifstream file(path);
|
|
||||||
std::string text;
|
|
||||||
std::getline(file, text);
|
|
||||||
if (text.empty()) throw std::runtime_error("empty rule");
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string replace(std::string text, const std::string& from, const std::string& to) {
|
|
||||||
const auto index = text.find(from);
|
|
||||||
if (index == std::string::npos) throw std::runtime_error("missing attribute");
|
|
||||||
text.replace(index, from.size(), to);
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void check(const char* name, const usbguard::Rule& policy, const usbguard::Rule& device, bool expected) {
|
|
||||||
if (policy.appliesTo(device) != expected) throw std::runtime_error(name);
|
|
||||||
std::cout << "ok - native USBGuard: " << name << '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
|
||||||
if (argc != 3) return 2;
|
|
||||||
// Test the policy produced by the real Bash implementation, not a C++ copy.
|
|
||||||
const auto policy = usbguard::Rule::fromString(read(argv[1]));
|
|
||||||
const auto text = read(argv[2]);
|
|
||||||
const auto device = usbguard::Rule::fromString(text);
|
|
||||||
check("original connection", policy, device, true);
|
|
||||||
auto moved = device;
|
|
||||||
moved.setViaPort("9-9.9");
|
|
||||||
check("another port", policy, moved, true);
|
|
||||||
moved.setParentHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
|
||||||
check("another hub", policy, moved, true);
|
|
||||||
auto reloaded = usbguard::Rule::fromString(policy.toString());
|
|
||||||
check("serialized policy reload on new hub", reloaded, moved, true);
|
|
||||||
check("reconnect on original port", reloaded, device, true);
|
|
||||||
auto different = moved;
|
|
||||||
different.setHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");
|
|
||||||
check("different hash denied", policy, different, false);
|
|
||||||
different = moved;
|
|
||||||
different.setSerial("different");
|
|
||||||
check("different serial denied", policy, different, false);
|
|
||||||
different = moved;
|
|
||||||
different.setName("different");
|
|
||||||
check("different name denied", policy, different, false);
|
|
||||||
check("different product denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046d:c53b")), false);
|
|
||||||
check("different vendor denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046e:c53a")), false);
|
|
||||||
check("different interface denied", policy, usbguard::Rule::fromString(replace(text, "03:00:00", "08:06:50")), false);
|
|
||||||
check("different connection type denied", policy, usbguard::Rule::fromString(replace(text, "with-connect-type \"unknown\"", "with-connect-type \"hotplug\"")), false);
|
|
||||||
// Existing unmarked/manual rules remain location-bound.
|
|
||||||
check("legacy rule still restricts ports and hubs", device, moved, false);
|
|
||||||
}
|
|
||||||
Executable
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||||
|
|
||||||
|
if ! command -v fc-match >/dev/null || [[ $(fc-list : file) != *NotoSansCJK-Regular.ttc* ]]; then
|
||||||
|
skip "fontconfig CJK variants need fc-match and noto-fonts-cjk"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
test_tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$test_tmp"' EXIT
|
||||||
|
|
||||||
|
candidate="$ROOT/default/fontconfig/conf.avail/50-omarchy.conf"
|
||||||
|
{
|
||||||
|
echo '<?xml version="1.0"?><!DOCTYPE fontconfig SYSTEM "fonts.dtd"><fontconfig>'
|
||||||
|
echo "<dir>/usr/share/fonts</dir><cachedir>$test_tmp/cache</cachedir>"
|
||||||
|
placed=false
|
||||||
|
for conf in /etc/fonts/conf.d/*.conf; do
|
||||||
|
name=${conf##*/}
|
||||||
|
[[ $name == "50-omarchy.conf" ]] && continue
|
||||||
|
if ! $placed && [[ $name > "50-omarchy.conf" ]]; then
|
||||||
|
echo "<include>$candidate</include>"
|
||||||
|
placed=true
|
||||||
|
fi
|
||||||
|
echo "<include>$conf</include>"
|
||||||
|
done
|
||||||
|
$placed || echo "<include>$candidate</include>"
|
||||||
|
echo '</fontconfig>'
|
||||||
|
} >"$test_tmp/fonts.conf"
|
||||||
|
export FONTCONFIG_FILE="$test_tmp/fonts.conf"
|
||||||
|
fc-cache >/dev/null
|
||||||
|
|
||||||
|
# Pango, Qt and Skia add lang as a string, which fc-match never does.
|
||||||
|
cat >"$test_tmp/string-lang.py" <<'PY'
|
||||||
|
import ctypes, sys
|
||||||
|
fc = ctypes.CDLL("libfontconfig.so.1")
|
||||||
|
fc.FcPatternCreate.restype = ctypes.c_void_p
|
||||||
|
fc.FcPatternAddString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p]
|
||||||
|
fc.FcConfigSubstitute.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int]
|
||||||
|
fc.FcDefaultSubstitute.argtypes = [ctypes.c_void_p]
|
||||||
|
fc.FcPatternFormat.restype = ctypes.c_char_p
|
||||||
|
fc.FcPatternFormat.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
|
||||||
|
fc.FcCharSetCreate.restype = ctypes.c_void_p
|
||||||
|
fc.FcCharSetAddChar.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
||||||
|
fc.FcPatternAddCharSet.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p]
|
||||||
|
fc.FcFontMatch.restype = ctypes.c_void_p
|
||||||
|
fc.FcFontMatch.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)]
|
||||||
|
for line in sys.stdin:
|
||||||
|
family, lang, char = line.split()
|
||||||
|
pattern = fc.FcPatternCreate()
|
||||||
|
fc.FcPatternAddString(pattern, b"family", family.encode())
|
||||||
|
if lang != "-":
|
||||||
|
fc.FcPatternAddString(pattern, b"lang", lang.encode())
|
||||||
|
if char == "families":
|
||||||
|
fc.FcConfigSubstitute(None, pattern, 0)
|
||||||
|
print(fc.FcPatternFormat(pattern, b"%{family}").decode())
|
||||||
|
continue
|
||||||
|
if char != "-":
|
||||||
|
charset = fc.FcCharSetCreate()
|
||||||
|
fc.FcCharSetAddChar(charset, int(char, 16))
|
||||||
|
fc.FcPatternAddCharSet(pattern, b"charset", charset)
|
||||||
|
fc.FcConfigSubstitute(None, pattern, 0)
|
||||||
|
fc.FcDefaultSubstitute(pattern)
|
||||||
|
result = ctypes.c_int()
|
||||||
|
print(fc.FcPatternFormat(fc.FcFontMatch(None, pattern, ctypes.byref(result)), b"%{family[0]}").decode())
|
||||||
|
PY
|
||||||
|
|
||||||
|
# resolve <locale> <string|langset> reads "family lang char" lines
|
||||||
|
resolve() {
|
||||||
|
if [[ $2 == "string" ]]; then
|
||||||
|
LANG=$1 python3 "$test_tmp/string-lang.py"
|
||||||
|
else
|
||||||
|
local family lang char query
|
||||||
|
while read -r family lang char; do
|
||||||
|
query=$family
|
||||||
|
[[ $lang == "-" ]] || query+=":lang=$lang"
|
||||||
|
[[ $char == "-" ]] || query+=":charset=$char"
|
||||||
|
LANG=$1 fc-match -f '%{family[0]}\n' "$query"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
declare -A latin=([sans-serif]="Liberation Sans" [serif]="Liberation Serif" [monospace]="JetBrainsMono Nerd Font")
|
||||||
|
declare -A cjk=([sans-serif]="Noto Sans CJK" [serif]="Noto Serif CJK" [monospace]="Noto Sans Mono CJK")
|
||||||
|
declare -A variant=(
|
||||||
|
[zh]=SC [zh-cn]=SC [zh-sg]=SC [zh-hans]=SC [zh-hans-cn]=SC [zh-hans-hk]=SC
|
||||||
|
[zh-tw]=TC [zh-hant]=TC [zh-hant-tw]=TC
|
||||||
|
[zh-hk]=HK [zh-mo]=HK [zh-hant-hk]=HK [zh-hant-mo]=HK
|
||||||
|
[yue]=HK [yue-hk]=HK [yue-hant]=HK [yue-hant-hk]=HK [yue-hant-cn]=HK
|
||||||
|
[yue-hans]=SC [yue-hans-cn]=SC [yue-hans-hk]=SC [yue-cn]=SC
|
||||||
|
[ja]=JP [ja-jp]=JP [ko]=KR [ko-kr]=KR
|
||||||
|
)
|
||||||
|
|
||||||
|
queries=() expected=()
|
||||||
|
for tag in "${!variant[@]}"; do
|
||||||
|
for generic in sans-serif serif monospace; do
|
||||||
|
queries+=("$generic $tag -") expected+=("${latin[$generic]}")
|
||||||
|
queries+=("$generic $tag 9aa8") expected+=("${cjk[$generic]} ${variant[$tag]}")
|
||||||
|
done
|
||||||
|
done
|
||||||
|
for generic in sans-serif serif monospace; do
|
||||||
|
queries+=("$generic - -") expected+=("${latin[$generic]}")
|
||||||
|
done
|
||||||
|
|
||||||
|
for locale in C.UTF-8 zh_CN.UTF-8; do
|
||||||
|
for path in string langset; do
|
||||||
|
mapfile -t actual < <(printf '%s\n' "${queries[@]}" | resolve "$locale" "$path")
|
||||||
|
for i in "${!queries[@]}"; do
|
||||||
|
[[ ${actual[i]} == "${expected[i]}" ]] ||
|
||||||
|
fail "$locale $path lang: ${queries[i]} resolves to ${expected[i]}" "${actual[i]}"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
pass "$locale: tagged CJK keeps Omarchy's Latin families and sets Han in the tag's variant"
|
||||||
|
done
|
||||||
|
|
||||||
|
for path in string langset; do
|
||||||
|
for generic in sans-serif serif monospace; do
|
||||||
|
actual=$(echo "$generic - 9aa8" | resolve zh_CN.UTF-8 "$path")
|
||||||
|
[[ $actual == "${cjk[$generic]} SC" ]] || fail "zh_CN $path: untagged Han in $generic is SC" "$actual"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
pass "a Chinese locale sets untagged Han in SC"
|
||||||
|
|
||||||
|
untouched=$(echo "sans-serif en families" | resolve C.UTF-8 string)
|
||||||
|
[[ $(echo "sans-serif ja families" | resolve C.UTF-8 string) != "$untouched" ]] ||
|
||||||
|
fail "lang=ja changes the family list, so the prefix check below can see a match"
|
||||||
|
for tag in jam jav kok koi; do
|
||||||
|
actual=$(echo "sans-serif $tag families" | resolve C.UTF-8 string)
|
||||||
|
[[ $actual == "$untouched" ]] || fail "string lang=$tag is not taken for Japanese or Korean" "$actual"
|
||||||
|
done
|
||||||
|
pass "languages that only share a prefix with ja or ko get no CJK variant"
|
||||||
@@ -93,7 +93,7 @@ sed -n '/^rekey_luks() {/,/^}/p; /^run_provisioning() {/,/^}/p; /^cleanup_oem_st
|
|||||||
/^platform_ready() {/,/^}/p; /^run_setup() {/,/^}/p
|
/^platform_ready() {/,/^}/p; /^run_setup() {/,/^}/p
|
||||||
/^rekey_accepts_password() {/,/^}/p' \
|
/^rekey_accepts_password() {/,/^}/p' \
|
||||||
"$ROOT/bin/omarchy-provision-owner" |
|
"$ROOT/bin/omarchy-provision-owner" |
|
||||||
sed -e "s|/etc/|$tmp/etc/|g" -e "s|/usr/bin/omarchy-thunderbolt-authorization-admin|omarchy-thunderbolt-authorization-admin|" >"$tmp/provision.sh"
|
sed "s|/etc/|$tmp/etc/|g" >"$tmp/provision.sh"
|
||||||
grep -q '^run_provisioning() {' "$tmp/provision.sh" && grep -q '^run_setup() {' "$tmp/provision.sh" ||
|
grep -q '^run_provisioning() {' "$tmp/provision.sh" && grep -q '^run_setup() {' "$tmp/provision.sh" ||
|
||||||
fail "omarchy-provision-owner defines its setup and provisioning worker"
|
fail "omarchy-provision-owner defines its setup and provisioning worker"
|
||||||
|
|
||||||
@@ -241,11 +241,6 @@ setup_functions() {
|
|||||||
configure_hostname() { :; }
|
configure_hostname() { :; }
|
||||||
configure_timezone() { :; }
|
configure_timezone() { :; }
|
||||||
finalize_user() { :; }
|
finalize_user() { :; }
|
||||||
# Accessory enrollment, recorded with whether the staged unlock was gone.
|
|
||||||
omarchy-pkg-present() { return 1; }
|
|
||||||
omarchy-thunderbolt-authorization-admin() {
|
|
||||||
if luks_staged_unlock_remains; then echo "$* early" >>"$TMP/accessories"; else echo "$*" >>"$TMP/accessories"; fi
|
|
||||||
}
|
|
||||||
limine_entries_stale() {
|
limine_entries_stale() {
|
||||||
crash_point "re-key returned"
|
crash_point "re-key returned"
|
||||||
[[ -e $TMP/stale ]]
|
[[ -e $TMP/stale ]]
|
||||||
@@ -335,7 +330,7 @@ slot_count() {
|
|||||||
fixture() {
|
fixture() {
|
||||||
local format=${1:-luks2}
|
local format=${1:-luks2}
|
||||||
rm -rf "$tmp/provisioning" "$tmp/etc" "$tmp/boot" "$tmp/log" "$tmp/output" "$tmp/trace" "$tmp/rebuilds" "$tmp/adds" "$tmp/rebuild-fail" "$tmp/kill-noop" \
|
rm -rf "$tmp/provisioning" "$tmp/etc" "$tmp/boot" "$tmp/log" "$tmp/output" "$tmp/trace" "$tmp/rebuilds" "$tmp/adds" "$tmp/rebuild-fail" "$tmp/kill-noop" \
|
||||||
"$tmp/prepare-fail" "$tmp/screen" "$tmp/stale" "$tmp/token-slot" "$tmp/slot-record" "$tmp/record-fail" "$tmp/accessories"
|
"$tmp/prepare-fail" "$tmp/screen" "$tmp/stale" "$tmp/token-slot" "$tmp/slot-record" "$tmp/record-fail"
|
||||||
mkdir -p "$tmp/provisioning"
|
mkdir -p "$tmp/provisioning"
|
||||||
chmod 755 "$tmp/provisioning"
|
chmod 755 "$tmp/provisioning"
|
||||||
touch "$tmp/provisioning/pending"
|
touch "$tmp/provisioning/pending"
|
||||||
@@ -474,8 +469,6 @@ for run_spec in "${matrix[@]}"; do
|
|||||||
fixture "$format"
|
fixture "$format"
|
||||||
run attempt "$owner_password" || fail "$backend: uninterrupted setup completes" "$(cat "$tmp/log" "$tmp/output")"
|
run attempt "$owner_password" || fail "$backend: uninterrupted setup completes" "$(cat "$tmp/log" "$tmp/output")"
|
||||||
assert_provisioned "uninterrupted" "$owner_password"
|
assert_provisioned "uninterrupted" "$owner_password"
|
||||||
[[ $(cat "$tmp/accessories" 2>/dev/null) == "owner" ]] ||
|
|
||||||
fail "$backend: accessories are enrolled once, after the staged unlock is gone" "$(cat "$tmp/accessories" 2>/dev/null)"
|
|
||||||
total_steps=$(cat "$tmp/steps")
|
total_steps=$(cat "$tmp/steps")
|
||||||
(( total_steps >= 11 )) || fail "$backend: every durable step is a crash point" "$(cat "$tmp/trace")"
|
(( total_steps >= 11 )) || fail "$backend: every durable step is a crash point" "$(cat "$tmp/trace")"
|
||||||
if [[ $platform == "apple" ]]; then
|
if [[ $platform == "apple" ]]; then
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ for (const item of defaults) {
|
|||||||
if (item.id !== 'system.lock' && zh && zh.declared.includes('label') && result.label !== zh.label) fail(`${item.id} shows its Chinese label`)
|
if (item.id !== 'system.lock' && zh && zh.declared.includes('label') && result.label !== zh.label) fail(`${item.id} shows its Chinese label`)
|
||||||
}
|
}
|
||||||
if (merged['system.lock'].label !== 'Mine') fail('a user extension still overrides the Chinese label')
|
if (merged['system.lock'].label !== 'Mine') fail('a user extension still overrides the Chinese label')
|
||||||
if (merged['system.reboot'].label !== '重启' || merged['setup.language'].label !== '语言') fail('Chinese labels reach the merged menu')
|
if (merged['system.reboot'].label !== '重启' || merged['setup.language'].label !== '语言' || merged['install.china.wechat'].label !== '微信') fail('Chinese labels reach the merged menu')
|
||||||
console.log('ok - Chinese labels replace only the label, and user extensions still win')
|
console.log('ok - Chinese labels replace only the label, and user extensions still win')
|
||||||
JS
|
JS
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,14 @@ assert(
|
|||||||
&& defaultById['setup.language.zh'].action.includes('omarchy-language zh'),
|
&& defaultById['setup.language.zh'].action.includes('omarchy-language zh'),
|
||||||
'menu offers English and Simplified Chinese under Setup > Language'
|
'menu offers English and Simplified Chinese under Setup > Language'
|
||||||
)
|
)
|
||||||
|
const regionEntries = defaultItems.filter(item => item.parent === 'setup.region')
|
||||||
|
assert(
|
||||||
|
regionEntries.map(item => item.label).join('\0') === 'Global\0China'
|
||||||
|
&& regionEntries.every(item => item.checked.includes(`== \"${item.label}\"`) && !item.when)
|
||||||
|
&& defaultById['setup.region.global'].action.includes('omarchy-region global')
|
||||||
|
&& defaultById['setup.region.china'].action.includes('omarchy-region cn'),
|
||||||
|
'menu offers Global and China under Setup > Region'
|
||||||
|
)
|
||||||
// Software you already have keeps its place in Install, dimmed rather than
|
// Software you already have keeps its place in Install, dimmed rather than
|
||||||
// dropped, so the list reads as a catalog of what Omarchy can install.
|
// dropped, so the list reads as a catalog of what Omarchy can install.
|
||||||
// Chromium Account is the sole Install row with anything left to hide for. The
|
// Chromium Account is the sole Install row with anything left to hide for. The
|
||||||
@@ -314,7 +322,8 @@ const windowsGuard = 'omarchy-hw-x86'
|
|||||||
const x86OnlyInstalls = ['install.windows', 'install.browser.edge', 'install.service.dropbox', 'install.service.spotify', 'install.gaming.minecraft', 'install.gaming.heroic']
|
const x86OnlyInstalls = ['install.windows', 'install.browser.edge', 'install.service.dropbox', 'install.service.spotify', 'install.gaming.minecraft', 'install.gaming.heroic']
|
||||||
assertDeepEqual(
|
assertDeepEqual(
|
||||||
defaultItems
|
defaultItems
|
||||||
.filter(item => item.id.startsWith('install.') && item.action && item.when)
|
// China Apps rows hide by region, not because the software is there.
|
||||||
|
.filter(item => item.id.startsWith('install.') && item.action && item.when && item.when !== '[[ "$(omarchy-region)" == "China" ]]')
|
||||||
.map(item => item.id)
|
.map(item => item.id)
|
||||||
.sort(),
|
.sort(),
|
||||||
[...x86OnlyInstalls, 'install.service.chromium-account'].sort(),
|
[...x86OnlyInstalls, 'install.service.chromium-account'].sort(),
|
||||||
@@ -372,13 +381,6 @@ assert(
|
|||||||
defaultById['setup.security.passwordless-sudo'].action.includes('omarchy-sudo-passwordless'),
|
defaultById['setup.security.passwordless-sudo'].action.includes('omarchy-sudo-passwordless'),
|
||||||
'menu places Passwordless Sudo under Setup > Security'
|
'menu places Passwordless Sudo under Setup > Security'
|
||||||
)
|
)
|
||||||
assert(
|
|
||||||
defaultById['setup.security.usb-authorization'].action.includes('omarchy-setup-security-usb-authorization')
|
|
||||||
&& defaultById['remove.security.usb-authorization'].action.includes('omarchy-remove-security-usb-authorization')
|
|
||||||
&& defaultById['setup.security.usb-authorization-boot'].action.includes('omarchy-setup-security-usb-authorization --boot')
|
|
||||||
&& defaultById['remove.security.usb-authorization-boot'].action.includes('omarchy-remove-security-usb-authorization --boot-only'),
|
|
||||||
'menu can enable and remove USB device authorization under Security'
|
|
||||||
)
|
|
||||||
assert(
|
assert(
|
||||||
!defaultById['trigger.toggle.direct-boot'] && !defaultById['trigger.toggle.passwordless-sudo'],
|
!defaultById['trigger.toggle.direct-boot'] && !defaultById['trigger.toggle.passwordless-sudo'],
|
||||||
'menu removes the relocated toggles from Trigger > Toggle'
|
'menu removes the relocated toggles from Trigger > Toggle'
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||||
|
|
||||||
|
scratch=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$scratch"' EXIT
|
||||||
|
export OMARCHY_PATH="$ROOT"
|
||||||
|
export TARGET_ROOT="$scratch/target root"
|
||||||
|
mkdir -p "$TARGET_ROOT/etc/omarchy"
|
||||||
|
|
||||||
|
apply() { "$ROOT/bin/omarchy-apply-pacman" "$1" "$TARGET_ROOT"; }
|
||||||
|
|
||||||
|
for channel in stable rc edge; do
|
||||||
|
apply "$channel"
|
||||||
|
cmp "$ROOT/default/pacman/pacman-$channel.conf" "$TARGET_ROOT/etc/pacman.conf"
|
||||||
|
cmp "$ROOT/default/pacman/mirrorlist-$channel" "$TARGET_ROOT/etc/pacman.d/mirrorlist"
|
||||||
|
done
|
||||||
|
pass "missing region preserves all global channel templates byte for byte"
|
||||||
|
|
||||||
|
chmod 600 "$TARGET_ROOT/etc/pacman.conf"
|
||||||
|
apply stable
|
||||||
|
[[ $(stat -c %a "$TARGET_ROOT/etc/pacman.conf") == 600 ]] || fail "refresh keeps a customized pacman.conf mode"
|
||||||
|
chmod 644 "$TARGET_ROOT/etc/pacman.conf"
|
||||||
|
pass "refresh keeps existing config file modes"
|
||||||
|
|
||||||
|
printf 'cn\n' > "$TARGET_ROOT/etc/omarchy/region"
|
||||||
|
for channel in stable rc edge; do
|
||||||
|
case "$channel" in
|
||||||
|
stable) host=stable-mirror.omarchy ;;
|
||||||
|
rc) host=rc-mirror.omarchy ;;
|
||||||
|
edge) host=mirror.omarchy ;;
|
||||||
|
esac
|
||||||
|
apply "$channel"
|
||||||
|
expected=$(printf 'Server = https://%s.cn/$repo/os/$arch\nServer = https://%s.org/$repo/os/$arch' "$host" "$host")
|
||||||
|
[[ $(<"$TARGET_ROOT/etc/pacman.d/mirrorlist") == "$expected" ]] || fail "$channel mirror order and variables"
|
||||||
|
{
|
||||||
|
cat "$ROOT/default/pacman/pacman-$channel.conf"
|
||||||
|
printf '\n[archlinuxcn]\nServer = https://mirrors.ustc.edu.cn/archlinuxcn/$arch\n'
|
||||||
|
} > "$scratch/expected.conf"
|
||||||
|
cmp "$scratch/expected.conf" "$TARGET_ROOT/etc/pacman.conf"
|
||||||
|
apply "$channel"
|
||||||
|
cmp "$TARGET_ROOT/etc/pacman.conf.bak" "$TARGET_ROOT/etc/pacman.conf"
|
||||||
|
cmp "$TARGET_ROOT/etc/pacman.d/mirrorlist.bak" "$TARGET_ROOT/etc/pacman.d/mirrorlist"
|
||||||
|
done
|
||||||
|
pass "China puts its channel mirror first, keeps the global one as fallback, and is idempotent"
|
||||||
|
|
||||||
|
cp "$TARGET_ROOT/etc/pacman.conf" "$scratch/before.conf"
|
||||||
|
cp "$TARGET_ROOT/etc/pacman.d/mirrorlist" "$scratch/before.mirrors"
|
||||||
|
for region in '' CN chn zz '../cn' '$(touch NEVER_EXECUTE)'; do
|
||||||
|
printf '%s\n' "$region" > "$TARGET_ROOT/etc/omarchy/region"
|
||||||
|
if apply stable > "$scratch/error" 2>&1; then
|
||||||
|
fail "invalid region accepted: $region"
|
||||||
|
fi
|
||||||
|
cmp "$scratch/before.conf" "$TARGET_ROOT/etc/pacman.conf"
|
||||||
|
cmp "$scratch/before.mirrors" "$TARGET_ROOT/etc/pacman.d/mirrorlist"
|
||||||
|
done
|
||||||
|
pass "unsupported and malformed regions fail before replacing either config"
|
||||||
|
|
||||||
|
printf 'cn\n' > "$TARGET_ROOT/etc/omarchy/region"
|
||||||
|
mkdir -p "$scratch/incomplete/default/regions/cn/pacman"
|
||||||
|
cp -a "$ROOT/default/pacman" "$scratch/incomplete/default/"
|
||||||
|
cp "$ROOT/default/regions/cn/pacman/pacman.conf.append" "$scratch/incomplete/default/regions/cn/pacman/"
|
||||||
|
if OMARCHY_PATH="$scratch/incomplete" apply stable > "$scratch/error" 2>&1; then
|
||||||
|
fail "incomplete region profile accepted"
|
||||||
|
fi
|
||||||
|
cmp "$scratch/before.conf" "$TARGET_ROOT/etc/pacman.conf"
|
||||||
|
cmp "$scratch/before.mirrors" "$TARGET_ROOT/etc/pacman.d/mirrorlist"
|
||||||
|
pass "missing fragment cannot leave a half-generated config"
|
||||||
|
|
||||||
|
# Refresh renders these files unprivileged and hands root plain copies; its
|
||||||
|
# privileged path is covered in the sudo-boundary sandbox, never on the host.
|
||||||
|
cp "$TARGET_ROOT/etc/pacman.conf" "$scratch/before.conf"
|
||||||
|
mkdir -p "$scratch/rendered"
|
||||||
|
"$ROOT/bin/omarchy-apply-pacman" --render rc "$scratch/rendered" "$TARGET_ROOT"
|
||||||
|
rg -qxF 'Server = https://pkgs.omarchy.org/rc/$arch' "$scratch/rendered/pacman.conf"
|
||||||
|
rg -qxF '[archlinuxcn]' "$scratch/rendered/pacman.conf"
|
||||||
|
[[ $(<"$scratch/rendered/mirrorlist") == $'Server = https://rc-mirror.omarchy.cn/$repo/os/$arch\nServer = https://rc-mirror.omarchy.org/$repo/os/$arch' ]] ||
|
||||||
|
fail "render puts the regional channel mirror first"
|
||||||
|
cmp "$scratch/before.conf" "$TARGET_ROOT/etc/pacman.conf"
|
||||||
|
pass "render builds the regional channel files without touching the target"
|
||||||
|
|
||||||
|
if "$ROOT/bin/omarchy-apply-pacman" --render dev "$scratch/rendered" "$TARGET_ROOT" > "$scratch/error" 2>&1; then
|
||||||
|
fail "render accepts a nonexistent dev pacman template"
|
||||||
|
fi
|
||||||
|
pass "render rejects channels without pacman templates"
|
||||||
|
|
||||||
|
printf 'global\n' > "$TARGET_ROOT/etc/omarchy/region"
|
||||||
|
apply stable
|
||||||
|
cmp "$ROOT/default/pacman/pacman-stable.conf" "$TARGET_ROOT/etc/pacman.conf"
|
||||||
|
cmp "$ROOT/default/pacman/mirrorlist-stable" "$TARGET_ROOT/etc/pacman.d/mirrorlist"
|
||||||
|
pass "users can opt out of regional repository defaults"
|
||||||
|
|
||||||
|
# The shipped China profile is repository defaults only. Language, locale, and
|
||||||
|
# input methods follow the installer's language choice, not the region.
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
region = Path(os.environ["ROOT"]) / "default/regions/cn"
|
||||||
|
packages = {line.strip() for line in (region / "packages").read_text().splitlines() if line.strip() and not line.startswith("#")}
|
||||||
|
assert packages == {"archlinuxcn-keyring"}, packages
|
||||||
|
assert not (region / "skel").exists()
|
||||||
|
PY
|
||||||
|
pass "China profile adds only its repository keyring"
|
||||||
|
|
||||||
|
# The installer derives the region from the chosen timezone, so every profile
|
||||||
|
# needs a mapping, and no timezone may select two regions.
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
zoneinfo = Path("/usr/share/zoneinfo")
|
||||||
|
owners = {}
|
||||||
|
for profile in sorted((Path(os.environ["ROOT"]) / "default/regions").iterdir()):
|
||||||
|
lines = (profile / "timezones").read_text().splitlines()
|
||||||
|
zones = [line.strip() for line in lines if line.strip() and not line.startswith("#")]
|
||||||
|
assert zones, f"{profile.name} maps no timezones"
|
||||||
|
for zone in zones:
|
||||||
|
assert zone not in owners, f"{zone} maps to {owners[zone]} and {profile.name}"
|
||||||
|
owners[zone] = profile.name
|
||||||
|
if zoneinfo.is_dir():
|
||||||
|
assert (zoneinfo / zone).is_file(), f"{profile.name} lists unknown timezone {zone}"
|
||||||
|
|
||||||
|
# The installer offers tzdata's backward-compatible names too, so China
|
||||||
|
# covers every name that links to its two zones and nothing else.
|
||||||
|
# Without tzdata.zi (minimal containers) only the canonical zones can be
|
||||||
|
# checked, so completeness is enforced where the host has the link table.
|
||||||
|
china = {zone for zone, region in owners.items() if region == "cn"}
|
||||||
|
expected = {"Asia/Shanghai", "Asia/Urumqi"}
|
||||||
|
tzdata = zoneinfo / "tzdata.zi"
|
||||||
|
if tzdata.is_file():
|
||||||
|
for line in tzdata.read_text().splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) == 3 and fields[0] == "L" and fields[1] in {"Asia/Shanghai", "Asia/Urumqi"}:
|
||||||
|
expected.add(fields[2])
|
||||||
|
assert china == expected, (china, expected)
|
||||||
|
else:
|
||||||
|
assert expected <= china, (china, expected)
|
||||||
|
PY
|
||||||
|
pass "region profiles map each timezone to at most one region"
|
||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||||
|
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||||
|
copy_boundary_file bin/omarchy-refresh-pacman
|
||||||
|
copy_boundary_file bin/omarchy-apply-pacman
|
||||||
|
for file in default/pacman/pacman-rc.conf default/pacman/mirrorlist-rc \
|
||||||
|
default/regions/cn/pacman/pacman.conf.append default/regions/cn/pacman/mirrorlist-rc.prepend; do
|
||||||
|
copy_boundary_file "$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
# A sandboxed China machine: the render reads this region marker, never the
|
||||||
|
# host's /etc/omarchy/region.
|
||||||
|
mkdir -p "$SUDO_TEST_TARGET/etc/omarchy"
|
||||||
|
printf 'cn\n' >"$SUDO_TEST_TARGET/etc/omarchy/region"
|
||||||
|
|
||||||
|
# cp still logs its step, but now really copies, so the rendered files exist.
|
||||||
|
# Root's copies into /etc land in a capture directory instead of the host.
|
||||||
|
export SUDO_TEST_CAPTURE="$boundary_tmp/capture"
|
||||||
|
rm "$SUDO_TEST_ROOT/bin/cp"
|
||||||
|
cat >"$SUDO_TEST_ROOT/bin/cp" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'step:cp %s\n' "$*" >>"$SUDO_TEST_LOG"
|
||||||
|
destination=${!#}
|
||||||
|
if [[ $destination == /etc/* ]]; then
|
||||||
|
source=${*: -2:1}
|
||||||
|
[[ $source == /etc/* ]] && exit 0
|
||||||
|
/usr/bin/mkdir -p "$SUDO_TEST_CAPTURE${destination%/*}"
|
||||||
|
exec /usr/bin/cp "$source" "$SUDO_TEST_CAPTURE$destination"
|
||||||
|
fi
|
||||||
|
exec /usr/bin/cp "$@"
|
||||||
|
STUB
|
||||||
|
chmod +x "$SUDO_TEST_ROOT/bin/cp"
|
||||||
|
|
||||||
|
# Regional rendering must stay unprivileged: root only copies finished files,
|
||||||
|
# exactly as the channel refresh did before regions existed.
|
||||||
|
reset_boundary
|
||||||
|
"$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" rc >"$boundary_tmp/output" 2>&1 || fail "refresh failed" "$(<"$boundary_tmp/output")"
|
||||||
|
assert_boundary_cold "regional refresh"
|
||||||
|
python3 - "$SUDO_TEST_LOG" "$SUDO_TEST_ROOT" <<'PY'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
events = open(sys.argv[1]).read().splitlines()
|
||||||
|
root = sys.argv[2]
|
||||||
|
render = [i for i, e in enumerate(events) if e.startswith('step:cp ' + root + '/default/pacman/pacman-rc.conf ')]
|
||||||
|
assert len(render) == 1, events
|
||||||
|
rendered = events[render[0]].split()[-1].rsplit('/', 1)[0]
|
||||||
|
copies = [i for i, e in enumerate(events) if e.startswith('sudo -N cp ')]
|
||||||
|
assert [events[i] for i in copies[2:]] == [
|
||||||
|
f'sudo -N cp -f {rendered}/pacman.conf /etc/pacman.conf',
|
||||||
|
f'sudo -N cp -f {rendered}/mirrorlist /etc/pacman.d/mirrorlist',
|
||||||
|
], events
|
||||||
|
assert not any(e.startswith('sudo ') and 'omarchy-apply-pacman' in e for e in events), events
|
||||||
|
assert render[0] < copies[0], events
|
||||||
|
transaction = next(i for i, e in enumerate(events) if e.startswith('step:pacman '))
|
||||||
|
assert max(copies) < transaction, events
|
||||||
|
PY
|
||||||
|
{
|
||||||
|
cat "$ROOT/default/pacman/pacman-rc.conf"
|
||||||
|
printf '\n'
|
||||||
|
cat "$ROOT/default/regions/cn/pacman/pacman.conf.append"
|
||||||
|
} >"$boundary_tmp/expected-pacman.conf"
|
||||||
|
cat "$ROOT/default/regions/cn/pacman/mirrorlist-rc.prepend" "$ROOT/default/pacman/mirrorlist-rc" >"$boundary_tmp/expected-mirrorlist"
|
||||||
|
cmp -s "$boundary_tmp/expected-pacman.conf" "$SUDO_TEST_CAPTURE/etc/pacman.conf" ||
|
||||||
|
fail "root installs the rendered China pacman.conf" "$(diff "$boundary_tmp/expected-pacman.conf" "$SUDO_TEST_CAPTURE/etc/pacman.conf" 2>&1)"
|
||||||
|
cmp -s "$boundary_tmp/expected-mirrorlist" "$SUDO_TEST_CAPTURE/etc/pacman.d/mirrorlist" ||
|
||||||
|
fail "root installs the rendered China mirrorlist" "$(diff "$boundary_tmp/expected-mirrorlist" "$SUDO_TEST_CAPTURE/etc/pacman.d/mirrorlist" 2>&1)"
|
||||||
|
pass "refresh renders regional config unprivileged and gives root only plain copies"
|
||||||
|
|
||||||
|
reset_boundary
|
||||||
|
if "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" dev >"$boundary_tmp/output" 2>&1; then
|
||||||
|
fail "refresh accepts an invalid channel"
|
||||||
|
fi
|
||||||
|
if grep -Eq '^(sudo -N cp |step:pacman )' "$SUDO_TEST_LOG"; then
|
||||||
|
fail "invalid channel reached privileged work" "$(<"$SUDO_TEST_LOG")"
|
||||||
|
fi
|
||||||
|
pass "failed refresh does not proceed to privileged copies or a package update"
|
||||||
|
|
||||||
|
# A failed render still removes its scratch directory and exits cold.
|
||||||
|
reset_boundary
|
||||||
|
mv "$SUDO_TEST_ROOT/bin/omarchy-apply-pacman" "$boundary_tmp/omarchy-apply-pacman"
|
||||||
|
cat >"$SUDO_TEST_ROOT/bin/omarchy-apply-pacman" <<'STUB'
|
||||||
|
#!/bin/bash
|
||||||
|
: >"$3/pacman.conf"
|
||||||
|
exit 1
|
||||||
|
STUB
|
||||||
|
chmod +x "$SUDO_TEST_ROOT/bin/omarchy-apply-pacman"
|
||||||
|
mkdir -p "$boundary_tmp/scratch"
|
||||||
|
if TMPDIR="$boundary_tmp/scratch" "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" rc >"$boundary_tmp/output" 2>&1; then
|
||||||
|
fail "refresh succeeds after a failed render"
|
||||||
|
fi
|
||||||
|
mv "$boundary_tmp/omarchy-apply-pacman" "$SUDO_TEST_ROOT/bin/omarchy-apply-pacman"
|
||||||
|
[[ -z $(ls -A "$boundary_tmp/scratch") ]] || fail "failed render leaves its scratch directory" "$(ls -AR "$boundary_tmp/scratch")"
|
||||||
|
if grep -Eq '^(sudo -N cp |step:pacman )' "$SUDO_TEST_LOG"; then
|
||||||
|
fail "failed render reached privileged work" "$(<"$SUDO_TEST_LOG")"
|
||||||
|
fi
|
||||||
|
assert_boundary_cold "failed render"
|
||||||
|
pass "failed render removes its scratch directory and exits cold"
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||||
|
|
||||||
|
test_tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$test_tmp"' EXIT
|
||||||
|
|
||||||
|
root="$test_tmp/root" sandbox="$test_tmp/omarchy" calls="$test_tmp/calls"
|
||||||
|
mkdir -p "$root/etc/pacman.d" "$sandbox/bin" "$sandbox/install/config" "$sandbox/default/regions" "$test_tmp/bin" "$test_tmp/home"
|
||||||
|
cp -r "$ROOT/default/regions/cn" "$sandbox/default/regions/"
|
||||||
|
mkdir -p "$sandbox/shell/Commons" && cp "$ROOT/shell/Commons/I18nZh.js" "$sandbox/shell/Commons/"
|
||||||
|
rewrite() { sed -e "s|/etc/|$root/etc/|g" -e "s| /\\.cargo/| $root/.cargo/|g" "$1" >"$2"; chmod +x "$2"; }
|
||||||
|
rewrite "$ROOT/install/config/region.sh" "$sandbox/install/config/region.sh"
|
||||||
|
rewrite "$ROOT/bin/omarchy-region" "$sandbox/bin/omarchy-region"
|
||||||
|
|
||||||
|
for command in omarchy-refresh-pacman omarchy-system-logout pacman-key pacman omarchy-apply-pacman; do
|
||||||
|
printf '#!/bin/bash\necho "%s $*" >>"%s"\n' "$command" "$calls" >"$test_tmp/bin/$command"
|
||||||
|
done
|
||||||
|
printf '#!/bin/bash\necho "omarchy-update-pacman $*" >>"%s"\n[[ -z ${STUB_UPDATE_FAIL:-} ]]\n' "$calls" >"$test_tmp/bin/omarchy-update-pacman"
|
||||||
|
printf '#!/bin/bash\necho "${STUB_CHANNEL:-stable}"\n' >"$test_tmp/bin/omarchy-version-channel"
|
||||||
|
printf '#!/bin/bash\necho "${STUB_PLATFORM:-x86}"\n' >"$test_tmp/bin/omarchy-hw-platform"
|
||||||
|
printf '#!/bin/bash\n[[ $1 == env ]] && shift\nexec env "$@"\n' >"$test_tmp/bin/sudo"
|
||||||
|
printf '#!/bin/bash\necho "gum $*" >>"%s"\nexit "${GUM_ANSWER:-1}"\n' "$calls" >"$test_tmp/bin/gum"
|
||||||
|
chmod +x "$test_tmp/bin"/*
|
||||||
|
ln -s "$ROOT/bin/omarchy-text" "$test_tmp/bin/omarchy-text"
|
||||||
|
mkdir -p "$test_tmp/tmp"
|
||||||
|
export PATH="$test_tmp/bin:$sandbox/bin:$PATH" OMARCHY_PATH="$sandbox" HOME="$test_tmp/home" TMPDIR="$test_tmp/tmp"
|
||||||
|
switch_calls() { sed "s|$test_tmp/tmp/[^/ ]*|RENDERED|g" "$calls"; }
|
||||||
|
|
||||||
|
omarchy-region cn >/dev/null
|
||||||
|
[[ $(omarchy-region) == "China" && $(<"$root/etc/omarchy/region") == "cn" ]] ||
|
||||||
|
fail "switching to China records cn"
|
||||||
|
[[ $(switch_calls) == $'omarchy-update-pacman -Syu --noconfirm\nomarchy-apply-pacman --render stable RENDERED\npacman --config RENDERED/pacman.conf -Sy --noconfirm --needed archlinuxcn-keyring\npacman-key --populate archlinuxcn\nomarchy-refresh-pacman stable' ]] ||
|
||||||
|
fail "China upgrades, trusts ArchLinuxCN through the rendered cn config, then refreshes" "$(switch_calls)"
|
||||||
|
[[ -z $(ls -A "$test_tmp/tmp") ]] || fail "the rendered config is removed"
|
||||||
|
pass "switching to China trusts ArchLinuxCN before the refresh upgrades through it"
|
||||||
|
|
||||||
|
: >"$calls"
|
||||||
|
STUB_CHANNEL=edge omarchy-region cn >/dev/null
|
||||||
|
grep -qx 'omarchy-refresh-pacman edge' "$calls" || fail "the refresh keeps the machine's channel" "$(<"$calls")"
|
||||||
|
pass "switching to China again keeps the channel"
|
||||||
|
|
||||||
|
: >"$calls"
|
||||||
|
omarchy-region global >/dev/null
|
||||||
|
[[ $(omarchy-region) == "Global" && ! -e $root/etc/omarchy/region ]] ||
|
||||||
|
fail "switching to Global removes the region"
|
||||||
|
[[ $(<"$calls") == 'omarchy-refresh-pacman stable' ]] ||
|
||||||
|
fail "Global refreshes pacman back to the channel defaults" "$(<"$calls")"
|
||||||
|
pass "switching to Global restores the channel's pacman defaults"
|
||||||
|
|
||||||
|
if output=$(omarchy-region mars 2>&1); then
|
||||||
|
fail "an unknown region is refused" "$output"
|
||||||
|
fi
|
||||||
|
if output=$(STUB_CHANNEL="stable / edge" omarchy-region cn 2>&1); then
|
||||||
|
fail "a mixed channel is refused" "$output"
|
||||||
|
fi
|
||||||
|
[[ $(omarchy-region) == "Global" ]] || fail "a refused switch changes nothing"
|
||||||
|
pass "an unknown region or an unclear channel is refused"
|
||||||
|
|
||||||
|
: >"$calls"
|
||||||
|
if output=$(STUB_PLATFORM=aarch64-apple omarchy-region cn 2>&1); then
|
||||||
|
fail "an ARM machine refuses the China region" "$output"
|
||||||
|
fi
|
||||||
|
[[ $(omarchy-region) == "Global" && ! -s $calls ]] || fail "a refused ARM switch changes nothing" "$(<"$calls")"
|
||||||
|
pass "an ARM machine refuses the China region before changing anything"
|
||||||
|
|
||||||
|
: >"$calls"
|
||||||
|
if output=$(STUB_UPDATE_FAIL=1 omarchy-region cn 2>&1); then
|
||||||
|
fail "switching to China fails when the upgrade fails" "$output"
|
||||||
|
fi
|
||||||
|
[[ $(omarchy-region) == "Global" ]] && ! grep -q '^omarchy-refresh-pacman' "$calls" ||
|
||||||
|
fail "a failed switch to China leaves the region Global and pacman untouched" "$(<"$calls")"
|
||||||
|
pass "a failed switch to China leaves the machine Global"
|
||||||
|
|
||||||
|
grep -q '^gum' "$calls" && fail "a switch without a terminal asks nothing" "$(<"$calls")"
|
||||||
|
: >"$calls"
|
||||||
|
output=$(LANG=zh_CN.UTF-8 GUM_ANSWER=1 script -qec "omarchy-region cn" /dev/null)
|
||||||
|
grep -qxF 'gum confirm --affirmative 立即注销 --negative 稍后 立即注销以切换开发工具镜像?' "$calls" ||
|
||||||
|
fail "a Chinese session offers to log out in Chinese" "$(<"$calls")"
|
||||||
|
[[ $output == *"重新登录后切换开发工具镜像。"* ]] && ! grep -q '^omarchy-system-logout' "$calls" ||
|
||||||
|
fail "declining the logout leaves a reminder and the session" "$output"
|
||||||
|
: >"$calls"
|
||||||
|
LANG=en_US.UTF-8 GUM_ANSWER=0 script -qec "omarchy-region global" /dev/null >/dev/null
|
||||||
|
grep -qxF 'gum confirm --affirmative Log out now --negative Later Log out now to switch the developer mirrors?' "$calls" &&
|
||||||
|
grep -qx 'omarchy-system-logout ' "$calls" || fail "a terminal switch offers to log out and logs out on yes" "$(<"$calls")"
|
||||||
|
pass "a switch in a terminal offers to log out in the session's language"
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||||
|
|
||||||
|
test_tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$test_tmp"' EXIT
|
||||||
|
|
||||||
|
region_setup="$test_tmp/region.sh"
|
||||||
|
root="$test_tmp/root"
|
||||||
|
sed -e "s|/etc/|$root/etc/|g" -e "s| /\.cargo/| $root/.cargo/|g" "$ROOT/install/config/region.sh" >"$region_setup"
|
||||||
|
export OMARCHY_PATH="$ROOT"
|
||||||
|
mkdir -p "$root/etc/omarchy"
|
||||||
|
|
||||||
|
bash -e "$region_setup"
|
||||||
|
[[ -z $(find "$root" -type l) ]] || fail "no marker links nothing"
|
||||||
|
pass "no marker installs Global"
|
||||||
|
|
||||||
|
echo cn >"$root/etc/omarchy/region"
|
||||||
|
bash -e "$region_setup"
|
||||||
|
for link in "environment.conf etc/environment.d/90-omarchy-china.conf" "cargo.toml .cargo/config.toml" "gemrc etc/gemrc"; do
|
||||||
|
read -r source target <<<"$link"
|
||||||
|
[[ $(readlink "$root/$target") == "$ROOT/default/regions/cn/$source" ]] || fail "China links /$target to the shipped $source"
|
||||||
|
done
|
||||||
|
bash -e "$region_setup"
|
||||||
|
pass "the cn marker links the developer mirror drop-ins, again without error"
|
||||||
|
|
||||||
|
rm "$root/etc/gemrc"
|
||||||
|
echo ":sources: []" >"$root/etc/gemrc"
|
||||||
|
if output=$(bash -e "$region_setup" 2>&1); then
|
||||||
|
fail "China refuses to replace an admin's /etc/gemrc" "$output"
|
||||||
|
fi
|
||||||
|
[[ $output == "Refusing to replace $root/etc/gemrc, which Omarchy did not create" && $(<"$root/etc/gemrc") == ":sources: []" ]] ||
|
||||||
|
fail "China names the file it refuses to replace and leaves it" "$output"
|
||||||
|
rm "$root/etc/gemrc"
|
||||||
|
pass "region setup never replaces mirror config Omarchy did not create"
|
||||||
|
|
||||||
|
echo mars >"$root/etc/omarchy/region"
|
||||||
|
if output=$(bash -e "$region_setup" 2>&1); then
|
||||||
|
fail "an unknown region fails" "$output"
|
||||||
|
fi
|
||||||
|
[[ $output == "Unknown region: mars" ]] || fail "region setup names the unknown region" "$output"
|
||||||
|
pass "region setup rejects an unknown region"
|
||||||
|
|
||||||
|
echo cn >"$root/etc/omarchy/region"
|
||||||
|
bash -e "$region_setup"
|
||||||
|
OMARCHY_REGION=global bash -e "$region_setup"
|
||||||
|
[[ ! -e $root/etc/omarchy/region && -z $(find "$root" -type l) ]] || fail "Global removes the marker and the developer mirror links"
|
||||||
|
ln -s /usr/share/somewhere/gemrc "$root/etc/gemrc"
|
||||||
|
OMARCHY_REGION=global bash -e "$region_setup"
|
||||||
|
[[ -L $root/etc/gemrc ]] || fail "Global keeps a gemrc link Omarchy did not create"
|
||||||
|
rm "$root/etc/gemrc"
|
||||||
|
pass "switching to Global removes only what Omarchy linked"
|
||||||
|
|
||||||
|
OMARCHY_REGION=cn bash -e "$region_setup"
|
||||||
|
[[ $(<"$root/etc/omarchy/region") == "cn" && $(readlink "$root/etc/gemrc") == "$ROOT/default/regions/cn/gemrc" ]] ||
|
||||||
|
fail "switching to China records cn and links the drop-ins"
|
||||||
|
pass "switching to China records cn and links the drop-ins"
|
||||||
|
|
||||||
|
region_bin="$test_tmp/omarchy-region"
|
||||||
|
sed "s|/etc/omarchy/region|$root/etc/omarchy/region|" "$ROOT/bin/omarchy-region" >"$region_bin"
|
||||||
|
chmod +x "$region_bin"
|
||||||
|
[[ $("$region_bin") == "China" ]] || fail "the cn marker reads as China"
|
||||||
|
rm "$root/etc/omarchy/region"
|
||||||
|
[[ $("$region_bin") == "Global" ]] || fail "no marker reads as Global"
|
||||||
|
echo mars >"$root/etc/omarchy/region"
|
||||||
|
if output=$("$region_bin" 2>&1); then
|
||||||
|
fail "an unknown marker fails" "$output"
|
||||||
|
fi
|
||||||
|
[[ $output == "Unknown region in $root/etc/omarchy/region: mars" ]] || fail "the reader names the file and value" "$output"
|
||||||
|
pass "omarchy region reads Global, China, and fails on anything else"
|
||||||
|
|
||||||
|
grep -qx 'run_logged "$OMARCHY_INSTALL/config/region.sh"' "$ROOT/install/config/all.sh" || fail "the install runs region setup"
|
||||||
|
pass "the install runs region setup"
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
||||||
|
|
||||||
bash "$ROOT/test/shell.d/fixtures/thunderbolt/policy-test.sh"
|
|
||||||
bash "$ROOT/test/shell.d/fixtures/thunderbolt/startup-test.sh"
|
|
||||||
|
|
||||||
if [[ -n ${BOLT_TEST_SOURCE:-} ]]; then
|
|
||||||
/usr/bin/python3 "$ROOT/test/shell.d/fixtures/thunderbolt/bolt-integration.py" "$ROOT"
|
|
||||||
else
|
|
||||||
skip "real boltd integration needs BOLT_TEST_SOURCE and an UMockdev environment"
|
|
||||||
fi
|
|
||||||
@@ -6,6 +6,7 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|||||||
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
|
||||||
copy_boundary_file bin/omarchy-update
|
copy_boundary_file bin/omarchy-update
|
||||||
copy_boundary_file bin/omarchy-refresh-pacman
|
copy_boundary_file bin/omarchy-refresh-pacman
|
||||||
|
copy_boundary_file bin/omarchy-apply-pacman
|
||||||
# Replace the step symlink, preserving the real fixture dispatcher.
|
# Replace the step symlink, preserving the real fixture dispatcher.
|
||||||
rm "$SUDO_TEST_ROOT/bin/omarchy-update-aur-pkgs"
|
rm "$SUDO_TEST_ROOT/bin/omarchy-update-aur-pkgs"
|
||||||
copy_boundary_file bin/omarchy-update-aur-pkgs
|
copy_boundary_file bin/omarchy-update-aur-pkgs
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
||||||
source "$ROOT/install/helpers/usb-authorization-policy.sh"
|
|
||||||
scratch=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$scratch"' EXIT
|
|
||||||
|
|
||||||
identity='id 046d:c53a serial "" name "USB Receiver" hash "KCUFt1MumW4Pfs/8YXWzGQB6Hsnm8qkDqFVjfY0NIBY="'
|
|
||||||
interfaces='with-interface { 03:01:01 03:01:02 03:00:00 } with-connect-type "unknown"'
|
|
||||||
location='parent-hash "m7yTNWlczwBbYn+uRP6TRsY5AceKmAZ0Et6mgy58+/o=" via-port "3-1.1.2.1"'
|
|
||||||
device="allow $identity $location $interfaces"
|
|
||||||
portable=$(usb_authorization_portable_rule "$device")
|
|
||||||
[[ $portable == "allow $identity $interfaces"' label "omarchy-usb-authorization-v1"' ]] || fail "only device location is removed"
|
|
||||||
[[ $(usb_authorization_portable_rule "${device/#allow/block}") == "$portable" ]] || fail "blocked approvals use the same portable identity"
|
|
||||||
pass "portable USB rules retain every device identity attribute"
|
|
||||||
|
|
||||||
# A canonical escaped name cannot cause its contents to become attributes.
|
|
||||||
name='name "Receiver \" via-port \"fake\" parent-hash \"fake\" $(touch /tmp/usb-injection)"'
|
|
||||||
hostile="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $location $interfaces"
|
|
||||||
expected="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $interfaces"' label "omarchy-usb-authorization-v1"'
|
|
||||||
[[ $(usb_authorization_portable_rule "$hostile") == "$expected" ]] || fail "quoted attribute text must remain data"
|
|
||||||
if usb_authorization_portable_rule 'allow id 046d:c53a name "unterminated' >/dev/null; then fail "reject unmatched quote"; fi
|
|
||||||
if usb_authorization_portable_rule 'allow id 046d:c53a via-port' >/dev/null; then fail "reject missing location value"; fi
|
|
||||||
pass "USB portable parsing preserves escaped and hostile descriptor text"
|
|
||||||
|
|
||||||
# Exercise the actual root entrypoint startup boundary without root work.
|
|
||||||
sed -e "s|source /usr/bin/omarchy-security-functions|source $ROOT/bin/omarchy-security-functions|" \
|
|
||||||
-e 's|source /usr/share/omarchy/install/helpers/usb-authorization-policy.sh|exit 126|' \
|
|
||||||
"$ROOT/bin/omarchy-usb-authorization-approve" >"$scratch/entry"
|
|
||||||
chmod 755 "$scratch/entry"
|
|
||||||
printf 'set -p\n' >"$scratch/decoy"
|
|
||||||
if BASH_ENV="$scratch/decoy" /usr/bin/bash "$scratch/entry" -p; then fail "ordinary Bash with decoy -p is rejected"; fi
|
|
||||||
printf 'touch "%s"\n' "$scratch/injected" >"$scratch/startup"
|
|
||||||
if BASH_ENV="$scratch/startup" "$scratch/entry" >/dev/null 2>&1; then fail "startup fixture boundary"; fi
|
|
||||||
if /usr/bin/env 'BASH_FUNC_source%%=() { touch "$USB_TEST_INJECTED"; }' USB_TEST_INJECTED="$scratch/injected" "$scratch/entry" >/dev/null 2>&1; then fail "exported function fixture boundary"; fi
|
|
||||||
[[ ! -e $scratch/injected ]] || fail "privileged entrypoint executes caller startup code"
|
|
||||||
pass "USB privileged approval rejects unsafe Bash startup"
|
|
||||||
|
|
||||||
if ! command -v g++ >/dev/null || [[ ! -f /usr/include/usbguard/Rule.hpp ]]; then
|
|
||||||
skip "native USBGuard matcher requires g++ and USBGuard development headers"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
printf '%s\n' "$device" >"$scratch/device"
|
|
||||||
printf '%s\n' "$portable" >"$scratch/policy"
|
|
||||||
g++ -std=c++17 "$ROOT/test/shell.d/fixtures/usb-authorization/match.cpp" -lusbguard -o "$scratch/match"
|
|
||||||
"$scratch/match" "$scratch/policy" "$scratch/device"
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,257 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
||||||
source "$ROOT/bin/omarchy-usb-authorization-boot"
|
|
||||||
|
|
||||||
scratch=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$scratch"' EXIT
|
|
||||||
machine=12345678901234567890123456789012
|
|
||||||
export calls="$scratch/calls"
|
|
||||||
: >"$calls"
|
|
||||||
|
|
||||||
# Optional integration run uses real sbctl keys and sbattach in isolated paths.
|
|
||||||
sbctl() {
|
|
||||||
printf '%s\n' "$1" >>"$calls"
|
|
||||||
[[ ${SIGN_FAIL:-0} != 1 || $1 != sign ]] || return 1
|
|
||||||
[[ ${VERIFY_FAIL:-0} != 1 || $1 != verify ]] || return 1
|
|
||||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
|
||||||
/usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@"
|
|
||||||
elif [[ $1 == verify ]]; then
|
|
||||||
jq -n --arg file "${!#}" --argjson signed "${TEST_SIGNED:-1}" '[{file_name: $file, is_signed: $signed}]'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
sbattach() {
|
|
||||||
if [[ -n ${USB_TEST_SBATTACH:-} ]]; then
|
|
||||||
"$USB_TEST_SBATTACH" "$@"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
omarchy-cmd-missing() { return 1; }
|
|
||||||
limine-enroll-config() { [[ ${ENROLL_FAIL:-0} != 1 ]]; }
|
|
||||||
usb_authorization_secure_boot_enabled() { [[ ${SECURE_BOOT:-1} == 1 ]]; }
|
|
||||||
# Bootloader enrollment/rollback is exercised with real EFI binaries in its own suite.
|
|
||||||
usb_authorization_verify_bootloader() { return 0; }
|
|
||||||
|
|
||||||
fixture() {
|
|
||||||
local embedded="$1" hash digest image_base
|
|
||||||
esp="$scratch/case-$RANDOM"
|
|
||||||
history="$esp/$machine/limine_history"
|
|
||||||
manifest="$history/snapshots.json"
|
|
||||||
cache="$esp/cache.json"
|
|
||||||
config="$esp/limine.conf"
|
|
||||||
mkdir -p "$history"
|
|
||||||
printf '%s\0' "$embedded" >"$esp/cmdline"
|
|
||||||
cp /usr/lib/systemd/boot/efi/linuxx64.efi.stub "$esp/image.efi"
|
|
||||||
image_base=$(objdump -p "$esp/image.efi" | awk '$1 == "ImageBase" {print "0x" $2}')
|
|
||||||
objcopy --add-section .cmdline="$esp/cmdline" --change-section-vma .cmdline="$((image_base + 0x100000))" "$esp/image.efi"
|
|
||||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
|
||||||
sbctl sign "$esp/image.efi" >/dev/null
|
|
||||||
fi
|
|
||||||
digest=$(sha256sum "$esp/image.efi"); digest=${digest%% *}
|
|
||||||
old_name="test.efi_sha256_$digest"
|
|
||||||
cp "$esp/image.efi" "$history/$old_name"
|
|
||||||
hash=$(b2sum "$esp/image.efi"); hash=${hash%% *}
|
|
||||||
printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n comment: kernel-id=linux\n path: boot():/%s/limine_history/%s#%s\n cmdline: %s rootflags=subvol=/snapshot\n' \
|
|
||||||
"$machine" "$machine" "$old_name" "$hash" "$embedded" >"$config"
|
|
||||||
jq -n --arg name "$old_name" --arg hash "$hash" --arg machine "$machine" --arg cmdline "$embedded" '
|
|
||||||
def kernel:
|
|
||||||
{imageDetails: [{fileHashName: $name, fileName: "test.efi",
|
|
||||||
snapshotFilePathLine: "/" + $machine + "/limine_history/" + $name + "#" + $hash,
|
|
||||||
filePathLine: "/EFI/Linux/test.efi#" + $hash, properties: {HASH: "#" + $hash}}],
|
|
||||||
cmdlineDetails: [{cmdline: $cmdline, snapshotCmdline: $cmdline + " rootflags=subvol=/snapshot"}],
|
|
||||||
allInConfig: ["path: boot():/EFI/Linux/test.efi#" + $hash, "cmdline: " + $cmdline],
|
|
||||||
allInSnapshotConfig: ["comment: kernel-id=linux", "path: boot():/" + $machine + "/limine_history/" + $name + "#" + $hash, "cmdline: " + $cmdline + " rootflags=subvol=/snapshot"], subKernels: []};
|
|
||||||
{jsonFormatVersion: "1.3.0", snapshotEntries: [{kernelEntries: [kernel | .subKernels = [kernel]]}]}
|
|
||||||
' >"$manifest"
|
|
||||||
cp "$manifest" "$manifest.old"
|
|
||||||
cp "$manifest" "$cache"
|
|
||||||
cp "$manifest" "$esp/before.json"
|
|
||||||
cp "$config" "$esp/before.conf"
|
|
||||||
: >"$calls"
|
|
||||||
}
|
|
||||||
|
|
||||||
for expected in enabled disabled; do
|
|
||||||
old='quiet'
|
|
||||||
[[ $expected != disabled ]] || old+=' usbcore.authorized_default=0'
|
|
||||||
fixture "$old"
|
|
||||||
usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache"
|
|
||||||
new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest")
|
|
||||||
[[ $new_name != "$old_name" && -f $history/$new_name ]] || fail "conflicting archives get new content-addressed copies"
|
|
||||||
cmp "$history/$old_name" "$esp/image.efi" || fail "original archived image remains unchanged"
|
|
||||||
digest=$(sha256sum "$history/$new_name")
|
|
||||||
[[ $new_name == *"${digest%% *}" ]] || fail "replacement name matches its content hash"
|
|
||||||
objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section"
|
|
||||||
embedded=$(tr '\0' '\n' <"$esp/section")
|
|
||||||
wanted=quiet
|
|
||||||
[[ $expected != enabled ]] || wanted+=' usbcore.authorized_default=0'
|
|
||||||
[[ $embedded == "$wanted" ]] || fail "replacement must preserve embedded boot arguments and change only USB policy"
|
|
||||||
objcopy -O binary --only-section=.text "$history/$old_name" "$esp/text.before"
|
|
||||||
objcopy -O binary --only-section=.text "$history/$new_name" "$esp/text.after"
|
|
||||||
cmp "$esp/text.before" "$esp/text.after" || fail "archive conversion must not alter executable code"
|
|
||||||
[[ $(grep -c '^sign$' "$calls") == 1 && $(grep -c '^verify$' "$calls") == 2 ]] || fail "deduplicated image must be verified before and after signing"
|
|
||||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
|
||||||
! grep -Fq "$old_name" "$file" || fail "no old image references may survive in manifests"
|
|
||||||
usb_authorization_rewrite_snapshot_manifest "$expected" "$file"
|
|
||||||
# Both cached and structured restore representations must regenerate valid entries.
|
|
||||||
for format in cached structured; do
|
|
||||||
{
|
|
||||||
printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n' "$machine"
|
|
||||||
if [[ $format == cached ]]; then
|
|
||||||
jq -r '.snapshotEntries[0].kernelEntries[0].allInSnapshotConfig[]' "$file"
|
|
||||||
else
|
|
||||||
printf 'comment: kernel-id=linux\n'
|
|
||||||
jq -r '.snapshotEntries[0].kernelEntries[0].subKernels[0] | "path: boot():" + .imageDetails[0].snapshotFilePathLine, "cmdline: " + .cmdlineDetails[0].snapshotCmdline' "$file"
|
|
||||||
fi
|
|
||||||
} >"$esp/regenerated.conf"
|
|
||||||
usb_authorization_verify_boot_entries "$expected" "$esp/regenerated.conf" "$esp" "$machine" enabled || fail "regenerated $format $expected snapshot must verify"
|
|
||||||
done
|
|
||||||
done
|
|
||||||
usb_authorization_rewrite_boot_cmdlines "$expected" "$config" "$machine"
|
|
||||||
usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine" enabled || fail "$expected archive transition must verify under Secure Boot"
|
|
||||||
usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache"
|
|
||||||
[[ $(grep -c '^sign$' "$calls") == 1 ]] || fail "retry must not resign an already-compatible archive"
|
|
||||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
|
||||||
usb_authorization_verify_signature "$history/$new_name"
|
|
||||||
fi
|
|
||||||
pass "USB $expected transition updates signed history and all cached/structured references"
|
|
||||||
done
|
|
||||||
|
|
||||||
fixture quiet
|
|
||||||
old_hash=$(b2sum "$esp/image.efi"); old_hash=${old_hash%% *}
|
|
||||||
printf ' //current\n comment: kernel-id=current\n path: boot():/image.efi#%s\n cmdline: quiet\n' "$old_hash" >>"$config"
|
|
||||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
|
||||||
grep -Fq "path: boot():/image.efi#$old_hash" "$config" || fail "preflight must not rewrite hashes of unchanged current images sharing archive content"
|
|
||||||
pass "USB archive preflight preserves unchanged current-image hashes"
|
|
||||||
|
|
||||||
fixture 'quiet rd.test="two spaces"'
|
|
||||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
|
||||||
new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest")
|
|
||||||
objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section"
|
|
||||||
[[ $(tr '\0' '\n' <"$esp/section") == 'quiet rd.test="two spaces" usbcore.authorized_default=0' ]] || fail "unrelated quoted arguments must retain their whitespace"
|
|
||||||
pass "USB archive policy changes preserve unrelated embedded arguments and executable code"
|
|
||||||
|
|
||||||
for failure in SIGN_FAIL VERIFY_FAIL ENROLL_FAIL; do
|
|
||||||
fixture quiet
|
|
||||||
if (export "$failure=1"; usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache") >"$esp/failure.log" 2>&1; then
|
|
||||||
fail "$failure must prevent publication"
|
|
||||||
fi
|
|
||||||
cmp "$config" "$esp/before.conf" || fail "$failure must preserve the original boot menu"
|
|
||||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
|
||||||
cmp "$file" "$esp/before.json" || fail "$failure must preserve every manifest"
|
|
||||||
done
|
|
||||||
cmp "$history/$old_name" "$esp/image.efi" || fail "$failure must preserve the archived image"
|
|
||||||
done
|
|
||||||
pass "USB archive signing, verification and enrollment failures preserve original references"
|
|
||||||
|
|
||||||
fixture quiet
|
|
||||||
printf corrupt >>"$history/$old_name"
|
|
||||||
if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/hash-failure" 2>&1; then
|
|
||||||
fail "corrupt original archive must never be signed"
|
|
||||||
fi
|
|
||||||
[[ ! -s $calls ]] || fail "hash failure must occur before signing"
|
|
||||||
cmp "$config" "$esp/before.conf"
|
|
||||||
SECURE_BOOT=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache"
|
|
||||||
[[ ! -s $calls ]] || fail "non-Secure-Boot transitions must not rewrite archive images"
|
|
||||||
pass "USB archive conversion rejects corruption and skips non-Secure-Boot systems"
|
|
||||||
|
|
||||||
if [[ -z ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
|
||||||
fixture quiet
|
|
||||||
if TEST_SIGNED=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/unsigned" 2>&1; then
|
|
||||||
fail "a false-success sbctl result cannot authorize signing"
|
|
||||||
fi
|
|
||||||
! grep -qx sign "$calls" || fail "an unsigned original must not be resigned"
|
|
||||||
fi
|
|
||||||
for missing in menu manifest; do
|
|
||||||
fixture quiet
|
|
||||||
if [[ $missing == menu ]]; then
|
|
||||||
sed -i '/cmdline:/d' "$config"
|
|
||||||
else
|
|
||||||
jq '.snapshotEntries[0].kernelEntries[0].cmdlineDetails = []' "$manifest" >"$esp/modified"
|
|
||||||
mv "$esp/modified" "$manifest"
|
|
||||||
fi
|
|
||||||
usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" || fail "embedded-only $missing must support policy changes"
|
|
||||||
usb_authorization_rewrite_boot_cmdlines enabled "$config" "$machine"
|
|
||||||
usb_authorization_verify_boot_entries enabled "$config" "$esp" "$machine" enabled || fail "embedded-only $missing must retain a valid command line"
|
|
||||||
done
|
|
||||||
for section in .profile .pcrsig; do
|
|
||||||
fixture quiet
|
|
||||||
printf protected >"$esp/protected"
|
|
||||||
objcopy --add-section "$section=$esp/protected" "$history/$old_name" 2>/dev/null
|
|
||||||
if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/protected-failure" 2>&1; then
|
|
||||||
fail "a $section image must require its original builder"
|
|
||||||
fi
|
|
||||||
grep -Fq "($section)" "$esp/protected-failure" || fail "unsupported signing policies need an actionable error"
|
|
||||||
[[ ! -s $calls ]] || fail "unsupported signing policies must not be resigned"
|
|
||||||
done
|
|
||||||
pass "USB archive conversion requires authentic signatures and preserves boot/unlock requirements"
|
|
||||||
|
|
||||||
# Package hooks use this same lock. Installing the optional signature tool
|
|
||||||
# must finish before preparation takes the lock, while sync must run under it.
|
|
||||||
fixture quiet
|
|
||||||
prepare=$(declare -f usb_authorization_prepare_archived_images)
|
|
||||||
prepare=${prepare//\/run\/lock\/boot-partition.lock/$scratch\/preflight.lock}
|
|
||||||
eval "$prepare"
|
|
||||||
omarchy-cmd-missing() { return 0; }
|
|
||||||
omarchy-pkg-add() {
|
|
||||||
flock --nonblock "$scratch/preflight.lock" true || fail "package installation must precede the boot lock"
|
|
||||||
printf 'package\n' >>"$calls"
|
|
||||||
}
|
|
||||||
limine-snapper-sync() {
|
|
||||||
[[ $* == '--no-mutex --no-hooks' ]] || fail "preparation must use the already-held lock"
|
|
||||||
if flock --nonblock "$scratch/preflight.lock" true; then
|
|
||||||
fail "snapshot synchronization must hold the boot lock"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
usb_authorization_prepare_archived_images enabled "$config" "$esp" "$machine"
|
|
||||||
grep -qx package "$calls" || fail "missing signature tooling must be installed before preparing archives"
|
|
||||||
pass "USB archive preparation installs optional tools before taking Limine's lock"
|
|
||||||
|
|
||||||
# The disposable Lab runs the actual root-only main function with only boot paths
|
|
||||||
# and external build/sync commands redirected into this fixture.
|
|
||||||
if [[ ${USB_TEST_MAIN:-0} == 1 ]]; then
|
|
||||||
(( EUID == 0 )) || fail "main-path fixtures require the disposable guest's root"
|
|
||||||
usb_authorization_prepare_archived_images() {
|
|
||||||
usb_authorization_reconcile_archived_images "$1" "$2" "$3" "$machine" "$cache"
|
|
||||||
}
|
|
||||||
limine-mkinitcpio() { return 0; }
|
|
||||||
usb_authorization_sync_and_verify() {
|
|
||||||
local file
|
|
||||||
for file in "$manifest" "$manifest.old" "$cache"; do
|
|
||||||
usb_authorization_rewrite_snapshot_manifest "$1" "$file" || return 1
|
|
||||||
done
|
|
||||||
usb_authorization_rewrite_boot_cmdlines "$1" "$2" "$machine" || return 1
|
|
||||||
usb_authorization_verify_boot_entries "$1" "$2" "$3" "$machine" enabled
|
|
||||||
}
|
|
||||||
for action in enable disable; do
|
|
||||||
old=quiet
|
|
||||||
[[ $action != disable ]] || old+=' usbcore.authorized_default=0'
|
|
||||||
fixture "$old"
|
|
||||||
drop_in="$esp/entry-tool/usb-authorization.conf"
|
|
||||||
limine_defaults="$esp/defaults"
|
|
||||||
limine_conf="$config"
|
|
||||||
boot_path="$esp"
|
|
||||||
printf '# fixture defaults\n' >"$limine_defaults"
|
|
||||||
if [[ $action == disable ]]; then
|
|
||||||
mkdir -p "${drop_in%/*}"
|
|
||||||
printf '%s\n' "$setting" >"$drop_in"
|
|
||||||
usb_authorization_enable_snapshot_setting "$limine_defaults"
|
|
||||||
fi
|
|
||||||
cp "$limine_defaults" "$esp/defaults.before"
|
|
||||||
if (export SIGN_FAIL=1; usb_authorization_main "$action") >"$esp/main-failed" 2>&1; then
|
|
||||||
fail "$action must stop if archive preparation fails"
|
|
||||||
fi
|
|
||||||
cmp "$limine_defaults" "$esp/defaults.before" || fail "$action failure must precede snapshot-setting changes"
|
|
||||||
if [[ $action == enable ]]; then
|
|
||||||
[[ ! -e $drop_in ]] || fail "enable preflight failure must not create the boot setting"
|
|
||||||
else
|
|
||||||
[[ $(<"$drop_in") == "$setting" ]] || fail "disable preflight failure must preserve the boot setting"
|
|
||||||
fi
|
|
||||||
(usb_authorization_main "$action")
|
|
||||||
if [[ $action == enable ]]; then
|
|
||||||
[[ $(<"$drop_in") == "$setting" ]] || fail "successful enable sets the boot policy"
|
|
||||||
else
|
|
||||||
[[ ! -e $drop_in ]] || fail "successful disable clears the boot policy"
|
|
||||||
fi
|
|
||||||
pass "USB $action main path prepares archives before changing boot policy"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
|
||||||
source "$ROOT/bin/omarchy-usb-authorization-boot"
|
|
||||||
|
|
||||||
scratch=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$scratch"' EXIT
|
|
||||||
usb_authorization_secure_boot_enabled() { return 0; }
|
|
||||||
usb_authorization_limine_settings() { printf 'x64 %s\n' "${ENROLLMENT:-yes}"; }
|
|
||||||
|
|
||||||
# Real PE enrollment always runs. An optional isolated sbctl configuration also
|
|
||||||
# exercises real signatures; default runs track signed content without host keys.
|
|
||||||
sbctl() {
|
|
||||||
if [[ $1 == sign && ${SIGN_FAIL:-0} == 1 ]]; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then
|
|
||||||
/usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@"
|
|
||||||
elif [[ $1 == sign ]]; then
|
|
||||||
sha256sum "${!#}" | cut -d ' ' -f1 >>"$scratch/signed"
|
|
||||||
elif [[ $1 == verify ]]; then
|
|
||||||
local digest signed=0
|
|
||||||
digest=$(sha256sum "${!#}")
|
|
||||||
if grep -qx "${digest%% *}" "$scratch/signed"; then signed=1; fi
|
|
||||||
jq -n --arg file "${!#}" --argjson signed "$signed" '[{file_name: $file, is_signed: $signed}]'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
fixture() {
|
|
||||||
case_dir=$(mktemp -d "$scratch/case.XXXXXXXX")
|
|
||||||
boot_path="$case_dir/boot"
|
|
||||||
cache="$case_dir/cache"
|
|
||||||
limine_conf="$boot_path/limine.conf"
|
|
||||||
limine_defaults="$case_dir/defaults"
|
|
||||||
drop_in="$case_dir/dropins/usb-authorization.conf"
|
|
||||||
image="$boot_path/EFI/limine/limine_x64.efi"
|
|
||||||
mkdir -p "${image%/*}" "$cache/lss" "${drop_in%/*}"
|
|
||||||
printf 'initial menu\n' >"$limine_conf"
|
|
||||||
printf 'initial kernel\n' >"$boot_path/kernel.efi"
|
|
||||||
printf 'initial snapshot\n' >"$cache/lss/snapshots.json"
|
|
||||||
printf 'initial defaults\n' >"$limine_defaults"
|
|
||||||
: >"$scratch/signed"
|
|
||||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
|
||||||
if [[ ${ENROLLMENT:-yes} == yes ]]; then
|
|
||||||
digest=$(b2sum "$limine_conf")
|
|
||||||
limine enroll-config "$image" "${digest%% *}" --quiet
|
|
||||||
fi
|
|
||||||
sbctl sign "$image" >/dev/null
|
|
||||||
cp "$image" "$case_dir/original.efi"
|
|
||||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Matches the installed wrapper: restore unsigned binary, enroll, attempt sign,
|
|
||||||
# then mask failure with a successful final sync. No real ESP is ever touched.
|
|
||||||
limine-enroll-config() {
|
|
||||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
|
||||||
if [[ ${ENROLLMENT:-yes} == yes && ${SKIP_ENROLLMENT:-0} != 1 ]]; then
|
|
||||||
local digest
|
|
||||||
digest=$(b2sum "$limine_conf")
|
|
||||||
limine enroll-config "$image" "${digest%% *}" --quiet || return 1
|
|
||||||
fi
|
|
||||||
sbctl sign "$image" || true
|
|
||||||
sync -f "$image"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Relocate the outer real flock into scratch. The runner stands in for the
|
|
||||||
# namespace child and deliberately mutates the same files as rebuild hooks.
|
|
||||||
transaction=$(declare -f usb_authorization_boot_transaction)
|
|
||||||
transaction=${transaction//\/run\/lock\/boot-partition.lock/$scratch\/boot.lock}
|
|
||||||
eval "$transaction"
|
|
||||||
usb_authorization_run_transaction() {
|
|
||||||
if flock --nonblock "$scratch/boot.lock" true; then
|
|
||||||
fail "the real boot lock must cover the entire transaction"
|
|
||||||
fi
|
|
||||||
printf 'changed menu\n' >"$limine_conf"
|
|
||||||
printf 'changed kernel\n' >"$boot_path/kernel.efi"
|
|
||||||
printf 'changed metadata\n' >"$cache/lss/snapshots.json"
|
|
||||||
printf 'new cached backup\n' >"$cache/lss/snapshots.json.old"
|
|
||||||
mkdir -p "$boot_path/$(</etc/machine-id)/limine_history"
|
|
||||||
printf 'new archived backup\n' >"$boot_path/$(</etc/machine-id)/limine_history/snapshots.json.old"
|
|
||||||
printf 'changed defaults\n' >"$limine_defaults"
|
|
||||||
printf '%s\n' "$setting" >"$drop_in"
|
|
||||||
if [[ ${EARLY_FAILURE:-0} == 1 ]]; then
|
|
||||||
cp /usr/share/limine/BOOTX64.EFI "$image"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ ${UNCHECKED_HOOK:-0} == 1 ]]; then
|
|
||||||
limine-enroll-config
|
|
||||||
else
|
|
||||||
usb_authorization_enroll_config "$limine_conf" "$boot_path"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
for failure in SIGN_FAIL SKIP_ENROLLMENT EARLY_FAILURE; do
|
|
||||||
fixture
|
|
||||||
if (export "$failure=1"; usb_authorization_boot_transaction enable "$cache") >"$case_dir/failure.log" 2>&1; then
|
|
||||||
fail "$failure must reject the boot transition"
|
|
||||||
fi
|
|
||||||
cmp "$image" "$case_dir/original.efi" || fail "$failure must restore the signed bootloader byte-for-byte without signing"
|
|
||||||
[[ $(<"$limine_conf") == 'initial menu' ]] || fail "$failure must restore matching menu"
|
|
||||||
[[ $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "$failure must restore images referenced by that menu"
|
|
||||||
[[ $(<"$cache/lss/snapshots.json") == 'initial snapshot' ]] || fail "$failure must restore cached snapshot metadata"
|
|
||||||
[[ ! -e $cache/lss/snapshots.json.old && ! -e $boot_path/$(</etc/machine-id)/limine_history/snapshots.json.old ]] || fail "$failure must remove newly created recovery metadata"
|
|
||||||
[[ $(<"$limine_defaults") == 'initial defaults' && ! -e $drop_in ]] || fail "$failure must restore settings including an absent drop-in"
|
|
||||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
|
||||||
done
|
|
||||||
pass "USB boot transitions restore signed Limine, matching menu, images, metadata and settings on failure"
|
|
||||||
|
|
||||||
fixture
|
|
||||||
printf 'original opt-in\n' >"$drop_in"
|
|
||||||
if (SIGN_FAIL=1 UNCHECKED_HOOK=1 usb_authorization_boot_transaction disable "$cache") >"$case_dir/failure.log" 2>&1; then
|
|
||||||
fail "final verification must reject false-success rebuild hooks"
|
|
||||||
fi
|
|
||||||
cmp "$image" "$case_dir/original.efi" || fail "false-success hooks must restore original signed Limine"
|
|
||||||
[[ $(<"$drop_in") == 'original opt-in' ]] || fail "failed disable must preserve an existing drop-in"
|
|
||||||
pass "USB boot transitions independently verify enrollment after external hooks"
|
|
||||||
|
|
||||||
for enrollment in yes no; do
|
|
||||||
export ENROLLMENT="$enrollment"
|
|
||||||
fixture
|
|
||||||
usb_authorization_boot_transaction enable "$cache"
|
|
||||||
[[ $(<"$limine_conf") == 'changed menu' && -e $drop_in ]] || fail "successful transition must commit"
|
|
||||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
|
||||||
done
|
|
||||||
pass "USB boot transitions commit verified signing with enrollment enabled or disabled"
|
|
||||||
|
|
||||||
# Disposable Lab only: exercise the actual namespace runner and both lock
|
|
||||||
# inodes, with the installed enrollment/restore functions and real signatures.
|
|
||||||
if [[ ${USB_TEST_NAMESPACE:-0} == 1 ]]; then
|
|
||||||
(( EUID == 0 )) || fail "namespace tests require root in the disposable guest"
|
|
||||||
[[ -n ${USB_TEST_SBCTL_CONFIG:-} ]] || fail "namespace tests require isolated signing keys"
|
|
||||||
export ENROLLMENT=yes
|
|
||||||
for outcome in failure success; do
|
|
||||||
fixture
|
|
||||||
cp /usr/share/limine/BOOTX64.EFI "$case_dir/limine_x64.efi"
|
|
||||||
tar -cf "$case_dir/limine.bak" -C "$case_dir" limine_x64.efi
|
|
||||||
lock_inode=$(stat -Lc '%d:%i' /run/lock/boot-partition.lock)
|
|
||||||
# Source the shipped functions in both the parent and namespace child.
|
|
||||||
sed '/^if \[\[ ${BASH_SOURCE\[0\]} == "\$0" \]\]; then/,$d' "$ROOT/bin/omarchy-usb-authorization-boot" >"$case_dir/helper"
|
|
||||||
for variable in scratch case_dir boot_path cache limine_conf limine_defaults drop_in image lock_inode; do
|
|
||||||
printf '%s=%q\n' "$variable" "${!variable}" >>"$case_dir/helper"
|
|
||||||
done
|
|
||||||
declare -f sbctl usb_authorization_limine_settings usb_authorization_secure_boot_enabled >>"$case_dir/helper"
|
|
||||||
cat >>"$case_dir/helper" <<'SCRIPT'
|
|
||||||
usb_authorization_main() {
|
|
||||||
[[ $(stat -Lc '%d:%i' /run/lock/boot-partition.lock) != "$lock_inode" ]] || return 1
|
|
||||||
exec 9>/run/lock/boot-partition.lock
|
|
||||||
flock --nonblock 9 || return 1
|
|
||||||
printf 'changed menu\n' >"$limine_conf"
|
|
||||||
printf 'changed kernel\n' >"$boot_path/kernel.efi"
|
|
||||||
source /usr/lib/limine/limine-common-functions
|
|
||||||
LIMINE_DIR_PATH="${image%/*}"
|
|
||||||
LIMINE_EFI_FILE=limine_x64.efi
|
|
||||||
BINARY_TARGET_PATH="$image"
|
|
||||||
BINARY_BACKUP_PATH="$case_dir/limine.bak"
|
|
||||||
LIMINE_CONFIG_PATH="$limine_conf"
|
|
||||||
ENABLE_ENROLL_LIMINE_CONFIG=yes
|
|
||||||
is_sb_installed() { return 0; }
|
|
||||||
is_uefi() { return 0; }
|
|
||||||
limine-enroll-config() { enroll_config; }
|
|
||||||
usb_authorization_enroll_config "$limine_conf" "$boot_path"
|
|
||||||
}
|
|
||||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
|
||||||
usb_authorization_boot_transaction "$1" "$cache"
|
|
||||||
fi
|
|
||||||
SCRIPT
|
|
||||||
if [[ $outcome == failure ]]; then
|
|
||||||
if SIGN_FAIL=1 bash "$case_dir/helper" enable >"$case_dir/namespace.log" 2>&1; then
|
|
||||||
fail "actual namespace must reject installed wrapper's false success"
|
|
||||||
fi
|
|
||||||
grep -Fq 'Failed to sign' "$case_dir/namespace.log" || fail "failure must reach the installed enrollment function"
|
|
||||||
cmp "$image" "$case_dir/original.efi" || fail "namespace rollback restores original signed bootloader"
|
|
||||||
[[ $(<"$limine_conf") == 'initial menu' && $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "namespace rollback restores matching boot files"
|
|
||||||
else
|
|
||||||
bash "$case_dir/helper" disable >"$case_dir/namespace.log" 2>&1
|
|
||||||
[[ $(<"$limine_conf") == 'changed menu' ]] || fail "namespace success commits"
|
|
||||||
fi
|
|
||||||
usb_authorization_verify_bootloader "$limine_conf" "$boot_path"
|
|
||||||
flock --nonblock /run/lock/boot-partition.lock true || fail "outer lock must be released"
|
|
||||||
done
|
|
||||||
pass "USB real namespace isolates nested locks and recovers installed Limine signing failure"
|
|
||||||
fi
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
source "$(dirname "$0")/base-test.sh"
|
|
||||||
|
|
||||||
# Exercise reset staging in a temporary root with the real offline systemctl.
|
|
||||||
# Never invoke the reset command's main function or any actual disk operations.
|
|
||||||
python - "$ROOT" <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
repo = Path(sys.argv[1])
|
|
||||||
source = (repo / 'bin/omarchy-system-factory-reset').read_text()
|
|
||||||
functions = []
|
|
||||||
for name in ['install_provisioning_units', 'scrub_factory_accounts', 'sanitize_factory_baseline', 'stage_full_reset']:
|
|
||||||
match = re.search(r'^' + name + r'\(\) \{\n.*?^\}', source, re.M | re.S)
|
|
||||||
assert match, name
|
|
||||||
functions.append(match.group())
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory(prefix='omarchy-usb-reset-') as tmp:
|
|
||||||
scratch = Path(tmp)
|
|
||||||
for scenario in ['enabled', 'disabled', 'absent', 'disable-failure']:
|
|
||||||
root = scratch / scenario
|
|
||||||
top = root / 'top'
|
|
||||||
factory = top / '@factory'
|
|
||||||
for rel in ['etc/systemd/system/basic.target.wants', 'usr/lib/systemd/system',
|
|
||||||
'etc/usbguard', 'usr/bin', 'usr/share/omarchy/install/provisioning']:
|
|
||||||
(factory / rel).mkdir(parents=True, exist_ok=True)
|
|
||||||
(top / '@').mkdir()
|
|
||||||
(top / '@' / 'original-root').touch()
|
|
||||||
(factory / 'etc/passwd').write_text('root:x:0:0:root:/root:/bin/bash\n')
|
|
||||||
(factory / 'etc/machine-id').write_text('old-machine-id\n')
|
|
||||||
policy = 'allow id 1234:0001 name "Original owner keyboard" hash "old-owner"\n'
|
|
||||||
(factory / 'etc/usbguard/rules.conf').write_text(policy)
|
|
||||||
unit = factory / 'usr/lib/systemd/system/usbguard.service'
|
|
||||||
enabled_path = Path('etc/systemd/system/basic.target.wants/usbguard.service')
|
|
||||||
if scenario != 'absent':
|
|
||||||
unit.write_text('[Unit]\nDescription=Fixture USBGuard\n[Service]\nExecStart=/bin/true\n[Install]\nWantedBy=basic.target\n')
|
|
||||||
if scenario in ['enabled', 'disable-failure']:
|
|
||||||
(factory / enabled_path).symlink_to('/usr/lib/systemd/system/usbguard.service')
|
|
||||||
owner = factory / 'usr/bin/omarchy-provision-owner'
|
|
||||||
owner.touch()
|
|
||||||
owner.chmod(0o755)
|
|
||||||
for name in ['omarchy-provision-owner.service', 'omarchy-system-factory-reset-finish.service']:
|
|
||||||
shutil.copyfile(repo / 'install/provisioning' / name,
|
|
||||||
factory / 'usr/share/omarchy/install/provisioning' / name)
|
|
||||||
harness = '''#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
TOP_MNT="$1/top"
|
|
||||||
NEXT_NAME=@next
|
|
||||||
PROVISIONING_DIR=/var/lib/omarchy/provisioning
|
|
||||||
LOG_FILE="$1/log"
|
|
||||||
log() { :; }
|
|
||||||
fail() { echo "$*" >&2; exit 1; }
|
|
||||||
btrfs() {
|
|
||||||
if [[ $1 == subvolume && $2 == snapshot ]]; then
|
|
||||||
cp -a "$3" "$4"
|
|
||||||
elif [[ $1 == property ]]; then
|
|
||||||
:
|
|
||||||
else
|
|
||||||
return 90
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
# Account cleanup has its own real-tool suite; keep this fixture focused on USB.
|
|
||||||
usermod() {
|
|
||||||
[[ $1 == --root && ( $2 == "$TOP_MNT/$NEXT_NAME" || $2 == "$TOP_MNT/@factory" ) &&
|
|
||||||
$3 == --password && $4 == '!' && $5 == root ]]
|
|
||||||
}
|
|
||||||
systemd-id128() { echo new-machine-id; }
|
|
||||||
encrypted_install() { return 1; }
|
|
||||||
rebuild_next_boot() { :; }
|
|
||||||
sync() { :; }
|
|
||||||
systemctl() {
|
|
||||||
# All service changes must address the staged root, never the live machine.
|
|
||||||
[[ $1 == "--root=$TOP_MNT/$NEXT_NAME" && $2 == disable && $3 == usbguard.service ]] || return 91
|
|
||||||
[[ $2 != "${FAIL_OPERATION:-}" ]] || return 92
|
|
||||||
command systemctl "$@"
|
|
||||||
}
|
|
||||||
''' + '\n\n'.join(functions) + '\nstage_full_reset\n'
|
|
||||||
runner = root / 'run'
|
|
||||||
runner.write_text(harness)
|
|
||||||
env = os.environ.copy()
|
|
||||||
env['FAIL_OPERATION'] = 'disable' if scenario == 'disable-failure' else ''
|
|
||||||
result = subprocess.run(['bash', str(runner), str(root)], env=env, capture_output=True, text=True)
|
|
||||||
if scenario == 'disable-failure':
|
|
||||||
assert result.returncode != 0, 'failed disable must abort reset staging'
|
|
||||||
assert (top / '@' / 'original-root').exists(), 'failure must not activate the reset root'
|
|
||||||
assert 'could not disable USBGuard' in result.stderr
|
|
||||||
else:
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
staged = top / '@'
|
|
||||||
assert not (staged / enabled_path).is_symlink(), 'reset must not start USBGuard before enrollment'
|
|
||||||
assert (staged / 'var/lib/omarchy/provisioning/pending').exists()
|
|
||||||
assert (staged / 'etc/usbguard/rules.conf').read_text() == policy
|
|
||||||
if scenario == 'enabled':
|
|
||||||
assert (factory / enabled_path).is_symlink(), 'only the staged clone is disabled'
|
|
||||||
# The owner-enrollment helper enables the daemon after policy and
|
|
||||||
# ACL setup. Confirm an ordinary enable can reverse this disable.
|
|
||||||
subprocess.run(['systemctl', '--root=' + str(staged), 'enable', 'usbguard.service'],
|
|
||||||
check=True, capture_output=True, text=True)
|
|
||||||
assert (staged / enabled_path).is_symlink()
|
|
||||||
print('ok - factory USB enrollment staging: ' + scenario)
|
|
||||||
PY
|
|
||||||
Reference in new issue
Block a user