diff --git a/CHANGELOG.md b/CHANGELOG.md index dbccb2a..2680f43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,40 @@ month) from `config.mk`. A pre-release appends `-alpha.N`, `-beta.N` or stamped. Nothing else about the install path changed; this is the foundation the rest of Phase 6's update path (`lyona-update`) builds on. +- Add `lyona-update` (UPDATE-002, `docs/P6-UPDATE-HELPER.md`): a `check` / + `apply` / `rollback` / `backups` helper that lets an installed machine move + to a newer release and back again, on top of UPDATE-001's provenance + record. `check` compares the installed version against a `stable` or + `preview` GitHub release (calendar-version ordering, with a short-lived + cache so a panel indicator does not hammer the API) and reports `current`, + `behind`, `ahead`, `downgrade-offered`, `unknown`, or `offline` — never an + error for an unreachable network. `apply` downloads and SHA-256-verifies a + release tarball *before* unpacking it, builds unprivileged, backs up the + live install, then runs one confirmed privileged step + (`scripts/lyona-update-root`, installed via + `config/polkit/com.lyona.update.policy`) before verifying the result and + restamping provenance last — a build failure or a declined privileged step + costs nothing but time, never a half-applied system. `rollback` is the + missing half of `scripts/dev-sync-install.sh`'s existing backup machinery + (now reusable as a library via a `DEV_SYNC_INSTALL_LIB_ONLY` sourcing + guard that leaves its own direct-invocation behavior unchanged): it + refuses on any checksum or environment mismatch, and is designed to work + from a bare TTY with no desktop running by falling back from `pkexec` to + `sudo` when no agent is reachable — not yet exercised from an actual bare + TTY; that scenario is pending the disposable-VM verification pass in + `docs/P6-UPDATE-HELPER.md`. Channel and backup retention are configured in + `~/.config/lyona/update.conf`, seeded on first use and never overwritten. + The privileged step re-verifies the release tarball's checksum immediately + before use and then extracts, rebuilds, and installs from a scratch + directory the invoking user never has write access to, rather than running + a Makefile from a directory that was still writable by that user at the + moment root acted on it; `rollback`'s restore likewise validates every + backup archive member's path, type, and mode before extracting — refusing + anything outside the managed install locations, any non-regular member + (symlink, hardlink, device, FIFO, socket), and any setuid, setgid, or + sticky bit — rather than trusting GNU tar's own default root-extraction + behavior against a directory the invoking user could have replaced. + - Persist workspace, volume, Bluetooth, network, and power panel visibility in one versioned user-owned state file shared by every monitor, Control Center, and Settings. An absent file migrates from the prior implicit all-on state; diff --git a/Makefile b/Makefile index fe40926..a53012d 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ INSTALL_COMMANDS = \ scripts/active-audio \ scripts/dwm-accessibility-settings \ scripts/check-deps.sh \ + scripts/dev-sync-install.sh \ scripts/disable-powersaving \ scripts/dwm-controlcenter \ scripts/dwm-default-apps \ @@ -81,6 +82,7 @@ INSTALL_COMMANDS = \ scripts/lyona-console-theme \ scripts/lyona-grub-theme \ scripts/lyona-plymouth-theme \ + scripts/lyona-update \ scripts/lyona-version \ scripts/nvidia-gpu \ scripts/nvidia-suspend-test.sh \ @@ -94,9 +96,10 @@ INSTALL_COMMANDS = \ scripts/xdg-enable-autostart.sh \ scripts/xscreensaver-setup.sh INSTALL_COMMAND_NAMES = $(notdir ${INSTALL_COMMANDS}) -PRIVILEGED_HELPERS = scripts/dwm-settings-display-root +PRIVILEGED_HELPERS = scripts/dwm-settings-display-root scripts/lyona-update-root PRIVILEGED_HELPER_DIR = ${PREFIX}/libexec/lyona -POLKIT_ACTIONS = config/polkit/com.lyona.settings-display.policy +POLKIT_ACTIONS = config/polkit/com.lyona.settings-display.policy \ + config/polkit/com.lyona.update.policy # polkit does not search PREFIX-relative paths; this is a fixed system path # regardless of PREFIX. POLKIT_ACTIONS_DIR = /usr/share/polkit-1/actions @@ -207,7 +210,9 @@ install-system: done @echo "==> Installing privileged helpers..." for f in ${PRIVILEGED_HELPERS}; do \ - sed "s|@PREFIX@|${PREFIX}|g" "$$f" | \ + sed -e "s|@PREFIX@|${PREFIX}|g" -e "s|@MANPREFIX@|${MANPREFIX}|g" \ + -e "s|@DATADIR@|${DATADIR}|g" -e "s|@XSESSIONSDIR@|${XSESSIONSDIR}|g" \ + "$$f" | \ install -Dm755 /dev/stdin ${DESTDIR}${PRIVILEGED_HELPER_DIR}/$$(basename "$$f"); \ done @echo "==> Installing polkit actions..." @@ -397,7 +402,9 @@ uninstall: for name in ${INSTALL_COMMAND_NAMES}; do \ rm -f ${DESTDIR}${PREFIX}/bin/$$name; \ done - rm -f ${DESTDIR}${PRIVILEGED_HELPER_DIR}/dwm-settings-display-root + for name in $(notdir ${PRIVILEGED_HELPERS}); do \ + rm -f ${DESTDIR}${PRIVILEGED_HELPER_DIR}/$$name; \ + done for name in $(notdir ${POLKIT_ACTIONS}); do \ rm -f ${DESTDIR}${POLKIT_ACTIONS_DIR}/$$name; \ done @@ -420,10 +427,10 @@ release: dwm echo "==> Created ${RELEASE_ARCHIVE}" check-shell: - shellcheck install.sh scripts/dwm-accessibility-settings scripts/lyona-gtk-theme scripts/lyona-console-theme scripts/lyona-grub-theme scripts/lyona-plymouth-theme scripts/dwm-settings-toolkit scripts/dwm-session-launch scripts/dwm-default-apps scripts/dwm-diagnostics scripts/dwm-display-profile scripts/dwm-display-setup scripts/dwm-lock scripts/dwm-lock-watch scripts/dwm-keybinds scripts/dwm-panel-settings scripts/dwm-quickshell-launcher scripts/webapp-launch scripts/dwm-quickshell-controls scripts/dwm-quickshell-controlcenter scripts/dwm-quickshell-network scripts/dwm-quickshell-pointer scripts/dwm-quickshell-state scripts/dwm-quickshell-version-check scripts/dwm-settings scripts/dwm-settings-appearance scripts/dwm-settings-font scripts/dwm-settings-wallpaper scripts/dwm-settings-theme scripts/dwm-settings-provider scripts/dwm-status scripts/dwm-system-health scripts/dwm-terminal scripts/dwm-xdg-autostart scripts/install-herdr scripts/install-mybash scripts/lyona-cachyos scripts/lyona-version scripts/quickshell-qmllint scripts/run-tests scripts/*.sh tests/*.sh + shellcheck install.sh scripts/dwm-accessibility-settings scripts/lyona-gtk-theme scripts/lyona-console-theme scripts/lyona-grub-theme scripts/lyona-plymouth-theme scripts/dwm-settings-toolkit scripts/dwm-session-launch scripts/dwm-default-apps scripts/dwm-diagnostics scripts/dwm-display-profile scripts/dwm-display-setup scripts/dwm-lock scripts/dwm-lock-watch scripts/dwm-keybinds scripts/dwm-panel-settings scripts/dwm-quickshell-launcher scripts/webapp-launch scripts/dwm-quickshell-controls scripts/dwm-quickshell-controlcenter scripts/dwm-quickshell-network scripts/dwm-quickshell-pointer scripts/dwm-quickshell-state scripts/dwm-quickshell-version-check scripts/dwm-settings scripts/dwm-settings-appearance scripts/dwm-settings-font scripts/dwm-settings-wallpaper scripts/dwm-settings-theme scripts/dwm-settings-provider scripts/dwm-status scripts/dwm-system-health scripts/dwm-terminal scripts/dwm-xdg-autostart scripts/install-herdr scripts/install-mybash scripts/lyona-cachyos scripts/lyona-update scripts/lyona-update-root scripts/lyona-version scripts/quickshell-qmllint scripts/run-tests scripts/*.sh tests/*.sh check-format: - shfmt -d install.sh scripts/dwm-accessibility-settings scripts/lyona-gtk-theme scripts/lyona-console-theme scripts/lyona-grub-theme scripts/lyona-plymouth-theme scripts/dwm-settings-toolkit scripts/dwm-session-launch scripts/dwm-default-apps scripts/dwm-diagnostics scripts/dwm-display-profile scripts/dwm-display-setup scripts/dwm-lock scripts/dwm-lock-watch scripts/dwm-keybinds scripts/dwm-panel-settings scripts/dwm-quickshell-launcher scripts/webapp-launch scripts/dwm-quickshell-controls scripts/dwm-quickshell-controlcenter scripts/dwm-quickshell-network scripts/dwm-quickshell-pointer scripts/dwm-quickshell-state scripts/dwm-quickshell-version-check scripts/dwm-settings scripts/dwm-settings-appearance scripts/dwm-settings-font scripts/dwm-settings-wallpaper scripts/dwm-settings-theme scripts/dwm-settings-provider scripts/dwm-status scripts/dwm-system-health scripts/dwm-terminal scripts/dwm-xdg-autostart scripts/install-herdr scripts/install-mybash scripts/lyona-cachyos scripts/lyona-version scripts/quickshell-qmllint scripts/run-tests scripts/*.sh tests/*.sh + shfmt -d install.sh scripts/dwm-accessibility-settings scripts/lyona-gtk-theme scripts/lyona-console-theme scripts/lyona-grub-theme scripts/lyona-plymouth-theme scripts/dwm-settings-toolkit scripts/dwm-session-launch scripts/dwm-default-apps scripts/dwm-diagnostics scripts/dwm-display-profile scripts/dwm-display-setup scripts/dwm-lock scripts/dwm-lock-watch scripts/dwm-keybinds scripts/dwm-panel-settings scripts/dwm-quickshell-launcher scripts/webapp-launch scripts/dwm-quickshell-controls scripts/dwm-quickshell-controlcenter scripts/dwm-quickshell-network scripts/dwm-quickshell-pointer scripts/dwm-quickshell-state scripts/dwm-quickshell-version-check scripts/dwm-settings scripts/dwm-settings-appearance scripts/dwm-settings-font scripts/dwm-settings-wallpaper scripts/dwm-settings-theme scripts/dwm-settings-provider scripts/dwm-status scripts/dwm-system-health scripts/dwm-terminal scripts/dwm-xdg-autostart scripts/install-herdr scripts/install-mybash scripts/lyona-cachyos scripts/lyona-update scripts/lyona-update-root scripts/lyona-version scripts/quickshell-qmllint scripts/run-tests scripts/*.sh tests/*.sh check-session-guards: tests/test-autostart.sh @@ -632,13 +639,15 @@ check-install-manifest: all printf '%s\n' \ pre-existing \ usr/bin/dwm \ - usr/libexec/lyona/dwm-settings-display-root \ usr/share/man/man1/dwm.1 \ usr/share/xsessions/dwm.desktop \ etc/lyona-release; \ for name in ${INSTALL_COMMAND_NAMES}; do \ printf 'usr/bin/%s\n' "$$name"; \ done; \ + for name in $(notdir ${PRIVILEGED_HELPERS}); do \ + printf 'usr/libexec/lyona/%s\n' "$$name"; \ + done; \ find "assets/cursors/${CAPITAINE_DARK_THEME}" \ \( -type f -o -type l \) \ -printf 'usr/share/icons/${CAPITAINE_DARK_THEME}/%P\n'; \ @@ -661,9 +670,13 @@ check-install-manifest: all for name in dwm ${INSTALL_COMMAND_NAMES}; do \ test -x "$$stage/usr/bin/$$name"; \ done; \ - test -x "$$stage/usr/libexec/lyona/dwm-settings-display-root"; \ + for name in $(notdir ${PRIVILEGED_HELPERS}); do \ + test -x "$$stage/usr/libexec/lyona/$$name"; \ + done; \ grep -Fq 'org.freedesktop.policykit.exec.path">/usr/libexec/lyona/dwm-settings-display-root' \ "$$stage/usr/share/polkit-1/actions/com.lyona.settings-display.policy"; \ + grep -Fq 'org.freedesktop.policykit.exec.path">/usr/libexec/lyona/lyona-update-root' \ + "$$stage/usr/share/polkit-1/actions/com.lyona.update.policy"; \ grep -Fqx 'Exec=/usr/bin/dwm' \ "$$stage/usr/share/xsessions/dwm.desktop"; \ test -f "$$stage/usr/share/icons/${CAPITAINE_DARK_THEME}/cursors/default"; \ @@ -681,6 +694,9 @@ check-install-preservation: check-lyona-version: tests/test-lyona-version.sh +check-lyona-update: + tests/test-lyona-update.sh + check-test-runner: @$(call run_managed_test,tests/test-run-tests.sh) @@ -773,6 +789,7 @@ check: $(MAKE) check-install $(MAKE) check-install-preservation $(MAKE) check-lyona-version + $(MAKE) check-lyona-update $(MAKE) check-test-runner $(MAKE) check-lightdm-config $(MAKE) release-check @@ -780,7 +797,7 @@ check: .PHONY: clean all check check-accessibility check-appearance check-build-config check-build-deps check-default-apps check-xdg-autostart check-dev-sync-install \ check-test-runner \ check-display-profile check-display-setup check-archiso check-arch-packages check-arch-platform check-format check-install \ - check-gearlever-install check-herdr-install check-mybash-install check-install-manifest check-install-preservation check-lyona-version check-lock \ + check-gearlever-install check-herdr-install check-mybash-install check-install-manifest check-install-preservation check-lyona-version check-lyona-update check-lock \ check-session-guards check-session-migration check-webapp-launch check-screenshot check-release-helper check-shell check-diagnostics check-status check-test-lib check-shell-contracts check-gtk-theme check-plymouth-theme check-grub-theme check-session-launch check-dwm-roundtrips check-system-health check-settings \ check-quickshell-launcher check-quickshell-controls check-quickshell-audio check-quickshell-controlcenter check-quickshell-power check-quickshell-power-backend check-quickshell-power-model check-quickshell-session-actions check-quickshell-defaults-model check-quickshell-appearance-model check-quickshell-design-system check-quickshell-large-surfaces check-quickshell-large-surfaces-xvfb check-quickshell-panel-menus check-quickshell-panel-settings check-quickshell-command-menu check-quickshell-notifications check-quickshell-tray check-quickshell-health-xvfb check-quickshell-settings-xvfb check-quickshell-network check-quickshell-connectivity check-quickshell-qml check-lightdm-config check-terminal check-xvfb-runtime install install-system install-user \ install-cursors install-grub-theme install-gtk-themes stamp-system stamp-user native release release-check uninstall diff --git a/TASKS.md b/TASKS.md index 95b84c5..fe34089 100644 --- a/TASKS.md +++ b/TASKS.md @@ -72,35 +72,84 @@ Acceptance: ### UPDATE-002: `lyona-update` Helper -- [ ] Ship `lyona-update check|apply|rollback`, staging to - `$XDG_STATE_HOME/lyona/updates//`, verifying a signed release - tarball's SHA-256 against the GitHub release asset digest before unpacking, - and never swapping the live tree in place (`Commands.helperCommand` - resolves helpers from the data dir of a *running* session). -- [ ] Reuse `scripts/dev-sync-install.sh`'s existing backup/verify machinery +- [x] Ship `lyona-update check|apply|rollback|backups`, staging to + `$XDG_STATE_HOME/lyona/updates//`, verifying a release tarball's + SHA-256 against the GitHub release asset digest before unpacking, and never + swapping the live tree in place. — **Met**, `scripts/lyona-update`, + `tests/test-lyona-update.sh`. +- [x] Reuse `scripts/dev-sync-install.sh`'s existing backup/verify machinery (`backup_live_install()`, `verify_install()`, `verify_tree()`, `runtime_verify()`) rather than rebuilding it, and add the missing restore path so `rollback` actually reads a backup back — provably from a bare TTY - with no desktop running, per `docs/P6-UPDATE-HELPER.md`'s acceptance. -- [ ] One confirmed privileged step (the existing `${PREFIX}/libexec/lyona` + - `dwm-polkit` pattern) for `make install-system` alone; everything else — - check, download, verify, build, stage — runs unprivileged. Declining leaves - a staged, verified, uninstalled update and a non-zero exit, never a - half-applied system. -- [ ] `check`/`apply` support a channel (`stable`/`preview`) recorded in - `~/.config/lyona/update.conf`, seeded but never overwritten. + with no desktop running (falls back from `pkexec` to `sudo` when no + graphical session/agent is reachable). — **Met**: a + `DEV_SYNC_INSTALL_LIB_ONLY`/`DEV_SYNC_INSTALL_REPO_DIR` sourcing guard added + to `dev-sync-install.sh` (its own direct-invocation behavior unchanged, + `tests/test-dev-sync-install.sh`); `scripts/lyona-update-root`'s + `restore-system` verb accepts either `PKEXEC_UID` or `SUDO_UID`. The + power-loss-mid-install and bare-TTY scenarios themselves need the + disposable-VM pass in `docs/P6-UPDATE-HELPER.md`'s Verification section — + no root is available to exercise them in the automated suite. +- [x] One confirmed privileged step (the existing `${PREFIX}/libexec/lyona` + polkit pattern) for `make install-system` alone; everything else — check, + download, verify, build, stage — runs unprivileged. Declining leaves a + staged, verified, uninstalled update and a non-zero exit, never a + half-applied system. — **Met**, `scripts/lyona-update-root`, + `config/polkit/com.lyona.update.policy`; the "declined" path is exercised + in `tests/test-lyona-update.sh` (no trusted root-owned helper exists in the + unprivileged test sandbox, which is itself the natural "unavailable" case). + Hardened after review: the privileged helper originally ran `make -C + install-system` against a directory the invoking user could + still write to at that point — a Makefile/`config.mk` executes arbitrary + shell during GNU Make's own variable expansion (`$(shell ...)`), not only + through the recipe someone thinks they're invoking, so this was arbitrary + root code execution behind an "Install a lyona update" auth prompt. Fixed + by having `install-system release` re-verify the tarball's SHA-256 + immediately before use, then extract, rebuild, and install from a fresh + root-owned-only scratch directory the invoking user has never had write + access to (closing the verify-then-mutate window down to nothing, and + ensuring the binary every user on the machine runs is one root itself + built from verified source, not a copy the invoking user could have + swapped after their own unprivileged build finished). `restore-system` had + the same shape (`tar -xpf` onto `/` from a manifest and checksum both + living in the same user-writable backup directory) and now validates every + archive member — path, type, and mode — before extracting: the path must + fall under a fixed set of managed locations (no `..` or absolute escape), + the type must be a regular file or directory (never a symlink, hardlink, + device, FIFO, or socket, any of which GNU tar preserves and creates by + default when run as root), and the mode must carry no setuid, setgid, or + sticky bit (a setuid-root `dwm` is a root shell for every user on the + machine, since dwm can spawn arbitrary configured commands). Verified + against a small harness covering a legitimate backup plus each rejected + shape (symlink, hardlink, setuid, FIFO, a nested path under a directory + that should only ever be flat, and a path outside every managed prefix) — + the legitimate case is accepted and every hostile shape is refused with a + specific reason. `--from-checkout` (`install-system checkout`) is + unaffected — it carries the same trust level as running `sudo make + install-system` directly from a developer's own checkout, not a weaker one + introduced by going through `lyona-update`. +- [x] `check`/`apply` support a channel (`stable`/`preview`) recorded in + `~/.config/lyona/update.conf`, seeded but never overwritten. — **Met**, + `tests/test-lyona-update.sh`. Acceptance: - An interrupted `apply` leaves a mixed tree recoverable by `rollback`, never a silent claim of success — the provenance stamp from UPDATE-001 is written - last, after `rollback` re-verifies. + last, after `rollback` re-verifies. — Ordering is correct by construction + (backup before any write, stamp last, per the nine-step sequence in + `docs/P6-UPDATE-HELPER.md`); the actual power-loss/recovery run needs the + disposable-VM pass, not covered by the unprivileged automated suite. - A downgrade or offline `check` degrades explicitly (`apply --file PATH`, - `--allow-downgrade`) rather than failing unhelpfully. + `--allow-downgrade`) rather than failing unhelpfully. — **Met**, + `tests/test-lyona-update.sh`. - Preservation carries over unweakened: everything `tests/test-install-preservation.sh` already guards (`config.h`, `~/.config/lyona/*.toml`, symlinked config directories, settings-helper-owned files) survives an update the same way - it survives a fresh install. + it survives a fresh install. — The preservation machinery itself is reused + unmodified (`make install-user`, `dev-sync-install.sh`'s verify functions); + a full `apply`-driven end-to-end preservation run requires real privilege + and is part of the disposable-VM pass, not the automated suite. ### UPDATE-003: Settings and Control Center Surfaces diff --git a/config/polkit/com.lyona.update.policy b/config/polkit/com.lyona.update.policy new file mode 100644 index 0000000..e3ca196 --- /dev/null +++ b/config/polkit/com.lyona.update.policy @@ -0,0 +1,16 @@ + + + + + Install or roll back a lyona system update + Authentication is required to install or roll back a lyona system update + system-software-update + + no + no + auth_admin + + @PREFIX@/libexec/lyona/lyona-update-root + + diff --git a/docs/P6-UPDATE-HELPER.md b/docs/P6-UPDATE-HELPER.md index b798f21..d8c5f37 100644 --- a/docs/P6-UPDATE-HELPER.md +++ b/docs/P6-UPDATE-HELPER.md @@ -1,8 +1,9 @@ # UPDATE-002 — `lyona-update` Second boundary of the Phase 6 update path. Architecture and rationale: -[`P6-UPDATE-OVERVIEW.md`](P6-UPDATE-OVERVIEW.md). Depends on -[`UPDATE-001`](P6-UPDATE-PROVENANCE.md) for the installed-version record. +[`P6-UPDATE-OVERVIEW.md`](P6-UPDATE-OVERVIEW.md). Depends on UPDATE-001 for +the installed-version record — **done**, see `CHANGELOG.md` and `TASKS.md`'s +`UPDATE-001`; its own planning document was removed once implemented. ## Context diff --git a/docs/P6-UPDATE-OVERVIEW.md b/docs/P6-UPDATE-OVERVIEW.md index f7b0c23..f654de1 100644 --- a/docs/P6-UPDATE-OVERVIEW.md +++ b/docs/P6-UPDATE-OVERVIEW.md @@ -8,15 +8,21 @@ Detail lives in three companion plans: | Boundary | Document | Delivers | | --- | --- | --- | -| UPDATE-001 | [`P6-UPDATE-PROVENANCE.md`](P6-UPDATE-PROVENANCE.md) | An installed system that can state what it is running | +| UPDATE-001 | **Done** — see `CHANGELOG.md`, `TASKS.md` | An installed system that can state what it is running | | UPDATE-002 | [`P6-UPDATE-HELPER.md`](P6-UPDATE-HELPER.md) | `lyona-update` — check, stage, apply, roll back | | UPDATE-003 | [`P6-UPDATE-SURFACE.md`](P6-UPDATE-SURFACE.md) | Settings and Control Center surfaces over that helper | -> **Sequencing.** `AGENTS.md` forbids beginning the next phase in a change -> scoped to complete the current one, and Phase 5 is still active -> (`ROADMAP.md:301`). These are plans; implementing UPDATE-001 must wait until -> Phase 5 closes. The ROADMAP/TASKS edits described below belong to the commit -> that opens Phase 6, not to this planning document. +`P6-UPDATE-PROVENANCE.md` (UPDATE-001's own planning document) has been +removed now that it is implemented — the record of what changed lives in +`CHANGELOG.md` and `TASKS.md` from here on, per this project's convention of +retiring a plan document once it is implemented (see `docs/UPSTREAM-SYNC.md` +for the same convention applied to the upstream-sync phase docs). + +> **Sequencing.** Phase 5 closed and UPDATE-002 landed (2026-09-08). UPDATE-003 +> is next; `AGENTS.md` still forbids beginning it in a change scoped to +> complete UPDATE-001, and it forbids beginning the separate upstream-ported +> system-management work (`docs/UPSTREAM-SYNC.md`) in a change scoped to +> complete UPDATE-001…003. --- diff --git a/docs/P6-UPDATE-PROVENANCE.md b/docs/P6-UPDATE-PROVENANCE.md deleted file mode 100644 index d631bf6..0000000 --- a/docs/P6-UPDATE-PROVENANCE.md +++ /dev/null @@ -1,244 +0,0 @@ -# UPDATE-001 — Install Provenance - -First boundary of the Phase 6 update path. Architecture and rationale: -[`P6-UPDATE-OVERVIEW.md`](P6-UPDATE-OVERVIEW.md). - -## Context - -An installed lyona system cannot state what it is running. - -- `/etc/lyona-iso-release` is written only into the live medium's airootfs - (`scripts/build-lyona-arch-iso.sh:341-349`). The target root is `pacstrap`ped - and `archiso/airootfs/root/lyona-postinstall.sh` never copies it across. -- `make install-system`, `make install-user` and `install.sh` write no marker. -- `dwm -v` reports the `VERSION` compiled in from `config.mk` (`dwm.c:5833`) — - the C binary only. Scripts, QML and config carry no version, so a partially - applied install looks identical to a clean one. - -Every later boundary depends on this: nothing can report "an update is -available" without knowing what is installed, and nothing can verify an update -succeeded without a record to compare against. - -This boundary is also worth landing on its own merit — it makes -`dwm-diagnostics` and bug reports far more useful than "latest, I think". - -## Deliverables - -1. A provenance record, written by both install paths. -2. `scripts/lyona-version` — a versioned reader, following the helper protocol - conventions already used across `scripts/`. -3. Convergence of the ISO and existing-system install paths so both produce the - same record. - ---- - -## 1. The record - -Two scopes, because the install has two scopes. - -### System: `/etc/lyona-release` - -Written by `make install-system` (root already, no new escalation). Mode `0644`. - -``` -LYONA_VERSION=2026.08.0-beta.1 -LYONA_COMMIT=b85539b8f2c1d4e6a90371fc2b5e8d47a1c09e33 -LYONA_SOURCE=iso -LYONA_PREFIX=/usr/local -LYONA_INSTALL_DATE=2026-08-29T14:03:11Z -``` - -`LYONA_SOURCE` is one of `iso`, `tarball`, `checkout`, or `unknown` — it tells -the update helper whether a git remote is available and whether the tree it -would replace is user-modified. - -`LYONA_COMMIT` is `unknown` when git is unavailable, which is the normal case on -an ISO install. The build stamps it instead: `build-lyona-arch-iso.sh:342` -already computes `build_commit` for `/etc/lyona-iso-release`, so the ISO path -can carry a real commit through to the target. - -### User: `$XDG_STATE_HOME/lyona/install.state` - -Written by `make install-user`. Mode `0600`. Same keys plus the user-scope -paths, so a per-user install on a shared machine is still self-describing: - -``` -LYONA_VERSION=2026.08.0-beta.1 -LYONA_COMMIT=b85539b8f2c1d4e6a90371fc2b5e8d47a1c09e33 -LYONA_SOURCE=iso -LYONA_DATA_DIR=/home/user/.local/share/lyona -LYONA_CONFIG_DIR=/home/user/.config -LYONA_SOURCE_TREE=/home/user/.local/share/lyona -LYONA_INSTALL_DATE=2026-08-29T14:03:11Z -``` - -`LYONA_SOURCE_TREE` records where the sources that produced this install live — -the data dir on an ISO install, the clone path otherwise, empty when the -sources are gone. UPDATE-002 uses it for `--from-checkout` discovery. - -### Why two files rather than one - -The system and user halves can genuinely diverge: `make install-system` can be -run by root for a machine while a second user has never run `install-user`. A -single file could not express that, and the mismatch is exactly what -`lyona-version status` should surface. - ---- - -## 2. `scripts/lyona-version` - -A read-only helper. No mutation, no privileges, safe to call from a panel. - -Follows the conventions already established by `dwm-settings-appearance`, -`dwm-panel-settings` and friends: a versioned protocol header, tab-separated -records, a terminating `complete` line, and safe degradation rather than -failure. - -``` -lyona-version status -``` - -``` -lyona-version-protocol 1 0 -state available Installed version recorded for system and user -system 2026.08.0-beta.1 b85539b iso /usr/local 2026-08-29T14:03:11Z -user 2026.08.0-beta.1 b85539b iso /home/user/.local/share/lyona 2026-08-29T14:03:11Z -binary 2026.08.0-beta.1 -consistent yes -complete status -``` - -`state` is one of: - -| State | Meaning | -| --- | --- | -| `available` | Both records present and readable | -| `partial` | One record present — e.g. system installed, this user never ran `install-user` | -| `defaults` | No record found; pre-provenance install, treat as unknown version | -| `unavailable` | A record exists but is unsafe (symlink, wrong owner, oversized) or malformed | - -`binary` is `dwm -v` output, parsed. `consistent` is `yes` only when the system -record, the user record and the binary all agree — this is what catches a -half-applied install, and what UPDATE-002 checks after applying an update. - -Reuse the existing safety idiom rather than writing a new one: the -`state_file_safe()` shape from the panel-settings helper -(`scripts/dwm-panel-settings`) — regular file, not a symlink, owned by -the reader, single hard link, size-capped, not group/other-writable. A missing -record is a normal `defaults` state, never an error; an unsafe one is reported, -never rewritten. - -Also add: - -``` -lyona-version status --json # for scripting; same data, JSON object -lyona-version print # single line: the effective installed version -``` - -`print` exists so `dwm-diagnostics` and shell prompts have a trivial call. - ---- - -## 3. Install-path convergence - -### `Makefile` - -Add a `stamp-system` step to `install-system` and `stamp-user` to -`install-user`, both after the files they describe are in place — an -interrupted install must not leave a stamp claiming success. - -Commit resolution, in order: `LYONA_COMMIT` from the environment (the ISO build -passes it), else `git -C . rev-parse HEAD`, else `unknown`. Source resolution: -`LYONA_SOURCE` from the environment, else `checkout` when `.git` is present, -else `tarball`. - -Add `scripts/lyona-version` to `INSTALL_COMMANDS`, and to the `check-shell` and -`check-format` explicit lists (it has no `.sh` suffix, so the `scripts/*.sh` -glob does not cover it). - -Add `/etc/lyona-release` removal to `uninstall`. - -### `archiso/airootfs/root/lyona-postinstall.sh` - -Pass the ISO's own provenance through to the target install so an ISO-installed -machine records a real commit rather than `unknown`. The values are already -computed for `/etc/lyona-iso-release`; read them from that file on the live -medium and export `LYONA_SOURCE=iso` plus `LYONA_COMMIT` across the `install.sh` -invocation at `lyona-postinstall.sh:243-245`. - -Also copy `/etc/lyona-iso-release` itself to the target as -`/etc/lyona-iso-release` — it records which *image* built the machine, which -stays true after later updates and is useful for support even once -`/etc/lyona-release` has moved on. - -### `install.sh` - -No behaviour change needed — it delegates to the Makefile targets, which now -stamp. Add the resulting version to the completion summary -(`print_install_summary` neighbourhood) so the installer's last line states what -was installed. - ---- - -## Tests - -### New: `tests/test-lyona-version.sh` - -Following `tests/lib.sh` conventions (`make_workspace`, `assert_line`, -`assert_contains`, `fail`): - -| Case | Assertion | -| --- | --- | -| No records | `state defaults`, exit 0, no file created | -| Both records, agreeing | `state available`, `consistent yes` | -| System only | `state partial`, names the missing user record | -| Version mismatch system vs user | `consistent no`, both values reported | -| Binary mismatch | `consistent no` — the half-applied-install case | -| Malformed record | `state unavailable`, file preserved byte-for-byte | -| Symlinked record | `state unavailable`, link target never read or written | -| World-writable record | `state unavailable` | -| `print` with no records | prints `unknown`, exit 0 | -| `--json` | valid JSON, same values as the tab-separated form | - -### Extend: `tests/test-install-preservation.sh` - -- A stamp is written by `install-system` and `install-user`. -- A **failed** install writes no stamp (stage the failure by making a target - path unwritable). -- Re-running an install over an existing stamp replaces it rather than - appending. - -### Extend: `tests/test-arch-iso-builder.sh` - -It already asserts the staged `/etc/lyona-iso-release` -(`test-arch-iso-builder.sh:644-676`). Add: the postinstall script exports -`LYONA_SOURCE=iso` and a commit into the `install.sh` invocation. - -### `Makefile` - -`check-lyona-version:` target running the new test; added to `check:` and -`.PHONY`. - ---- - -## Verification - -```bash -scripts/run-tests make check-lyona-version -scripts/run-tests make check-install-preservation check-arch-iso-builder -scripts/run-tests make check-shell check-format -scripts/run-tests make check -``` - -End to end, on a disposable Arch VM (per `docs/RELEASING.md`, not `/tmp`): - -1. Fresh existing-system install → `/etc/lyona-release` and - `~/.local/state/lyona/install.state` both exist, agree, and match `dwm -v`. - `lyona-version status` reports `available` / `consistent yes`. -2. `lyona-version print` returns the `config.mk` version. -3. Simulate a half-applied install — reinstall only `install-user` from a tree - with a bumped `config.mk` — and confirm `consistent no` naming both values. -4. On a machine installed from the ISO, confirm `LYONA_SOURCE=iso` and a real - commit hash, not `unknown`. -5. Confirm a pre-provenance machine (delete both records) reports `defaults` - and still exits 0 — upgrading *into* provenance must not error. diff --git a/docs/P6-UPDATE-SURFACE.md b/docs/P6-UPDATE-SURFACE.md index 6467338..7d80077 100644 --- a/docs/P6-UPDATE-SURFACE.md +++ b/docs/P6-UPDATE-SURFACE.md @@ -1,9 +1,9 @@ # UPDATE-003 — Update Surfaces in Settings and Control Center Third boundary of the Phase 6 update path. Architecture and rationale: -[`P6-UPDATE-OVERVIEW.md`](P6-UPDATE-OVERVIEW.md). Depends on -[`UPDATE-001`](P6-UPDATE-PROVENANCE.md) and -[`UPDATE-002`](P6-UPDATE-HELPER.md). +[`P6-UPDATE-OVERVIEW.md`](P6-UPDATE-OVERVIEW.md). Depends on UPDATE-001 +(**done**, see `CHANGELOG.md` and `TASKS.md` — its own planning document was +removed once implemented) and [`UPDATE-002`](P6-UPDATE-HELPER.md). ## Context diff --git a/docs/UPSTREAM-SYNC.md b/docs/UPSTREAM-SYNC.md index 0ca5f5f..93cf2e6 100644 --- a/docs/UPSTREAM-SYNC.md +++ b/docs/UPSTREAM-SYNC.md @@ -321,14 +321,16 @@ their own boundary group: | Prerequisite | Document | Delivers | Status | | --- | --- | --- | --- | -| UPDATE-001 | [`P6-UPDATE-PROVENANCE.md`](P6-UPDATE-PROVENANCE.md) | An installed system that can state what it is running | Designed, not started | +| UPDATE-001 | **Done** — see `CHANGELOG.md`, `TASKS.md` | An installed system that can state what it is running | Done (2026-09-08) | | UPDATE-002 | [`P6-UPDATE-HELPER.md`](P6-UPDATE-HELPER.md) | `lyona-update` — check, stage, apply, roll back | Designed, not started | | UPDATE-003 | [`P6-UPDATE-SURFACE.md`](P6-UPDATE-SURFACE.md) | Settings and Control Center surfaces over that helper | Designed, not started | (`P6-UPDATE-OVERVIEW.md` indexes all three plus the architecture decisions -behind them; it is not its own boundary.) None of the three is implemented — -the currently active `TASKS.md` phase is still Phase 5, and `P6-UPDATE-OVERVIEW.md` -itself says implementation must wait until Phase 5 closes. +behind them; it is not its own boundary.) UPDATE-001 and UPDATE-002 are implemented — the +currently active `TASKS.md` phase is Phase 6 (System Management), and +`P6-UPDATE-PROVENANCE.md` has been removed now that it is done, per this +project's plan-doc-retirement convention. UPDATE-002 and UPDATE-003 remain +not started. **The two efforts meet at exactly one point**, and it is worth naming precisely rather than leaving as a vague "later": `P6-UPDATE-SURFACE.md`'s diff --git a/scripts/dev-sync-install.sh b/scripts/dev-sync-install.sh index 107d63e..2059020 100755 --- a/scripts/dev-sync-install.sh +++ b/scripts/dev-sync-install.sh @@ -30,10 +30,17 @@ Path overrides use the same variables as the Makefile: EOF } -die() { - printf '%s: %s\n' "$program" "$*" >&2 - exit 1 -} +# A caller sourcing this file in DEV_SYNC_INSTALL_LIB_ONLY mode has almost +# certainly already defined its own die() (dwm-paths.sh's helpers require +# one); defining a second one here would silently replace theirs for the +# rest of their process, misattributing every later error message to this +# file's own $program instead of the sourcing script's. +if ! command -v die >/dev/null 2>&1; then + die() { + printf '%s: %s\n' "$program" "$*" >&2 + exit 1 + } +fi note() { printf '==> %s\n' "$*" @@ -52,27 +59,45 @@ validate_live_root() { fi } -repo_dir=$( - unset CDPATH - cd -- "$(dirname -- "$0")/.." && pwd -) +# DEV_SYNC_INSTALL_REPO_DIR lets a sourcing caller (lyona-update, staging an +# unpacked release rather than this script's own checkout) point every path +# and function below at a different tree. $0 is meaningless once sourced -- +# it names the sourcing script, not this file -- so lib-only mode requires +# the override explicitly rather than silently deriving a wrong path from it. +if [ "${DEV_SYNC_INSTALL_LIB_ONLY:-0}" = 1 ]; then + [ -n "${DEV_SYNC_INSTALL_REPO_DIR:-}" ] || + die "DEV_SYNC_INSTALL_REPO_DIR is required when sourcing this file" + repo_dir=$DEV_SYNC_INSTALL_REPO_DIR +else + repo_dir=${DEV_SYNC_INSTALL_REPO_DIR:-$( + unset CDPATH + cd -- "$(dirname -- "$0")/.." && pwd + )} +fi +# DEV_SYNC_INSTALL_LIB_ONLY=1: a caller sources this file for its functions +# and path variables (backup_live_install, verify_install, runtime_verify, +# prepare_expected_files) without running this script's own CLI or build/ +# install/backup sequence. Skip argument parsing entirely in that mode -- +# $@ belongs to the sourcing script, not to us. check_only=0 -while [ "$#" -gt 0 ]; do - case $1 in - --check) - check_only=1 - ;; - -h | --help) - usage - exit 0 - ;; - *) - die "unknown option: $1" - ;; - esac - shift -done +if [ "${DEV_SYNC_INSTALL_LIB_ONLY:-0}" != 1 ]; then + while [ "$#" -gt 0 ]; do + case $1 in + --check) + check_only=1 + ;; + -h | --help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac + shift + done +fi if [ -n "${DESTDIR:-}" ]; then die "DESTDIR is not supported; this command targets a live installation" @@ -93,7 +118,7 @@ quickshell_dir=$config_home/quickshell binary_target=$prefix/bin/dwm man_target=$manprefix/man1/dwm.1 xsession_target=$xsessions_dir/dwm.desktop -display_root_helper_target=$prefix/libexec/lyona/dwm-settings-display-root +privileged_helper_dir=$prefix/libexec/lyona make_command=${MAKE:-make} validate_live_root USER_HOME "$user_home" @@ -116,7 +141,8 @@ trap 'rm -rf "$work"' EXIT HUP INT TERM install_sources_file=$work/install-sources expected_man=$work/dwm.1 expected_xsession=$work/dwm.desktop -expected_display_root_helper=$work/dwm-settings-display-root +privileged_helpers_file=$work/privileged-helpers +expected_privileged_dir=$work/privileged tree_diff=$work/tree.diff prepare_expected_files() { @@ -127,12 +153,24 @@ prepare_expected_files() { [ -s "$install_sources_file" ] || die "Makefile did not report any installed commands" + # shellcheck disable=SC2016 + "$make_path" -s -C "$repo_dir" --no-print-directory \ + --eval='dwm-dev-print-privileged-helpers: ; @printf "%s\n" $(PRIVILEGED_HELPERS)' \ + dwm-dev-print-privileged-helpers >"$privileged_helpers_file" + [ -s "$privileged_helpers_file" ] || + die "Makefile did not report any privileged helpers" + version=$(awk '$1 == "VERSION" && $2 == "=" { print $3; exit }' "$repo_dir/config.mk") [ -n "$version" ] || die "could not read VERSION from config.mk" sed "s/VERSION/$version/g" "$repo_dir/dwm.1" >"$expected_man" sed "s|@PREFIX@|$prefix|g" "$repo_dir/dwm.desktop" >"$expected_xsession" - sed "s|@PREFIX@|$prefix|g" "$repo_dir/scripts/dwm-settings-display-root" \ - >"$expected_display_root_helper" + + mkdir -p "$expected_privileged_dir" + while IFS= read -r privileged_helper; do + [ -n "$privileged_helper" ] || continue + sed "s|@PREFIX@|$prefix|g" "$repo_dir/$privileged_helper" \ + >"$expected_privileged_dir/${privileged_helper##*/}" + done <"$privileged_helpers_file" } verification_failed=0 @@ -196,20 +234,25 @@ verify_install() { verify_executable "$repo_dir/$install_source" "$prefix/bin/$install_name" \ "installed command $install_name" done <"$install_sources_file" - verify_executable "$expected_display_root_helper" \ - "$display_root_helper_target" "privileged display helper" verify_privileged_helper_trust=1 if [ "${DWM_DEV_SYNC_SKIP_PRIVILEGED_TRUST:-0}" = 1 ]; then verify_privileged_helper_trust=0 fi - if [ "$verify_privileged_helper_trust" -eq 1 ] && [ -e "$display_root_helper_target" ]; then - if [ "$(stat -c %u "$display_root_helper_target")" -ne 0 ] || - find "$display_root_helper_target" -maxdepth 0 -perm /022 -print -quit | grep -q .; then - printf 'UNTRUSTED: privileged display helper ownership or mode (%s)\n' \ - "$display_root_helper_target" >&2 - verification_failed=1 + while IFS= read -r privileged_helper; do + [ -n "$privileged_helper" ] || continue + privileged_helper_name=${privileged_helper##*/} + privileged_helper_target=$privileged_helper_dir/$privileged_helper_name + verify_executable "$expected_privileged_dir/$privileged_helper_name" \ + "$privileged_helper_target" "privileged helper $privileged_helper_name" + if [ "$verify_privileged_helper_trust" -eq 1 ] && [ -e "$privileged_helper_target" ]; then + if [ "$(stat -c %u "$privileged_helper_target")" -ne 0 ] || + find "$privileged_helper_target" -maxdepth 0 -perm /022 -print -quit | grep -q .; then + printf 'UNTRUSTED: privileged helper ownership or mode: %s (%s)\n' \ + "$privileged_helper_name" "$privileged_helper_target" >&2 + verification_failed=1 + fi fi - fi + done <"$privileged_helpers_file" verify_file "$expected_man" "$man_target" "dwm man page" verify_file "$expected_xsession" "$xsession_target" "dwm X session" @@ -271,7 +314,10 @@ backup_live_install() { [ -n "$install_source" ] || continue add_system_backup_path "$prefix/bin/${install_source##*/}" done <"$install_sources_file" - add_system_backup_path "$display_root_helper_target" + while IFS= read -r privileged_helper; do + [ -n "$privileged_helper" ] || continue + add_system_backup_path "$privileged_helper_dir/${privileged_helper##*/}" + done <"$privileged_helpers_file" for cursor_source in "$repo_dir"/assets/cursors/Capitaine-Cursors*; do [ -d "$cursor_source" ] || continue add_system_backup_path "$data_root/icons/${cursor_source##*/}" @@ -284,6 +330,7 @@ backup_live_install() { { printf 'commit=%s\n' "$(git -C "$repo_dir" rev-parse HEAD 2>/dev/null || printf unknown)" printf 'branch=%s\n' "$(git -C "$repo_dir" branch --show-current 2>/dev/null || printf unknown)" + printf 'version=%s\n' "$(awk '$1 == "VERSION" && $2 == "=" { print $3; exit }' "$repo_dir/config.mk")" printf 'prefix=%s\n' "$prefix" printf 'data_root=%s\n' "$data_root" printf 'config_home=%s\n' "$config_home" @@ -406,6 +453,14 @@ runtime_verify() { [ "$runtime_failed" -eq 0 ] } +if [ "${DEV_SYNC_INSTALL_LIB_ONLY:-0}" = 1 ]; then + # Every function and path variable above is now defined in the sourcing + # shell. Stop before this script's own build/verify/backup/install + # sequence -- the caller drives that with its own step ordering. + # shellcheck disable=SC2317 # exit is reachable when run directly, not sourced + return 0 2>/dev/null || exit 0 +fi + prepare_expected_files if [ "$check_only" -eq 1 ]; then diff --git a/scripts/lyona-update b/scripts/lyona-update new file mode 100755 index 0000000..01912fd --- /dev/null +++ b/scripts/lyona-update @@ -0,0 +1,774 @@ +#!/usr/bin/env bash +set -euo pipefail + +# shellcheck source=scripts/dwm-paths.sh +. "${BASH_SOURCE[0]%/*}/dwm-paths.sh" +export LC_ALL=C + +self=lyona-update +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +version_helper=${LYONA_UPDATE_VERSION_HELPER:-$script_dir/lyona-version} +root_helper_name=lyona-update-root +# An installed machine has no .git, so unlike lyona-release (which reads the +# remote from the local checkout via `gh`) this has to be a constant. Kept +# overridable only for the test suite, which stubs the GitHub API entirely. +github_repo=${LYONA_UPDATE_GITHUB_REPO:-technicks89/Lyona} +github_api_base=${LYONA_UPDATE_GITHUB_API:-https://api.github.com} +cache_ttl=${LYONA_UPDATE_CACHE_TTL:-300} + +warn() { printf '%s: %s\n' "$self" "$*" >&2; } +die() { + warn "$*" + exit 1 +} + +usage() { + cat <<'EOF' +Usage: lyona-update check [--channel stable|preview] [--json] + lyona-update apply [--version V] [--file PATH] [--from-checkout DIR] + [--allow-downgrade] [--dry-run] [--yes] + lyona-update rollback [--backup ID] [--list] [--yes] + lyona-update backups [--json] +EOF +} + +for required_command in awk curl jq mktemp sha256sum sort tar; do + command -v "$required_command" >/dev/null 2>&1 || + die "required command not found: $required_command" +done + +config_home=${XDG_CONFIG_HOME:-} +data_home=${XDG_DATA_HOME:-} +state_home=${XDG_STATE_HOME:-} +cache_home=${XDG_CACHE_HOME:-} +[[ $config_home == /* ]] || config_home=${HOME:?HOME is required for XDG_CONFIG_HOME fallback}/.config +[[ $data_home == /* ]] || data_home=${HOME:?HOME is required for XDG_DATA_HOME fallback}/.local/share +[[ $state_home == /* ]] || state_home=${HOME:?HOME is required for XDG_STATE_HOME fallback}/.local/state +[[ $cache_home == /* ]] || cache_home=${HOME:?HOME is required for XDG_CACHE_HOME fallback}/.cache + +config_file=$config_home/lyona/update.conf +cache_file=$cache_home/lyona/update-index.json +updates_root=$state_home/lyona/updates +backups_root=$state_home/lyona/live-update-backups + +# --- version comparison ----------------------------------------------- + +valid_version() { + local v=$1 + [[ $v =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?(-(alpha|beta|rc)\.[0-9]+)?$ ]] +} + +# Encodes YYYY.MM[.PATCH][-alpha|beta|rc.N] into a 13-digit zero-padded +# string that sorts correctly with a plain string comparison: a release +# outranks every pre-release of the same core version, and rc > beta > alpha. +version_rank() { + local v=$1 core=$1 suffix='' suffix_word='' suffix_num=0 srank=9 year month patch=0 + valid_version "$v" || return 1 + if [[ $v == *-* ]]; then + core=${v%%-*} + suffix=${v#*-} + suffix_word=${suffix%%.*} + suffix_num=${suffix#*.} + case $suffix_word in + alpha) srank=1 ;; + beta) srank=2 ;; + rc) srank=3 ;; + esac + fi + IFS=. read -r year month patch <<<"$core" + patch=${patch:-0} + # 10# forces base-10: bash's printf %d otherwise reads a leading-zero + # field like month "08" as invalid octal and silently substitutes 0. + printf '%04d%02d%03d%d%03d\n' \ + "$((10#$year))" "$((10#$month))" "$((10#$patch))" "$srank" "$((10#$suffix_num))" +} + +# --- configuration ------------------------------------------------------- + +channel=stable +check_on_login=true +auto_apply=false +keep_backups=5 + +seed_config() { + [[ -e $config_file ]] && return 0 + printf 'channel=stable\ncheck_on_login=true\nauto_apply=false\nkeep_backups=5\n' | + install -Dm600 /dev/stdin "$config_file" +} + +read_config() { + seed_config + [[ -f $config_file && ! -L $config_file ]] || return 0 + local key value + while IFS='=' read -r key value || [[ -n $key ]]; do + # check_on_login is read for shape validation only -- consumed by + # the login-time check this boundary doesn't implement yet + # (UPDATE-003). + # shellcheck disable=SC2034 + case $key in + channel) channel=$value ;; + check_on_login) check_on_login=$value ;; + auto_apply) auto_apply=$value ;; + keep_backups) keep_backups=$value ;; + esac + done <"$config_file" + # The key exists so the file shape is stable, but this boundary has no + # unattended-apply implementation -- a stray "true" must be refused + # loudly rather than silently doing nothing. + [[ $auto_apply == false ]] || die "auto_apply=true is not supported yet; set it back to false in $config_file" + [[ $keep_backups =~ ^[0-9]+$ ]] || die "keep_backups must be a non-negative integer in $config_file" +} + +# --- installed version ----------------------------------------------- + +installed_status_json= +installed_version= +installed_state= +installed_commit= +installed_source= + +read_installed() { + [[ -x $version_helper ]] || die "lyona-version helper is unavailable: $version_helper" + installed_status_json=$("$version_helper" status --json) + installed_version=$("$version_helper" print) + installed_state=$(jq -r '.state' <<<"$installed_status_json") + if jq -e '.user.state == "available"' <<<"$installed_status_json" >/dev/null; then + installed_commit=$(jq -r '.user.commit' <<<"$installed_status_json") + installed_source=$(jq -r '.user.source' <<<"$installed_status_json") + elif jq -e '.system.state == "available"' <<<"$installed_status_json" >/dev/null; then + installed_commit=$(jq -r '.system.commit' <<<"$installed_status_json") + installed_source=$(jq -r '.system.source' <<<"$installed_status_json") + else + installed_commit=unknown + installed_source=unknown + fi +} + +# --- GitHub release lookup -------------------------------------------- + +fetch_url() { + curl --fail --silent --show-error --location \ + --connect-timeout 5 --max-time 15 \ + -H 'Accept: application/vnd.github+json' \ + "$@" +} + +# Prints one release object (never an array) for the requested channel, or +# fails if the channel has nothing published or the network is unreachable. +fetch_release() { + local requested_channel=$1 + case $requested_channel in + stable) + fetch_url "$github_api_base/repos/$github_repo/releases/latest" + ;; + preview) + # Newest first, drafts already excluded for an unauthenticated + # request -- the first entry is the newest preview or stable build. + fetch_url "$github_api_base/repos/$github_repo/releases" | jq -c '.[0] // empty' + ;; + *) + die "unknown channel: $requested_channel (expected stable or preview)" + ;; + esac +} + +# Populates release_version / release_commit / release_published / release_asset_name / +# release_asset_url / release_asset_size / release_checksum. Returns 1 when the +# remote could not be reached or has nothing published for the channel -- +# both are reported as "offline", never as an error. +release_version= +release_commit=unknown +release_published=unknown +release_asset_name= +release_asset_url= +release_asset_size=unknown +release_checksum=unknown + +# The cache stores every resolved field, not just the raw release listing -- +# a cache hit must not still hit the network for the checksum and tag-commit +# lookups below, or the TTL would only ever save one of the three requests +# a panel indicator makes on every poll. +load_cache() { + local requested_channel=$1 age mtime now cached + [[ -f $cache_file && ! -L $cache_file ]] || return 1 + cached=$(cat "$cache_file" 2>/dev/null) || return 1 + [[ $(jq -r '.channel // empty' <<<"$cached") == "$requested_channel" ]] || return 1 + mtime=$(stat -c %Y -- "$cache_file" 2>/dev/null) || return 1 + now=$(date +%s) + age=$((now - mtime)) + ((age >= 0 && age < cache_ttl)) || return 1 + release_version=$(jq -r '.version // empty' <<<"$cached") + [[ -n $release_version ]] || return 1 + release_commit=$(jq -r '.commit // "unknown"' <<<"$cached") + release_published=$(jq -r '.publishedAt // "unknown"' <<<"$cached") + release_asset_name=$(jq -r '.assetName // empty' <<<"$cached") + release_asset_url=$(jq -r '.assetUrl // empty' <<<"$cached") + release_asset_size=$(jq -r '.assetSize // "unknown"' <<<"$cached") + release_checksum=$(jq -r '.checksum // "unknown"' <<<"$cached") + return 0 +} + +save_cache() { + local requested_channel=$1 tmp + ensure_owned_directory "$(dirname "$cache_file")" "cache" + tmp=$(mktemp "$(dirname "$cache_file")/.update-index.json.XXXXXX") + jq -cn \ + --arg channel "$requested_channel" \ + --arg version "$release_version" \ + --arg commit "$release_commit" \ + --arg publishedAt "$release_published" \ + --arg assetName "$release_asset_name" \ + --arg assetUrl "$release_asset_url" \ + --arg assetSize "$release_asset_size" \ + --arg checksum "$release_checksum" \ + '{channel: $channel, version: $version, commit: $commit, + publishedAt: $publishedAt, assetName: $assetName, assetUrl: $assetUrl, + assetSize: $assetSize, checksum: $checksum}' >"$tmp" + mv -fT "$tmp" "$cache_file" +} + +resolve_release() { + local requested_channel=$1 release_json tag_json + load_cache "$requested_channel" && return 0 + + if ! release_json=$(fetch_release "$requested_channel" 2>/dev/null) || + [[ -z $release_json || $release_json == null ]]; then + return 1 + fi + + release_version=$(jq -r '.tag_name // empty' <<<"$release_json") + release_version=${release_version#v} + [[ -n $release_version ]] || return 1 + release_published=$(jq -r '.published_at // "unknown"' <<<"$release_json") + + release_asset_name="lyona-$release_version.tar.gz" + release_asset_url=$(jq -r --arg name "$release_asset_name" \ + '.assets[]? | select(.name == $name) | .browser_download_url' <<<"$release_json") + release_asset_size=$(jq -r --arg name "$release_asset_name" \ + '.assets[]? | select(.name == $name) | .size' <<<"$release_json") + [[ -n $release_asset_size ]] || release_asset_size=unknown + + local checksums_name checksums_url + checksums_name="lyona-$release_version-SHA256SUMS" + checksums_url=$(jq -r --arg name "$checksums_name" \ + '.assets[]? | select(.name == $name) | .browser_download_url' <<<"$release_json") + if [[ -n $checksums_url ]]; then + release_checksum=$(fetch_url "$checksums_url" 2>/dev/null | + awk -v want="$release_asset_name" '$0 ~ want"$" { print $1; exit }') + [[ -n $release_checksum ]] || release_checksum=unknown + fi + + # Best-effort only: the release commit comes from resolving the tag's + # git ref, a second API call that a rate limit or a transient failure + # must not turn into a hard "check" failure. + tag_json=$(fetch_url "$github_api_base/repos/$github_repo/git/ref/tags/v$release_version" 2>/dev/null) || tag_json= + if [[ -n $tag_json ]]; then + release_commit=$(jq -r '.object.sha // "unknown"' <<<"$tag_json") + release_commit=${release_commit:0:7} + [[ -n $release_commit ]] || release_commit=unknown + fi + + save_cache "$requested_channel" 2>/dev/null || : + return 0 +} + +# --- check --------------------------------------------------------------- + +cmd_check() { + read_config + local requested_channel=$channel json=false + while (($# > 0)); do + case $1 in + --channel) + (($# >= 2)) || die "--channel requires a value" + requested_channel=$2 + shift 2 + ;; + --json) + json=true + shift + ;; + *) die "unknown option: $1" ;; + esac + done + case $requested_channel in + stable | preview) ;; + *) die "--channel must be stable or preview: $requested_channel" ;; + esac + + read_installed + + local state message='' reached=false + if [[ $installed_state == unavailable ]]; then + state=unavailable + message='The installed-version record is present but cannot be trusted.' + elif [[ $installed_state == defaults ]]; then + state=unknown + message='No install provenance record was found.' + elif ! resolve_release "$requested_channel"; then + state=offline + message='The update server could not be reached.' + else + reached=true + local installed_rank remote_rank + installed_rank=$(version_rank "$installed_version" 2>/dev/null || printf 0) + remote_rank=$(version_rank "$release_version" 2>/dev/null || printf 0) + if [[ $installed_source == checkout && $remote_rank -le $installed_rank ]]; then + state=ahead + message='Running a development checkout newer than the published release.' + elif [[ $remote_rank > $installed_rank ]]; then + state=behind + message="A newer $requested_channel release is available." + elif [[ $remote_rank < $installed_rank ]]; then + state=downgrade-offered + message="The $requested_channel channel offers an older release than what is installed." + else + state=current + message='The installed release matches the channel.' + fi + fi + + if [[ $json == true ]]; then + jq -cn \ + --arg state "$state" \ + --arg message "$message" \ + --arg installedVersion "$installed_version" \ + --arg installedCommit "$installed_commit" \ + --arg channel "$requested_channel" \ + --arg reached "$reached" \ + --arg availableVersion "${release_version:-}" \ + --arg availableCommit "${release_commit:-unknown}" \ + --arg availablePublished "${release_published:-unknown}" \ + --arg assetName "${release_asset_name:-}" \ + --arg assetChecksum "${release_checksum:-unknown}" \ + --arg assetSize "${release_asset_size:-unknown}" \ + '{ + state: $state, message: $message, channel: $channel, + installed: {version: $installedVersion, commit: $installedCommit}, + available: (if $reached == "true" then { + version: $availableVersion, commit: $availableCommit, + publishedAt: $availablePublished, + asset: {name: $assetName, checksum: $assetChecksum, size: $assetSize} + } else null end) + }' + return 0 + fi + + printf 'lyona-update-protocol\t1\t0\n' + printf 'state\t%s\t%s\n' "$state" "$message" + printf 'installed\t%s\t%s\n' "$installed_version" "$installed_commit" + if [[ $reached == true ]]; then + printf 'available\t%s\t%s\t%s\n' "$release_version" "$release_commit" "$release_published" + printf 'channel\t%s\n' "$requested_channel" + printf 'asset\t%s\t%s\t%s\n' "$release_asset_name" "$release_checksum" "$release_asset_size" + else + printf 'channel\t%s\n' "$requested_channel" + fi + printf 'complete\tcheck\n' +} + +# --- shared helpers for apply / rollback ------------------------------ + +trusted_root_helper() { + local candidate canonical derived= + derived=${script_dir%/bin}/libexec/lyona/$root_helper_name + for candidate in "$derived" /usr/local/libexec/lyona/$root_helper_name \ + /usr/libexec/lyona/$root_helper_name; do + [[ -n $candidate ]] || continue + canonical=$(readlink -f -- "$candidate" 2>/dev/null || true) + [[ -n $canonical && $canonical == "$candidate" ]] || continue + [[ -f $candidate && ! -L $candidate && -x $candidate ]] || continue + [[ $(stat -c %u -- "$candidate") == 0 ]] || continue + find "$candidate" -maxdepth 0 -type f ! -perm /022 -print -quit 2>/dev/null | grep -q . || continue + printf '%s\n' "$candidate" + return 0 + done + return 1 +} + +# rollback is the path a user takes with no desktop running, so this must not +# require a polkit agent: it tries pkexec only when a graphical session looks +# reachable, and always has sudo as a fallback. +run_privileged() { + local helper + helper=$(trusted_root_helper) || die "trusted privileged update helper is unavailable" + if [[ -n ${DISPLAY:-}${WAYLAND_DISPLAY:-} ]] && command -v pkexec >/dev/null 2>&1; then + local pkexec_status=0 + pkexec "$helper" "$@" || pkexec_status=$? + case $pkexec_status in + 0) return 0 ;; + 126 | 127) warn "pkexec could not authorize or run this step; falling back to sudo" ;; + *) return "$pkexec_status" ;; + esac + fi + command -v sudo >/dev/null 2>&1 || + die "neither a polkit agent nor sudo is available to complete this step" + sudo "$helper" "$@" +} + +# --- apply ----------------------------------------------------------- + +sha256_of() { + sha256sum -- "$1" | awk '{ print $1 }' +} + +cmd_apply() { + local requested_version='' file_path='' from_checkout='' allow_downgrade=false dry_run=false yes=false + while (($# > 0)); do + case $1 in + --version) + (($# >= 2)) || die "--version requires a value" + requested_version=$2 + shift 2 + ;; + --file) + (($# >= 2)) || die "--file requires a value" + file_path=$2 + shift 2 + ;; + --from-checkout) + (($# >= 2)) || die "--from-checkout requires a value" + from_checkout=$2 + shift 2 + ;; + --allow-downgrade) + allow_downgrade=true + shift + ;; + --dry-run) + dry_run=true + shift + ;; + --yes) + yes=true + shift + ;; + *) die "unknown option: $1" ;; + esac + done + [[ -z $file_path || -z $from_checkout ]] || die "--file and --from-checkout are mutually exclusive" + + read_config + read_installed + + local staging_dir tarball checksum install_mode config_h_arg=- + if [[ -n $from_checkout ]]; then + valid_absolute_path "$from_checkout" || die "--from-checkout must be an absolute path" + [[ -f $from_checkout/config.mk ]] || die "not a lyona checkout: $from_checkout" + requested_version=$(awk '$1 == "VERSION" && $2 == "=" { print $3; exit }' "$from_checkout/config.mk") + [[ -n $requested_version ]] || die "could not read VERSION from $from_checkout/config.mk" + staging_dir=$from_checkout + install_mode=checkout + else + install_mode=release + if [[ -n $file_path ]]; then + valid_absolute_path "$file_path" || die "--file must be an absolute path" + [[ -f $file_path && ! -L $file_path ]] || die "tarball not found: $file_path" + tarball=$file_path + [[ -n $requested_version ]] || die "--file requires --version to confirm what it contains" + else + [[ -n $requested_version ]] || { + resolve_release "$channel" || die "could not reach the update server; pass --file for an offline install" + requested_version=$release_version + } + valid_version "$requested_version" || die "not a valid version: $requested_version" + [[ $requested_version == "${release_version:-}" ]] || resolve_release "$channel" || + die "could not look up release $requested_version" + [[ $requested_version == "$release_version" ]] || + die "requested version $requested_version does not match the resolved release $release_version" + checksum=$release_checksum + [[ $checksum != unknown ]] || die "no published checksum for $requested_version; refusing to install unverified" + + ensure_owned_directory "$updates_root" "updates" + tarball=$updates_root/lyona-$requested_version.tar.gz + [[ -n $release_asset_url ]] || die "release $requested_version has no source archive asset" + warn "downloading lyona-$requested_version.tar.gz" + local tmp_tarball + tmp_tarball=$(mktemp "$updates_root/.lyona-$requested_version.tar.gz.XXXXXX") + if ! curl --fail --silent --show-error --location \ + --retry 3 --connect-timeout 10 --max-time 300 \ + "$release_asset_url" --output "$tmp_tarball"; then + rm -f "$tmp_tarball" + die "failed to download $release_asset_url" + fi + mv -fT "$tmp_tarball" "$tarball" + fi + + if [[ -n ${checksum:-} ]] && [[ $checksum != unknown ]]; then + : + elif [[ -n $file_path ]]; then + resolve_release "$channel" && [[ $requested_version == "$release_version" ]] && checksum=$release_checksum + checksum=${checksum:-unknown} + fi + if [[ ${checksum:-unknown} != unknown ]]; then + warn "verifying checksum" + local actual_checksum + actual_checksum=$(sha256_of "$tarball") + [[ $actual_checksum == "$checksum" ]] || + die "checksum mismatch for $tarball: expected $checksum, got $actual_checksum" + else + warn "no checksum available for $tarball; proceeding without verification is not supported" + die "refusing to install an unverified tarball" + fi + + staging_dir=$updates_root/$requested_version + rm -rf "$staging_dir" + mkdir -p "$staging_dir" + if ! tar -xzf "$tarball" -C "$staging_dir" --strip-components=1; then + rm -rf "$staging_dir" + die "failed to unpack $tarball" + fi + local live_config_h=${script_dir%/*}/config.h + if [[ -f $live_config_h ]]; then + cp -a "$live_config_h" "$staging_dir/config.h" + config_h_arg=$live_config_h + fi + fi + + local installed_rank requested_rank + installed_rank=$(version_rank "$installed_version" 2>/dev/null || printf 0) + requested_rank=$(version_rank "$requested_version" 2>/dev/null || die "not a valid version: $requested_version") + if [[ $requested_rank < $installed_rank && $allow_downgrade != true ]]; then + die "requested version $requested_version is older than the installed $installed_version; pass --allow-downgrade to proceed" + fi + + warn "building $requested_version" + if ! make -C "$staging_dir" clean >/dev/null; then + die "failed to clean the staging tree" + fi + if ! make -C "$staging_dir" all; then + die "build failed for $requested_version; the live install is untouched" + fi + + if [[ $dry_run == true ]]; then + printf 'dry run: would back up the live install, then write:\n' + printf ' %s/bin (system commands and dwm)\n' "${PREFIX:-/usr/local}" + printf ' %s/libexec/lyona (privileged helpers)\n' "${PREFIX:-/usr/local}" + printf ' %s/share/man/man1/dwm.1\n' "${PREFIX:-/usr/local}" + printf ' /etc/lyona-release\n' + printf ' %s\n' "$state_home/lyona/install.state" + printf 'complete\tapply-dry-run\n' + return 0 + fi + + if [[ $yes != true ]]; then + local answer + printf 'Install %s over the running system? [y/N] ' "$requested_version" >&2 + IFS= read -r answer || answer= + case $answer in + y | Y | yes | YES) ;; + *) die "not confirmed; the staged, built update in $staging_dir was left in place" ;; + esac + fi + + # shellcheck source=scripts/dev-sync-install.sh + DEV_SYNC_INSTALL_LIB_ONLY=1 DEV_SYNC_INSTALL_REPO_DIR=$staging_dir \ + . "$script_dir/dev-sync-install.sh" + prepare_expected_files + + warn "backing up the live installation" + backup_live_install + + warn "installing $requested_version (this step requires authentication)" + local privileged_install_ok=true + if [[ $install_mode == release ]]; then + run_privileged install-system release "$tarball" "$checksum" "$requested_version" "$config_h_arg" || + privileged_install_ok=false + else + run_privileged install-system checkout "$staging_dir" "$requested_version" || + privileged_install_ok=false + fi + if [[ $privileged_install_ok != true ]]; then + die "the privileged install step failed or was declined; the live install is untouched, and the staged, built update in $staging_dir was left in place" + fi + if ! make -C "$staging_dir" install-user \ + USER_HOME="$HOME" OWNER="$(id -un)" \ + XDG_CONFIG_HOME="$config_home" XDG_DATA_HOME="$data_home" XDG_STATE_HOME="$state_home"; then + die "the user-level install step failed after the system files were already updated; run lyona-update rollback" + fi + + warn "verifying the installed state" + verify_install || die "the live installation does not match the staged update; run lyona-update rollback" + + warn "runtime check" + runtime_verify 1 || warn "runtime validation reported a problem; see above" + + prune_backups + printf 'complete\tapply\t%s\n' "$requested_version" +} + +# --- backups / rollback ------------------------------------------------ + +list_backup_ids() { + [[ -d $backups_root ]] || return 0 + find "$backups_root" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' 2>/dev/null | sort -r +} + +backup_version_of() { + local id=$1 checkout_file + checkout_file=$backups_root/$id/checkout.txt + [[ -f $checkout_file ]] || { + printf 'unknown\n' + return 0 + } + awk -F= '$1 == "version" { print $2; exit }' "$checkout_file" 2>/dev/null +} + +prune_backups() { + [[ $keep_backups -gt 0 ]] || return 0 + local id count=0 + while IFS= read -r id; do + [[ -n $id ]] || continue + count=$((count + 1)) + if ((count > keep_backups)); then + rm -rf -- "${backups_root:?}/$id" + fi + done < <(list_backup_ids) +} + +cmd_backups() { + local json=false + while (($# > 0)); do + case $1 in + --json) + json=true + shift + ;; + *) die "unknown option: $1" ;; + esac + done + if [[ $json == true ]]; then + printf '[' + local id first=true version date + while IFS= read -r id; do + [[ -n $id ]] || continue + version=$(backup_version_of "$id") + date=${id%%-*} + [[ $first == true ]] || printf ',' + first=false + jq -cn --arg id "$id" --arg version "$version" --arg date "$date" \ + '{id: $id, version: $version, date: $date}' + done < <(list_backup_ids) + printf ']\n' + return 0 + fi + printf 'lyona-update-protocol\t1\t0\n' + local id version + while IFS= read -r id; do + [[ -n $id ]] || continue + version=$(backup_version_of "$id") + printf 'backup\t%s\t%s\n' "$id" "$version" + done < <(list_backup_ids) + printf 'complete\tbackups\n' +} + +verify_backup_checksums() { + local backup_dir=$1 archive + local sums_file=$backup_dir/SHA256SUMS + [[ -f $sums_file && ! -L $sums_file ]] || die "backup has no checksum manifest: $backup_dir" + for archive in "$backup_dir"/*.tar; do + [[ -f $archive ]] || continue + (cd "$backup_dir" && sha256sum --quiet --check <(grep -F "$(basename "$archive")" SHA256SUMS)) || + die "backup checksum verification failed: $archive" + done +} + +cmd_rollback() { + local backup_id='' list_only=false yes=false + while (($# > 0)); do + case $1 in + --backup) + (($# >= 2)) || die "--backup requires a value" + backup_id=$2 + shift 2 + ;; + --list) + list_only=true + shift + ;; + --yes) + yes=true + shift + ;; + *) die "unknown option: $1" ;; + esac + done + + if [[ $list_only == true ]]; then + cmd_backups + return 0 + fi + + if [[ -z $backup_id ]]; then + backup_id=$(list_backup_ids | head -n1) + [[ -n $backup_id ]] || die "no backups are available" + fi + local backup_dir=$backups_root/$backup_id + case $backup_id in + */* | '' | . | ..) die "invalid backup id: $backup_id" ;; + esac + [[ -d $backup_dir && ! -L $backup_dir ]] || die "backup not found: $backup_id" + + verify_backup_checksums "$backup_dir" + + local checkout_file=$backup_dir/checkout.txt + local backup_prefix backup_config_home backup_data_home + backup_prefix=$(awk -F= '$1 == "prefix" { print $2; exit }' "$checkout_file" 2>/dev/null) + backup_config_home=$(awk -F= '$1 == "config_home" { print $2; exit }' "$checkout_file" 2>/dev/null) + backup_data_home=$(awk -F= '$1 == "xdg_data_home" { print $2; exit }' "$checkout_file" 2>/dev/null) + local current_prefix=${PREFIX:-/usr/local} + if [[ -n $backup_prefix && $backup_prefix != "$current_prefix" ]] || + [[ -n $backup_config_home && $backup_config_home != "$config_home" ]] || + [[ -n $backup_data_home && $backup_data_home != "$data_home" ]]; then + die "backup $backup_id was taken against a different environment; refusing to restore into this one" + fi + + if [[ $yes != true ]]; then + local answer + printf 'Restore backup %s over the running system? [y/N] ' "$backup_id" >&2 + IFS= read -r answer || answer= + case $answer in + y | Y | yes | YES) ;; + *) die "not confirmed" ;; + esac + fi + + warn "restoring system files (this step requires authentication)" + run_privileged restore-system "$backup_dir" || + die "the privileged restore step failed or was declined" + + if [[ -f $backup_dir/lyona-data.tar ]]; then + tar -C "$(dirname "$data_home/lyona")" -xpf "$backup_dir/lyona-data.tar" + fi + if [[ -f $backup_dir/quickshell.tar ]]; then + tar -C "$(dirname "$config_home/quickshell")" -xpf "$backup_dir/quickshell.tar" + fi + + local restored_version + restored_version=$(backup_version_of "$backup_id") + printf 'SESSION RESTART REQUIRED: log out and back in to activate %s.\n' "${restored_version:-the restored version}" + printf 'complete\trollback\t%s\n' "${restored_version:-unknown}" +} + +# --- dispatch -------------------------------------------------------- + +[[ $# -ge 1 ]] || { + usage >&2 + exit 2 +} +subcommand=$1 +shift +case $subcommand in +check) cmd_check "$@" ;; +apply) cmd_apply "$@" ;; +rollback) cmd_rollback "$@" ;; +backups) cmd_backups "$@" ;; +-h | --help) + usage + exit 0 + ;; +*) + usage >&2 + exit 2 + ;; +esac diff --git a/scripts/lyona-update-root b/scripts/lyona-update-root new file mode 100755 index 0000000..e7c8ea8 --- /dev/null +++ b/scripts/lyona-update-root @@ -0,0 +1,307 @@ +#!/usr/bin/env bash +set -euo pipefail + +install_prefix='@PREFIX@' +install_manprefix='@MANPREFIX@' +install_datadir='@DATADIR@' +install_xsessionsdir='@XSESSIONSDIR@' + +die() { + printf 'lyona-update-root: %s\n' "$*" >&2 + exit 1 +} + +trusted_parent_chain() { + local parent=${1%/*} + while :; do + [[ -d $parent && ! -L $parent ]] || return 1 + [[ $(stat -c %u "$parent") == 0 ]] || return 1 + find "$parent" -maxdepth 0 -type d ! -perm /022 -print -quit 2>/dev/null | grep -q . || return 1 + [[ $parent == / ]] && return 0 + parent=${parent%/*} + [[ -n $parent ]] || parent=/ + done +} + +trusted_file() { + local path=$1 canonical + canonical=$(readlink -f -- "$path" 2>/dev/null || true) + [[ -n $canonical && $canonical == "$path" ]] || return 1 + trusted_parent_chain "$canonical" || return 1 + [[ -f $canonical && ! -L $canonical && -x $canonical ]] || return 1 + [[ $(stat -c %u "$canonical") == 0 ]] || return 1 + find "$canonical" -maxdepth 0 -type f ! -perm /022 -print -quit 2>/dev/null | grep -q . +} + +# A directory that a specific unprivileged user owns and controls, and that +# no other unprivileged user could have written into. This is the whole +# security boundary for a staging/backup directory: it must be provably the +# invoking user's own, never a shared or world-writable location this helper +# was tricked into trusting. +user_owned_dir() { + local path=$1 want_uid=$2 mode + [[ $path == /* && $path != */../* && $path != *'/..' ]] || return 1 + [[ -d $path && ! -L $path ]] || return 1 + [[ $(stat -c %u -- "$path") == "$want_uid" ]] || return 1 + mode=$(stat -c %a -- "$path") + ((($((8#$mode)) & 8#022) == 0)) || return 1 + return 0 +} + +# Same shape, for a single regular file rather than a directory. +user_owned_file() { + local path=$1 want_uid=$2 mode + [[ $path == /* && $path != */../* && $path != *'/..' ]] || return 1 + [[ -f $path && ! -L $path ]] || return 1 + [[ $(stat -c %u -- "$path") == "$want_uid" ]] || return 1 + mode=$(stat -c %a -- "$path") + ((($((8#$mode)) & 8#022) == 0)) || return 1 + return 0 +} + +log_outcome() { + local verb=$1 version=$2 target=$3 outcome=$4 + [[ -n $log_file ]] || return 0 + umask 077 + mkdir -p "$(dirname -- "$log_file")" 2>/dev/null || return 0 + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$verb" "$version" "$target" "$outcome" \ + >>"$log_file" 2>/dev/null || : +} + +[[ $EUID == 0 ]] || die "this helper must run as root" +self=$(readlink -f "${BASH_SOURCE[0]}") +[[ $self == "$install_prefix/libexec/lyona/lyona-update-root" ]] || + die "helper is not running from its trusted installed path" +trusted_file "$self" || die "helper is not a trusted root-owned executable" + +# rollback must work from a bare TTY with no polkit agent running, so this +# accepts sudo's invoking-user variable as well as pkexec's -- never a +# caller-supplied argument, and never anything read from a shell the +# invoking user could have shaped (both variables come from the setuid +# wrapper itself, not from the environment the invoker otherwise controls). +if [[ -n ${PKEXEC_UID:-} ]]; then + [[ $PKEXEC_UID =~ ^[0-9]+$ ]] || die "PKEXEC_UID is malformed" + invoking_uid=$PKEXEC_UID +elif [[ -n ${SUDO_UID:-} ]]; then + [[ $SUDO_UID =~ ^[0-9]+$ ]] || die "SUDO_UID is malformed" + invoking_uid=$SUDO_UID +else + die "this helper must run through polkit or sudo" +fi +invoking_home=$(getent passwd "$invoking_uid" | cut -d: -f6) +[[ -n $invoking_home && $invoking_home == /* ]] || + die "could not resolve the invoking user's home directory" +invoking_user=$(getent passwd "$invoking_uid" | cut -d: -f1) +[[ -n $invoking_user ]] || die "could not resolve the invoking user's name" +state_home=$invoking_home/.local/state +log_file=$state_home/lyona/update.log + +make_path=$(command -v make) || die "make is unavailable" + +# Everything below this point that ends up executed -- a Makefile recipe, a +# script invoked by one -- must come from a tree this process itself +# extracted from a tarball it just re-hashed, never from a directory the +# invoking user has had write access to at any point. A staging directory +# the unprivileged caller built and handed us is fine as a *data* source +# (a prebuilt "dwm" is inert until the user's own session later runs it, +# same as anything else in their PATH already), but it must never be the +# thing "make install-system" is run against, because config.mk and the +# Makefile execute arbitrary shell during evaluation (`$(shell ...)`), not +# only via the recipe someone thinks they are invoking. +extract_verified_tree() { + local tarball=$1 dest=$2 + tar -xzf "$tarball" -C "$dest" --strip-components=1 || + die "failed to extract the verified release tarball" +} + +case ${1:-} in +install-system) + case ${2:-} in + release) + [[ $# == 6 ]] || + die "install-system release requires a tarball, its checksum, the version, and a config.h path (or -)" + tarball_path=$3 + expected_sha256=$4 + requested_version=$5 + config_h_path=$6 + updates_root=$state_home/lyona/updates + + case $tarball_path in + "$updates_root"/*) ;; + *) die "tarball is not under the invoking user's managed updates area" ;; + esac + user_owned_file "$tarball_path" "$invoking_uid" || + die "tarball is not a safe, user-owned file" + [[ $expected_sha256 =~ ^[0-9a-fA-F]{64}$ ]] || die "malformed expected checksum" + [[ $requested_version =~ ^[0-9][0-9A-Za-z.+_-]*$ ]] || die "malformed version" + + actual_sha256=$(sha256sum -- "$tarball_path" | awk '{ print $1 }') + # Re-verified here, immediately before extraction, rather than + # trusted from the unprivileged caller's earlier check: that check + # and this one straddle an arbitrary amount of time (build, + # confirmation prompt) during which the tarball sits in a + # directory the invoking user can still write to. + [[ ${actual_sha256,,} == "${expected_sha256,,}" ]] || + die "tarball checksum does not match the expected release digest; refusing to install" + + if [[ $config_h_path != - ]]; then + user_owned_file "$config_h_path" "$invoking_uid" || + die "config.h is not a safe, user-owned file" + fi + + verified_dir=$(mktemp -d) || die "could not create a scratch directory" + chmod 700 "$verified_dir" + trap 'rm -rf "$verified_dir"' EXIT + + extract_verified_tree "$tarball_path" "$verified_dir" + staged_version=$(awk '$1 == "VERSION" && $2 == "=" { print $3; exit }' "$verified_dir/config.mk") + [[ -n $staged_version ]] || die "could not read VERSION from the verified release's config.mk" + [[ $staged_version == "$requested_version" ]] || + die "the verified release is version $staged_version, not the requested $requested_version" + + if [[ $config_h_path != - ]]; then + cp -a "$config_h_path" "$verified_dir/config.h" + fi + + # The compiled binary is rebuilt here, from source this process + # just verified, rather than trusted from the unprivileged + # caller's own prior build: a "dwm" copied in from the staging + # directory would be installed system-wide, where every user who + # selects it at login runs whatever the invoking user last put + # there -- not only their own session. + if ! "$make_path" -C "$verified_dir" clean; then + die "failed to clean the verified build tree" + fi + if ! "$make_path" -C "$verified_dir" all; then + die "failed to build the verified release" + fi + + if ! "$make_path" -C "$verified_dir" install-system \ + USER_HOME="$invoking_home" \ + OWNER="$invoking_user" \ + XDG_STATE_HOME="$state_home"; then + log_outcome install-system "$requested_version" "$verified_dir" failed + die "install-system failed" + fi + log_outcome install-system "$requested_version" "$tarball_path" succeeded + ;; + checkout) + [[ $# == 4 ]] || die "install-system checkout requires a checkout directory and its version" + staging_dir=$3 + requested_version=$4 + + # No release tarball exists for a local developer checkout to + # re-verify against; this mode carries the same trust level as + # the invoking user running "sudo make install-system" themselves + # directly from that same checkout -- not a weaker one introduced + # by going through lyona-update. + user_owned_dir "$staging_dir" "$invoking_uid" || + die "checkout directory is not a safe, user-owned directory" + [[ -x $staging_dir/dwm && -f $staging_dir/dwm ]] || + die "checkout directory has no built dwm binary" + [[ -f $staging_dir/config.mk ]] || die "checkout directory has no config.mk" + staged_version=$(awk '$1 == "VERSION" && $2 == "=" { print $3; exit }' "$staging_dir/config.mk") + [[ -n $staged_version ]] || die "could not read VERSION from the checkout's config.mk" + [[ $staged_version == "$requested_version" ]] || + die "checkout version ($staged_version) does not match the requested version ($requested_version)" + + if ! "$make_path" -C "$staging_dir" install-system \ + USER_HOME="$invoking_home" \ + OWNER="$invoking_user" \ + XDG_STATE_HOME="$state_home"; then + log_outcome install-system "$requested_version" "$staging_dir" failed + die "install-system failed" + fi + log_outcome install-system "$requested_version" "$staging_dir" succeeded + ;; + *) + die "usage: lyona-update-root install-system release TARBALL SHA256 VERSION CONFIG_H|- | install-system checkout CHECKOUT_DIR VERSION" + ;; + esac + ;; +restore-system) + [[ $# == 2 ]] || die "restore-system requires a backup directory" + backup_dir=$2 + backups_root=$state_home/lyona/live-update-backups + case $backup_dir in + "$backups_root"/*) ;; + *) die "backup directory is not under the invoking user's managed backups area" ;; + esac + user_owned_dir "$backup_dir" "$invoking_uid" || + die "backup directory is not a safe, user-owned directory" + sums_file=$backup_dir/SHA256SUMS + [[ -f $sums_file && ! -L $sums_file ]] || die "backup has no checksum manifest" + system_archive=$backup_dir/system-files.tar + if [[ -e $system_archive ]]; then + [[ -f $system_archive && ! -L $system_archive ]] || die "unsafe system-files archive" + # This only catches accidental corruption, not a deliberate + # tamperer: SHA256SUMS lives in the same user-writable directory + # as the archive it checks, so both can be regenerated together. + # The member-path allowlist below is what actually bounds a + # hostile archive's blast radius. + (cd "$backup_dir" && sha256sum --quiet --check <(grep -F 'system-files.tar' SHA256SUMS)) || + die "system-files.tar failed checksum verification; refusing to restore" + + # Never trust an archive member's path, type, or mode: an archive + # built (or replaced) by the invoking user could otherwise plant + # a member anywhere on the filesystem ("../../etc/cron.d/x", an + # absolute path), or a symlink/hardlink/device/fifo/socket that + # resolves outside the managed locations at extraction time, or + # a setuid/setgid root binary at an already-allowed path ("dwm" + # with the setuid bit is a root shell for every user on the + # machine, since dwm can spawn arbitrary configured commands) -- + # and GNU tar preserves all of that by default when run as root. + # --numeric-owner avoids trusting archive-supplied user/group + # names for anything, including as a parsing surface. + bin_dir_rel=${install_prefix#/}/bin + libexec_dir_rel=${install_prefix#/}/libexec/lyona + man_dir_rel=${install_manprefix#/}/man1 + xsessions_dir_rel=${install_xsessionsdir#/} + icons_dir_rel=${install_datadir#/}/icons + licenses_dir_rel=${install_datadir#/}/licenses/lyona + while IFS= read -r listing_line; do + [[ -n $listing_line ]] || continue + member_type=${listing_line:0:1} + member_mode=${listing_line:1:9} + member_path=$(awk '{ for (i = 6; i <= NF; i++) printf "%s%s", (i > 6 ? " " : ""), $i }' <<<"$listing_line") + [[ -n $member_path ]] || die "could not parse a backup archive listing line: $listing_line" + + case $member_type in + - | d) ;; + *) die "backup archive contains a non-regular member (type '$member_type'): $member_path" ;; + esac + case $member_mode in + *[sStT]*) die "backup archive contains a member with setuid, setgid, or sticky permissions: $member_path" ;; + esac + case $member_path in + /* | *..*) die "backup archive contains an unsafe path: $member_path" ;; + esac + + case $member_path in + "$bin_dir_rel/"*/*) die "backup archive contains a nested path under a flat directory: $member_path" ;; + "$bin_dir_rel/" | "$bin_dir_rel/"*) ;; + "$libexec_dir_rel/"*/*) die "backup archive contains a nested path under a flat directory: $member_path" ;; + "$libexec_dir_rel/" | "$libexec_dir_rel/"*) ;; + "$man_dir_rel/"*/*) die "backup archive contains a nested path under a flat directory: $member_path" ;; + "$man_dir_rel/" | "$man_dir_rel/"*) ;; + "$xsessions_dir_rel/"*/*) die "backup archive contains a nested path under a flat directory: $member_path" ;; + "$xsessions_dir_rel/" | "$xsessions_dir_rel/"*) ;; + "$icons_dir_rel/" | "$icons_dir_rel/"*) ;; + "$licenses_dir_rel/" | "$licenses_dir_rel/"*) ;; + etc/lyona-release) ;; + *) die "backup archive contains a path outside the managed install locations: $member_path" ;; + esac + done < <(tar --numeric-owner -tvf "$system_archive") + + if ! tar -C / -xpf "$system_archive"; then + log_outcome restore-system unknown "$backup_dir" failed + die "restoring system-files.tar failed" + fi + fi + log_outcome restore-system unknown "$backup_dir" succeeded + ;; +*) + die "usage: lyona-update-root install-system release|checkout ... | restore-system BACKUP_DIR" + ;; +esac diff --git a/tests/test-dev-sync-install.sh b/tests/test-dev-sync-install.sh index b21a7f0..20dd9b7 100755 --- a/tests/test-dev-sync-install.sh +++ b/tests/test-dev-sync-install.sh @@ -18,6 +18,7 @@ state_home="$test_home/.local/state" data_dir="$xdg_data_home/lyona" output="$work/output" install_sources="$work/install-sources" +privileged_helpers="$work/privileged-helpers" mkdir -p "$test_repo" "$prefix/bin" "$prefix/libexec/lyona" \ "$manprefix/man1" "$xsessions_dir" \ @@ -39,10 +40,17 @@ chmod 755 "$test_repo/dwm" make -s -C "$test_repo" --no-print-directory \ --eval='test-print-install-sources: ; @printf "%s\n" $(INSTALL_COMMANDS)' \ test-print-install-sources >"$install_sources" +# shellcheck disable=SC2016 +make -s -C "$test_repo" --no-print-directory \ + --eval='test-print-privileged-helpers: ; @printf "%s\n" $(PRIVILEGED_HELPERS)' \ + test-print-privileged-helpers >"$privileged_helpers" install -Dm755 "$test_repo/dwm" "$prefix/bin/dwm" -sed "s|@PREFIX@|$prefix|g" "$test_repo/scripts/dwm-settings-display-root" | - install -Dm755 /dev/stdin "$prefix/libexec/lyona/dwm-settings-display-root" +while IFS= read -r privileged_helper; do + [ -n "$privileged_helper" ] || continue + sed "s|@PREFIX@|$prefix|g" "$test_repo/$privileged_helper" | + install -Dm755 /dev/stdin "$prefix/libexec/lyona/${privileged_helper##*/}" +done <"$privileged_helpers" while IFS= read -r install_source; do [ -n "$install_source" ] || continue install -Dm755 "$test_repo/$install_source" \ diff --git a/tests/test-lyona-update.sh b/tests/test-lyona-update.sh new file mode 100755 index 0000000..156baba --- /dev/null +++ b/tests/test-lyona-update.sh @@ -0,0 +1,347 @@ +#!/bin/sh +set -eu + +# shellcheck source=tests/lib.sh +. "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)/lib.sh" +helper=$repo/scripts/lyona-update +make_workspace + +home=$work/home +config_home=$home/.config +data_home=$home/.local/share +state_home=$home/.local/state +cache_home=$home/.cache +bin_dir=$work/bin +responses_dir=$work/curl-responses +system_record=$work/etc-lyona-release +user_record=$state_home/lyona/install.state + +mkdir -p "$home" "$config_home" "$data_home" "$state_home" "$cache_home" \ + "$bin_dir" "$responses_dir" + +github_api=https://stub.invalid +github_repo=test/lyona + +run_update() { + HOME="$home" PATH="$bin_dir:$PATH" \ + XDG_CONFIG_HOME="$config_home" XDG_DATA_HOME="$data_home" \ + XDG_STATE_HOME="$state_home" XDG_CACHE_HOME="$cache_home" \ + DWM_TEST_SYSTEM_RECORD="$system_record" DWM_TEST_SYSTEM_OWNER="$(id -u)" \ + DWM_TEST_USER_RECORD="$user_record" \ + LYONA_UPDATE_GITHUB_API="$github_api" LYONA_UPDATE_GITHUB_REPO="$github_repo" \ + LYONA_UPDATE_CACHE_TTL="${LYONA_UPDATE_CACHE_TTL:-300}" \ + "$helper" "$@" +} + +write_user_record() { + mkdir -p "$(dirname -- "$user_record")" + cat >"$user_record" + chmod 600 "$user_record" +} + +valid_user_record() { + version=$1 + source_kind=${2:-tarball} + cat </dev/null || : + # Each scenario seeds its own release fixture; a cache hit -- or a + # channel left over from the config-seeding scenario -- would otherwise + # skip the network, or hit an endpoint this scenario never seeded. + rm -rf "$cache_home/lyona" "$config_home/lyona" +} + +# A curl stub good enough for lyona-update's own uses: reads a canned +# response body for the requested URL from $responses_dir, keyed by turning +# the URL into a safe filename. Serves --output requests and plain stdout +# alike, since lyona-update uses both. +stub_curl() { + stub_command curl <<'SH' +#!/bin/sh +url= +output= +prev= +for a in "$@"; do + case $prev in --output) output=$a ;; esac + case $a in http*://*) url=$a ;; esac + prev=$a +done +[ ! -e "__RESPONSES__/../offline-flag" ] || exit 7 +key=$(printf '%s' "$url" | tr -c 'A-Za-z0-9' '_') +resp="__RESPONSES__/$key" +if [ ! -f "$resp" ]; then + printf 'curl-stub: no canned response for %s\n' "$url" >&2 + exit 22 +fi +if [ -n "$output" ]; then + cp "$resp" "$output" +else + cat "$resp" +fi +SH + sed -i "s|__RESPONSES__|$responses_dir|g" "$bin_dir/curl" +} + +canned_response() { + key=$(printf '%s' "$1" | tr -c 'A-Za-z0-9' '_') + cat >"$responses_dir/$key" +} + +go_offline() { + : >"$responses_dir/../offline-flag" +} + +go_online() { + rm -f "$responses_dir/../offline-flag" +} + +# Seeds a stable release (default channel) with the given version, asset +# checksum and tag commit, reachable through the stub curl. +seed_release() { + version=$1 + checksum=${2:-3f9cabc00000000000000000000000000000000000000000000000000000a1} + sha=${3:-f4a2c8199999999999999999999999999999999} + asset_url="$github_api/assets/lyona-$version.tar.gz" + sums_url="$github_api/assets/lyona-$version-SHA256SUMS" + canned_response "$github_api/repos/$github_repo/releases/latest" < patch > base > rc > beta > alpha ─────── +reset_curl_responses +for pair in \ + '2026.08.0:2026.09.0:behind' \ + '2026.08.0:2026.08.1:behind' \ + '2026.08.0-rc.1:2026.08.0:behind' \ + '2026.08.0-beta.2:2026.08.0-rc.1:behind' \ + '2026.08.0-beta.1:2026.08.0-beta.2:behind' \ + '2026.08.0-alpha.1:2026.08.0-beta.1:behind'; do + installed=${pair%%:*} + rest=${pair#*:} + available=${rest%%:*} + expected=${rest#*:} + reset_curl_responses + valid_user_record "$installed" | write_user_record + seed_release "$available" + status=$(run_update check) + assert_string_contains "$status" "$(printf 'state\t%s' "$expected")" \ + "$installed vs $available" +done + +# ── check: config seeded on first use, never overwritten ─────────────── +reset_curl_responses +valid_user_record 2026.09.0 | write_user_record +seed_release 2026.09.0 +rm -f "$config_home/lyona/update.conf" +run_update check >/dev/null +assert_file "$config_home/lyona/update.conf" +assert_contains "$config_home/lyona/update.conf" 'channel=stable' +printf 'channel=preview\ncheck_on_login=true\nauto_apply=false\nkeep_backups=5\n' \ + >"$config_home/lyona/update.conf" +run_update check >/dev/null +assert_contains "$config_home/lyona/update.conf" 'channel=preview' + +# ── apply: checksum mismatch aborts before unpacking ──────────────────── +reset_curl_responses +valid_user_record 2026.08.0 | write_user_record +seed_release 2026.09.0 deadbeef00000000000000000000000000000000000000000000000000dead +if run_update apply --version 2026.09.0 --yes >"$work/out" 2>&1; then + fail "checksum-mismatched apply unexpectedly succeeded" +fi +assert_contains "$work/out" 'checksum mismatch' +assert_no_file "$state_home/lyona/updates/2026.09.0" + +# ── apply --file: unverifiable tarball is refused outright ───────────── +reset_curl_responses +valid_user_record 2026.08.0 | write_user_record +go_offline +printf 'not a tarball\n' >"$work/local.tar.gz" +if run_update apply --file "$work/local.tar.gz" --version 2026.09.0 --yes \ + >"$work/out" 2>&1; then + fail "unverified --file apply unexpectedly succeeded" +fi +assert_contains "$work/out" 'refusing to install an unverified tarball' +go_online + +# ── apply: downgrade refused without --allow-downgrade ───────────────── +reset_curl_responses +valid_user_record 2026.09.0 | write_user_record +if run_update apply --from-checkout "$repo" --dry-run >"$work/out" 2>&1; then + fail "downgrade apply unexpectedly succeeded without --allow-downgrade" +fi +assert_contains "$work/out" 'pass --allow-downgrade to proceed' + +# ── apply --dry-run: lists privileged paths, installs nothing ────────── +reset_curl_responses +valid_user_record 0000.00.0 | write_user_record +status=$(run_update apply --from-checkout "$repo" --allow-downgrade --dry-run) +assert_string_contains "$status" 'would back up the live install' +assert_string_contains "$status" "$(printf 'complete\tapply-dry-run')" +assert_no_file "$state_home/lyona/live-update-backups" + +# ── apply: build failure exits before any privileged call ────────────── +reset_curl_responses +valid_user_record 0000.00.0 | write_user_record +broken_checkout=$work/broken-checkout +rm -rf "$broken_checkout" +cp -a "$repo" "$broken_checkout" +rm -rf "$broken_checkout/.git" +printf 'this is not valid C\n' >"$broken_checkout/dwm.c" +if run_update apply --from-checkout "$broken_checkout" --allow-downgrade --yes \ + >"$work/out" 2>&1; then + fail "apply with a broken build unexpectedly succeeded" +fi +assert_contains "$work/out" 'build failed' +assert_no_file "$state_home/lyona/live-update-backups" + +# ── apply: privileged step unavailable leaves the staged update in place, +# live install untouched, non-zero exit (no root helper exists here) ─ +reset_curl_responses +valid_user_record 0000.00.0 | write_user_record +if run_update apply --from-checkout "$repo" --allow-downgrade --yes \ + >"$work/out" 2>&1; then + fail "apply unexpectedly succeeded with no privileged helper installed" +fi +assert_contains "$work/out" 'privileged' +assert_dir "$state_home/lyona/live-update-backups" + +# ── backups: none yet ──────────────────────────────────────────────── +reset_curl_responses +rm -rf "$state_home/lyona/live-update-backups" +status=$(run_update backups) +assert_string_contains "$status" "$(printf 'complete\tbackups')" +json=$(run_update backups --json) +assert_equals '[]' "$json" "empty backups --json" + +# ── rollback: no backups available ────────────────────────────────────── +if run_update rollback >"$work/out" 2>&1; then + fail "rollback with no backups unexpectedly succeeded" +fi +assert_contains "$work/out" 'no backups are available' + +# ── rollback --list: newest first, with version and date ─────────────── +backups_root=$state_home/lyona/live-update-backups +rm -rf "$backups_root" +mkdir -p "$backups_root/20260101T000000Z-1" "$backups_root/20260301T000000Z-2" +printf 'version=2026.01.0\n' >"$backups_root/20260101T000000Z-1/checkout.txt" +printf 'version=2026.03.0\n' >"$backups_root/20260301T000000Z-2/checkout.txt" +status=$(run_update rollback --list) +newest_line=$(printf '%s\n' "$status" | grep '^backup' | head -n1) +assert_string_contains "$newest_line" '20260301T000000Z-2' +assert_string_contains "$newest_line" '2026.03.0' + +# ── rollback: checksum mismatch restores nothing ──────────────────────── +backup_dir=$backups_root/20260301T000000Z-2 +printf 'v1\n' >"$backup_dir/quickshell.tar" +printf 'deadbeef00000000000000000000000000000000000000000000000000dead %s\n' \ + "$backup_dir/quickshell.tar" >"$backup_dir/SHA256SUMS" +if run_update rollback --backup 20260301T000000Z-2 --yes >"$work/out" 2>&1; then + fail "rollback with a bad checksum manifest unexpectedly succeeded" +fi +assert_contains "$work/out" 'checksum' + +# ── rollback: prefix mismatch refuses rather than restoring blind ────── +backup_dir2=$backups_root/20260101T000000Z-1 +printf 'v1\n' >"$backup_dir2/quickshell.tar" +sha256sum "$backup_dir2/quickshell.tar" >"$backup_dir2/SHA256SUMS" +printf 'prefix=/some/other/prefix\nconfig_home=/some/other/config\nxdg_data_home=/some/other/data\n' \ + >"$backup_dir2/checkout.txt" +if PREFIX=/usr/local run_update rollback --backup 20260101T000000Z-1 --yes \ + >"$work/out" 2>&1; then + fail "rollback into a mismatched environment unexpectedly succeeded" +fi +assert_contains "$work/out" 'different environment' + +# ── --help / usage ─────────────────────────────────────────────────────── +status=$(run_update --help) +assert_string_contains "$status" 'Usage: lyona-update' +if run_update bogus-command >"$work/out" 2>&1; then + fail "unknown subcommand unexpectedly succeeded" +fi +assert_contains "$work/out" 'Usage: lyona-update' + +printf 'lyona-update contract: PASS\n'