diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 00000000..b42bcdc8 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,189 @@ +name: ISO lifecycle qualification + +on: + workflow_dispatch: + inputs: + tag: + description: Reviewed release tag matching VERSION + required: true + type: string + baseline_iso: + description: Older supported ISO on the PXE host under /data/pxe/iso + required: true + type: string + workflow_call: + inputs: + tag: + required: true + type: string + baseline_iso: + required: true + type: string + secrets: + CYBEXOS_RPM_SIGNING_KEY: + required: false + +permissions: + contents: read + +concurrency: + group: cybexos-iso-release + cancel-in-progress: false + +jobs: + qualify: + # Debian 13 PXE host: only reviewed main/tag code, with no signing secret + # or desktop-release environment attached to this machine. + if: github.repository == 'DigitalPals/CybexOS' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + runs-on: cybexos-iso-${{ github.run_id }} + timeout-minutes: 360 + env: + RELEASE_TAG: ${{ inputs.tag }} + BASELINE_ISO: ${{ inputs.baseline_iso }} + PYTHONDONTWRITEBYTECODE: '1' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Prepare isolated release workspace + run: | + set -euo pipefail + work=$(mktemp -d "$RUNNER_TEMP/cybexos-release.XXXXXXXX") + printf 'RELEASE_WORK=%s\n' "$work" >> "$GITHUB_ENV" + test -r /data/pxe/README.md + test -n "$BASELINE_ISO" + systemctl is-active --quiet iventoy.service + image/build --preflight + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '24' + - name: Install isolated browser test driver + run: | + npm install --prefix "$RELEASE_WORK/browser" --no-audit --no-fund --ignore-scripts playwright-core@1.63.0 + printf 'NODE_PATH=%s\n' "$RELEASE_WORK/browser/node_modules" >> "$GITHUB_ENV" + - name: Build and qualify graphical installs, upgrade and recovery + run: | + image/release-gate --execute --output "$RELEASE_WORK/qualification" \ + --tag "$RELEASE_TAG" --baseline-iso "$BASELINE_ISO" + - name: Transfer qualified artifacts and reports to the hosted signing job + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cybexos-qualified-desktop + # The common root is qualification/. Do not upload VM disks, console + # logs, browser state, the builder cache, or the source checkout. + path: | + ${{ env.RELEASE_WORK }}/qualification/build/artifacts/ + ${{ env.RELEASE_WORK }}/qualification/*/qualification.json + ${{ env.RELEASE_WORK }}/qualification/release-gate.json + if-no-files-found: error + compression-level: 0 + retention-days: 2 + - name: Retain bounded qualification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cybexos-qualification-reports + path: | + ${{ env.RELEASE_WORK }}/qualification/release-gate.json + ${{ env.RELEASE_WORK }}/qualification/*/qualification.json + retention-days: 14 + if-no-files-found: ignore + - name: Remove task staging + if: always() + run: | + if [[ -n ${RELEASE_WORK:-} && $RELEASE_WORK == "$RUNNER_TEMP"/cybexos-release.* ]]; then + rm -rf -- "$RELEASE_WORK" + fi + + sign: + name: Sign qualified desktop artifacts on a hosted Fedora container + needs: qualify + runs-on: ubuntu-latest + environment: desktop-release + timeout-minutes: 120 + env: + RELEASE_TAG: ${{ inputs.tag }} + PYTHONDONTWRITEBYTECODE: '1' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - name: Reserve space for raw and prepared desktop artifacts + run: | + set -euo pipefail + # This job owns its disposable GitHub-hosted VM. Remove only unused + # preinstalled SDKs; runner tools, Docker and our checkout stay intact. + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup + available=$(df --output=avail -B1 "$RUNNER_TEMP" | tail -n 1) + if (( available < 24 * 1024 * 1024 * 1024 )); then + echo 'Signing requires at least 24 GiB free for raw and prepared artifacts.' >&2 + exit 1 + fi + work=$(mktemp -d "$RUNNER_TEMP/cybexos-signing.XXXXXXXX") + printf 'RELEASE_WORK=%s\n' "$work" >> "$GITHUB_ENV" + - name: Prepare Fedora 44 RPM signing tools without private key access + run: | + docker build --tag cybexos-release-signing:local - <<'DOCKERFILE' + FROM fedora:44 + RUN dnf -y --setopt=install_weak_deps=False install python3 rpm rpm-sign createrepo_c gnupg2 tar gzip && dnf clean all + DOCKERFILE + - name: Download the exact qualified build and reports + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: cybexos-qualified-desktop + path: ${{ env.RELEASE_WORK }}/qualified + - name: Sign RPM and prepare verified release assets + env: + RELEASE_SIGNING_KEY: ${{ secrets.CYBEXOS_RPM_SIGNING_KEY }} + run: | + set -euo pipefail + test -n "$RELEASE_SIGNING_KEY" + # The key enters only this ephemeral hosted container. Its value is + # passed through the environment, never arguments or shell tracing. + docker run --rm --init --interactive \ + --name "cybexos-signing-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + --env RELEASE_SIGNING_KEY --env RELEASE_TAG --env GITHUB_REPOSITORY \ + --env PYTHONDONTWRITEBYTECODE=1 \ + --volume "$GITHUB_WORKSPACE:/source:ro" \ + --volume "$RELEASE_WORK:/release-work" \ + --workdir /source cybexos-release-signing:local bash -s <<'SIGN' + set -euo pipefail + keyhome=/release-work/signing + install -d -m 0700 "$keyhome" + trap 'gpgconf --homedir "$keyhome" --kill all; rm -rf -- "$keyhome"' EXIT + printf '%s' "$RELEASE_SIGNING_KEY" | gpg --homedir "$keyhome" --batch --import + unset RELEASE_SIGNING_KEY + fingerprint=$(python3 -c 'import json; print(json.load(open("image/channels/stable.json"))["fingerprint"])') + baseurl=$(python3 -c 'import json; print(json.load(open("image/channels/stable.json"))["baseurl"])') + image/release-repository /release-work/qualified/build/artifacts/cybexos-desktop-*.rpm \ + --output /release-work/signed --public-key image/channels/CYBEXOS-desktop.asc \ + --key "$fingerprint" --gnupghome "$keyhome" --baseurl "$baseurl" \ + --packages-baseurl "https://github.com/$GITHUB_REPOSITORY/releases/download/$RELEASE_TAG" + reports=() + for scenario in encrypted-us plain-us encrypted-nl plain-nl upgrade; do + reports+=(--qualification "/release-work/qualified/$scenario/qualification.json") + done + image/prepare-github-release --signed-repository /release-work/signed \ + --artifacts /release-work/qualified/build/artifacts \ + --output /release-work/publication --repository "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" --fingerprint "$fingerprint" "${reports[@]}" + # The container is root; artifacts must be readable by the hosted + # runner's upload action. Secret material is outside this directory. + chmod -R a+rX /release-work/publication + SIGN + - name: Retain qualified assets for the publishing job + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: cybexos-desktop-release + path: ${{ env.RELEASE_WORK }}/publication/ + if-no-files-found: error + compression-level: 0 + retention-days: 2 + - name: Remove signing material and task artifacts + if: always() + run: | + docker rm --force "cybexos-signing-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >/dev/null 2>&1 || true + docker image rm cybexos-release-signing:local >/dev/null 2>&1 || true + if [[ -n ${RELEASE_WORK:-} && $RELEASE_WORK == "$RUNNER_TEMP"/cybexos-signing.* ]]; then + sudo rm -rf -- "$RELEASE_WORK" + fi diff --git a/.github/workflows/live-image.yml b/.github/workflows/live-image.yml index 54e25fc4..506e9b75 100644 --- a/.github/workflows/live-image.yml +++ b/.github/workflows/live-image.yml @@ -2,30 +2,10 @@ name: Live image integration on: pull_request: - paths: - - image/** - - roles/apps/** - - roles/base/** - - roles/desktop/** - - roles/boot/** - - roles/dotfiles/** - - assets/** - - VERSION - - inventory/group_vars/all.yml - - .github/workflows/live-image.yml push: - paths: - - image/** - - roles/apps/** - - roles/base/** - - roles/desktop/** - - roles/boot/** - - roles/dotfiles/** - - assets/** - - VERSION - - inventory/group_vars/all.yml - - .github/workflows/live-image.yml + branches: [main] workflow_dispatch: + workflow_call: permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02c21117..82098fba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,11 +15,32 @@ concurrency: cancel-in-progress: false jobs: + prerequisites: + name: Release prerequisites + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Require reviewed license, version and older ISO + env: + RELEASE_TAG: ${{ github.ref_name }} + BASELINE_ISO: ${{ vars.CYBEXOS_BASELINE_ISO }} + run: | + set -euo pipefail + test -s LICENSE || test -s LICENSE.md + test -n "$BASELINE_ISO" + version=${RELEASE_TAG#v} + scripts/semver validate "$version" + test "$(cat VERSION)" = "$version" + source: name: Source contract # The exact job that gates main: same packages, same verified COPR key. uses: ./.github/workflows/tests.yml + image-source: + name: Image source contract + uses: ./.github/workflows/live-image.yml + vm: name: Generic Fedora VM install runs-on: ubuntu-latest @@ -32,17 +53,33 @@ jobs: if test -e /dev/kvm; then sudo chmod a+rw /dev/kvm; fi - run: ./tests/fedora-vm-convergence + iso: + name: ISO install, upgrade and recovery + needs: [prerequisites, source, image-source, vm] + uses: ./.github/workflows/desktop-release.yml + with: + tag: ${{ github.ref_name }} + baseline_iso: ${{ vars.CYBEXOS_BASELINE_ISO }} + secrets: inherit + publish: name: Build, attest, and publish release asset - needs: [source, vm] + needs: [source, vm, iso] runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 60 permissions: contents: write id-token: write attestations: write steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Download qualified desktop assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: cybexos-desktop-release + path: dist/desktop-release + - name: Require a repository distribution license + run: test -s LICENSE || test -s LICENSE.md - name: Install release build dependencies run: | sudo apt-get update @@ -117,9 +154,10 @@ jobs: --generate-notes --title "CybexOS $version" "${prerelease[@]}" gh release upload "$RELEASE_TAG" \ "dist/cybexos-$version.tar.zst" \ - "dist/cybexos-$version.tar.zst.sigstore.jsonl" dist/SHA256SUMS + "dist/cybexos-$version.tar.zst.sigstore.jsonl" dist/SHA256SUMS \ + dist/desktop-release/assets/* gh release edit "$RELEASE_TAG" --draft=false - for attempt in $(seq 1 12); do + for _attempt in $(seq 1 12); do if test "$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq .immutable)" = true; then exit 0 fi @@ -127,3 +165,29 @@ jobs: done echo "GitHub did not make $RELEASE_TAG immutable after publication" >&2 exit 1 + + pages: + name: Publish signed desktop repository metadata + needs: publish + # Prerelease assets are downloadable but must never advance stable clients. + if: ${{ !contains(github.ref_name, '-') }} + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: cybexos-desktop-release + path: desktop-release + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa + with: + path: desktop-release/pages + - name: Deploy verified metadata after RPM assets exist + id: deploy + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..389ed546 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DigitalPals and CybexOS contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b6e3f063..dd3c069f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # CybexOS -CybexOS stands for **Cybex Opinionated System**. It is an opinionated Hyprland -and Quickshell desktop for Fedora Linux, installed and -kept current with Ansible. The core configuration is hardware-neutral. A -separate, precisely gated role preserves extra support for the 2026 Dell XPS -14 and 16. +CybexOS stands for **Cybex Opinionated System**. It is an opinionated Fedora +Linux desktop built around Hyprland and Quickshell. The primary installation +path is the bootable CybexOS ISO; a source-checkout installer remains available +for development and existing checkout deployments. The core configuration is +hardware-neutral. A separate, precisely gated role preserves extra support +for the 2026 Dell XPS 14 and 16. The current release target is Fedora 44 on x86_64. Fedora remains responsible for the kernel, drivers, SELinux, and base operating system. @@ -27,25 +28,46 @@ see [the operations guide](docs/operations.md#migrating-from-fedora-config). enabled by default - automatically detected XPS 2026 speaker, camera, haptic, fingerprint, backlight, firmware, and power support -- a persistent installer configuration, verifier, uninstaller, and verified - GitHub release updater +- persistent installer configuration and lifecycle tools for source-checkout + deployments, plus a verified source release updater - one release-scoped CybexOS skill discoverable by compatible coding agents for safe installed-system diagnosis and customization - a user-selectable default AI coding agent with terminal, launcher, and keyboard entry points -There is no desktop-preset selection: every installation gets the same core -Hyprland/Quickshell desktop. The installer asks only about the target machine, -security decisions, personal dotfiles, and application opt-outs. +Source-checkout installs have no desktop-preset selection: every installation +gets the same core Hyprland/Quickshell desktop. That installer asks about the +target machine, security decisions, personal dotfiles, and application opt-outs. -On an installed CybexOS desktop, apply only Omawrite and the managed file +On a source-checkout installation, apply only Omawrite and the managed file associations with the saved configuration: ```bash ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags omawrite,mime-defaults ``` -## Install +## Install from the ISO + +For a normal installation, boot the CybexOS ISO and follow the on-screen +installer. It installs the desktop RPM and offline application set, then +configures the target system without requiring a Git checkout or a network +connection for the desktop payload. The image's [installation guide](image/README.md) +describes disk requirements, encryption, first boot and recovery. Use the +release instructions in [docs/releasing.md](docs/releasing.md) for current +artifact availability and checksums. + +The installed desktop is delivered by `cybexos-desktop`; its packaged files +live under `/usr/share/cybexos`. The first-login account is configured by the +image installer, and subsequent package upgrades use the system update path. + +## Install from a source checkout + +This supported path is intended for development and existing checkout-based +deployments. For a regular new installation, use the ISO above. + +The commands below install from this repository with Ansible; they do not +install or update the ISO's `cybexos-desktop` RPM. Repository-based options and +the saved installer configuration described here apply to this checkout path. Start with Fedora 44 and a user that can run `sudo`: @@ -168,6 +190,16 @@ updates and uninstall because it is user data rather than Ansible policy. ## Update +On ISO installations, `cybex update` updates Fedora packages, Flatpaks, and +the desktop RPM when a signed update channel has been enrolled. The default +ISO configuration does not enable a desktop RPM channel. Inspect it with +`cybex update-channel status --json`; enabling one requires its reviewed public +configuration and full signing-key fingerprint. See the +[release guide](docs/releasing.md) for channel setup. + +On source-checkout installations, use the release updater described below. +These releases are separate from the ISO desktop RPM channel. + After the first install, use: ```bash @@ -203,15 +235,15 @@ Useful commands: | Command | Purpose | | --- | --- | -| `cybex update --check` | Check the configured GitHub channel | -| `cybex update --system-only` | Update Fedora and Flatpak only | +| `cybex update --check` (source checkout) | Check the configured GitHub channel | +| `cybex update --system-only` (source checkout) | Update Fedora and Flatpak only | | `cybex agent` | Launch or choose the per-user default AI coding agent | | `cybex dev status` | Show whether the verified or a development runtime is active | | `cybex plugin list` | Inspect personal widgets and API compatibility | | `cybex verify` | Check the installed system (`--source` opts into developer checks) | | `cybex doctor` | Alias for `verify` | -| `cybex configure` | Re-run the installer questions | -| `cybex uninstall` | Remove project-managed configuration; retain applications | +| `cybex configure` (source checkout) | Re-run the installer questions | +| `cybex uninstall` (source checkout) | Remove project-managed configuration; retain applications | Detailed updater status, logs, cancellation, Btrfs recovery, and advanced Ansible tags are documented in [the operations guide](docs/operations.md). @@ -253,7 +285,7 @@ Key documentation: - [Quickshell development notes](docs/quickshell-notes.md) > [!IMPORTANT] -> The repository does not yet contain a repository-wide software license. -> Select one before calling the project open source or publishing a public -> release. Undocumented bundled raster assets were removed from the release -> payload; `assets/PROVENANCE.json` enforces that boundary. +> The repository's original code and configuration are MIT-licensed. This +> license does not cover third-party packages, artwork, fonts, trademarks, or +> images. Audit those separate redistribution terms before publishing bundled +> release media; see [licensing and asset provenance](docs/licensing.md). diff --git a/agent-skills/cybexos/SKILL.md b/agent-skills/cybexos/SKILL.md index ee08bba0..7afa64ab 100644 --- a/agent-skills/cybexos/SKILL.md +++ b/agent-skills/cybexos/SKILL.md @@ -5,48 +5,52 @@ description: Operate and customize an installed CybexOS Hyprland/Quickshell work # CybexOS -Use this skill for the installed [CybexOS](https://github.com/DigitalPals/CybexOS) +Use this skill for an installed [CybexOS](https://github.com/DigitalPals/CybexOS) desktop (Cybex Opinionated System). Codex can invoke it as `$cybexos`; Claude -Code exposes the same skill as `/cybexos`. It also supports implicit -invocation through the description above. +Code exposes the same skill as `/cybexos`. Its description also supports +automatic selection. -## Establish the installation +## Identify the installation -Read the active release through -`~/.local/share/cybexos/current`. It is useful for diagnostics, command -source, schemas, and tests, but it is release-managed and read-only. +Check `rpm -q cybexos-desktop` first. On ISO installations, packaged vendor +files are under `/usr/share/cybexos`; the user's +`~/.local/share/cybexos/runtime` is a compatibility symlink to the packaged +runtime. These systems do not use `~/.local/share/cybexos/current`. -Before changing anything, choose the ownership layer: +If the desktop RPM is absent, check whether this is a source-checkout +installation. Its active release is selected by +`~/.local/share/cybexos/current`; inspect it read-only for diagnostics and +schemas. Do not assume a missing RPM means an incomplete installation. -- User shell preferences belong in - `~/.config/cybexos/shell.json`. Read +## Choose the ownership layer + +- User shell preferences belong in `~/.config/cybexos/shell.json`. Read [Quickshell settings](references/quickshell-settings.md) before editing it. - Personal bar widgets belong in user-owned plugin packages. Read [User widgets](references/user-widgets.md); use the versioned plugin API and - `cybex plugin` commands. Adding a personal widget does not require a - distro checkout, edits to built-in modules, or Ansible deployment. -- Personal Hyprland changes belong in - `~/.config/cybexos/hypr/user.lua`, loaded after vendor defaults. + `cybex plugin` commands. +- Personal Hyprland changes belong in `~/.config/cybexos/hypr/user.lua`. - Changes to distro defaults, built-in Quickshell code, services, packages, - and other release-managed behavior belong in a writable checkout. Read + and other vendor behavior belong in a writable source checkout. Read [Managed configuration](references/managed-configuration.md). For supported operator commands and desktop actions, read [Commands and desktop helpers](references/commands.md). -## Non-negotiable boundaries +## Boundaries -- Never edit `~/.local/share/cybexos/current` or anything below it. -- Never directly edit vendor files under - `~/.local/share/cybexos/runtime`. Diagnose them by reading; use - `~/.config/cybexos`, or make source changes in a writable checkout and - select it with `cybex dev enable`. +- Never edit packaged files under `/usr/share/cybexos` or files below the + source installation's `~/.local/share/cybexos/current` or + `~/.local/share/cybexos/runtime` directly. +- Do not treat `cybex repair` as a way to deploy checkout changes. On ISO + installations it reapplies the policy bundled with the installed RPM; make + vendor changes in source, rebuild/update the desktop RPM, and then use the + supported package update path. - Never add personal plugin IDs or settings to `shell.json`'s built-in `mods` - or `modOpts`; their normalizers only recognize built-in modules. Preserve - plugin packages, preferences, and state across updates and rollbacks. + or `modOpts`. Preserve plugin packages, preferences, and state across + updates and rollbacks. - Preserve unrelated checkout changes. Read every applicable `AGENTS.md` before modifying or testing a checkout. -- Do not clone a checkout unless the user agrees to the documented location. - Require explicit user intent before reconfiguration, updates, uninstall, cancellation, reboot, shutdown, reset, package removal, or another destructive operation. A diagnostic request authorizes inspection, not a diff --git a/agent-skills/cybexos/references/commands.md b/agent-skills/cybexos/references/commands.md index 42e660ad..002e2a51 100644 --- a/agent-skills/cybexos/references/commands.md +++ b/agent-skills/cybexos/references/commands.md @@ -1,23 +1,46 @@ # Commands and desktop helpers -Prefer installed commands over reconstructed shell pipelines. Read their -active source under `~/.local/share/cybexos/current` or run their help -before using an unfamiliar option. +Prefer installed commands over reconstructed shell pipelines. On RPM systems, +packaged command sources live under `/usr/share/cybexos`; on source-checkout +systems, inspect the active release under `~/.local/share/cybexos/current` or +run help before using an unfamiliar option. ## CybexOS -- `cybex version` reports the active release. -- `cybex verify` and `cybex doctor` run non-destructive - installed-system checks. Add `--source` only when repository/developer checks - are intended. -- `cybex update --check` checks the configured release channel. +- `cybex version` reports the active desktop release. +- `cybex doctor --json` runs read-only installed-system diagnostics. +- `cybex update-channel status --json` reports the configured RPM update + channel. This does not start an update. +- On source-checkout installations, `cybex update --check` checks the + configured release channel. ISO installations use + `cybex update-channel status --json` for read-only RPM channel status; + `cybex update --check` is not supported there. +- `sudo /usr/libexec/cybexos-reconcile --status` inspects pending versioned + account and machine policy reconciliation; `--retry` requests a retry. + RPM upgrades defer that work to `cybexos-reconcile.service` and its timer. +- On source-checkout installations, `cybex verify` and `cybex doctor` provide + installed checks; add `--source` only when repository/developer checks are + intended. - `cybexos-update-run status --json`, `log-dir`, and `read-log` inspect a - durable update without starting one. + durable source-checkout update without starting one. -An actual `cybex update`, `configure`, `install`, or `uninstall` needs -explicit user intent. So do `cybexos-update-run cancel`, reboot, -shutdown, and recovery/reset operations. Do not infer authorization from a -request to diagnose or check status. +A public RPM channel is enabled only after reviewing and verifying its public +configuration and key fingerprint. The explicit enrollment command is: + +```bash +sudo cybex update-channel enroll /path/to/public.json \ + --fingerprint FULL_OPENPGP_PRIMARY_FINGERPRINT +``` + +Add `--check` to validate the channel without enabling it. The default public +configuration path is `image/channels/stable.json` in the source tree. Enrollment +pins the public key and signed metadata before updates are enabled. Never use a +private signing key on the installed workstation. + +An actual `cybex update`, `configure`, `install`, or `uninstall` needs explicit +user intent. So do update cancellation, reboot, shutdown, and recovery/reset +operations. Do not infer authorization from a request to diagnose or check +status. ## Desktop actions @@ -26,20 +49,17 @@ corresponding action supplies intent; otherwise explain the command rather than launching an interactive selector or sending data. - `screenshot` selects a region, saves it under `~/Pictures/Screenshots`, and - copies it. `screenshot fullscreen` captures the focused monitor. A - notification offers Satty editing. + copies it. `screenshot fullscreen` captures the focused monitor. - `screen-record` toggles a selected-region recording. The first call starts; - the next verified call stops and saves under `~/Videos/Screen Recordings`. + the next call stops and saves under `~/Videos/Screen Recordings`. - `screen-ocr` selects a region and copies recognized English text to the clipboard. - `quickshell-reminder add MINUTES [MESSAGE]` schedules a persistent reminder. - Use `list --json`, `cancel ID`, or `clear` for management. Convert natural - language durations to a positive whole number of minutes and preserve the - user's message. -- `localsend` launches/passes arguments to the LocalSend Flatpak. - `localsend-share clipboard`, `localsend-share file [PATH...]`, and - `localsend-share folder [PATH...]` send through its headless interface; - omitted paths open an interactive chooser. + Use `list --json`, `cancel ID`, or `clear` for management. +- `localsend` launches the LocalSend Flatpak. `localsend-share clipboard`, + `localsend-share file [PATH...]`, and `localsend-share folder [PATH...]` + send through its headless interface; omitted paths open an interactive + chooser. Screen capture, recording, OCR, reminders, and LocalSend are user-visible or externally consequential. Report cancellation or command failure accurately; diff --git a/agent-skills/cybexos/references/managed-configuration.md b/agent-skills/cybexos/references/managed-configuration.md index 2aa9f06a..4426b6ff 100644 --- a/agent-skills/cybexos/references/managed-configuration.md +++ b/agent-skills/cybexos/references/managed-configuration.md @@ -1,10 +1,18 @@ # Managed Hyprland and Quickshell changes Use this guide for persistent behavior owned by CybexOS: Hyprland, -Quickshell source, services, packages, launchers, or Ansible policy. Personal -widgets use [the user widget API](user-widgets.md), and personal Hyprland -overrides use `~/.config/cybexos/hypr/user.lua`. Those changes do not need -a distro fork. Do not edit deployed vendor copies or the active release tree. +Quickshell source, services, packages, launchers, or provisioning policy. +Personal widgets use [the user widget API](user-widgets.md), and personal +Hyprland overrides use `~/.config/cybexos/hypr/user.lua`. Those changes do +not need a distro fork. Never edit deployed vendor copies. + +ISO installations receive vendor files in the `cybexos-desktop` RPM under +`/usr/share/cybexos`; `~/.local/share/cybexos/runtime` points to its runtime. +There is no `~/.local/share/cybexos/current` release link on this installation +type. Persistent vendor changes for an ISO installation must be made in a +source checkout, included in a rebuilt desktop RPM, and delivered through the +RPM update channel. `cybex repair` reapplies policy bundled in the installed +RPM; it does not deploy files from a checkout. ## Find a writable checkout @@ -23,30 +31,33 @@ a distro fork. Do not edit deployed vendor copies or the active release tree. 4. Read the repository root `AGENTS.md` and any nearer `AGENTS.md` files before acting. -Use the active release read-only when no source change is needed. It contains -the exact deployed command and schema sources and is safer evidence than -memory. +Use the packaged source under `/usr/share/cybexos` read-only to inspect an ISO +installation. On a source-checkout installation, use its active release +read-only when no source change is needed. These are different deployment +paths; do not run checkout Ansible against an ISO installation as a way to +apply RPM-managed files. ## Change and deploy Keep the edit in the smallest managed source file. Check the worktree before and after, and do not reformat, delete, stage, or restore unrelated changes. -Run the repository gate before deployment: +Run the repository gate before a source-checkout deployment: ```bash ./tests/run ``` -Preview the machine change with the saved installer contract when practical: +For source-checkout installations, preview the machine change with the saved +installer contract when practical: ```bash ansible-playbook site.yml -e @/etc/cybexos/config.yml --check --diff ``` -Deploy through Ansible, choosing only a documented narrow tag when its -prerequisites are already present. Hyprland and Quickshell are normally in the -`desktop` role: +Deploy to a source-checkout installation through Ansible, choosing only a +documented narrow tag when its prerequisites are already present. Hyprland and +Quickshell are normally in the `desktop` role: ```bash ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags desktop diff --git a/docs/architecture/ownership.md b/docs/architecture/ownership.md index 0b07b8b3..dff53810 100644 --- a/docs/architecture/ownership.md +++ b/docs/architecture/ownership.md @@ -1,40 +1,44 @@ # CybexOS ownership boundary -CybexOS updates replace vendor runtime and integration files. They do -not merge into user customization trees. This is the machine-enforced boundary -for the transitional, pre-RPM layout. +CybexOS has two supported installation paths. ISO installations receive vendor +files through the `cybexos-desktop` RPM, installed under +`/usr/share/cybexos`; source-checkout installations use versioned releases and +may opt into a writable development checkout. In both paths, user preferences +and personal packages remain in user-owned locations. | Owner | Path | Update behavior | | --- | --- | --- | -| Vendor | `~/.local/share/cybexos/runtime/quickshell/` | Reconciled exactly from a verified release | -| Vendor | `~/.local/share/cybexos/runtime/hypr/` | Reconciled from rendered defaults in a verified release | -| Vendor | `~/.local/share/cybexos/releases/` and `current` | Staged and atomically selected by the release updater | -| User | `~/.config/cybexos/shell.json` | Read and written by the shell; never written by Ansible after a one-time, non-overwriting legacy copy | +| Vendor, ISO | `/usr/share/cybexos/` | Owned by `cybexos-desktop`; replaced by RPM upgrades | +| Vendor, ISO | `~/.local/share/cybexos/runtime` | Compatibility symlink to `/usr/share/cybexos/runtime` | +| Vendor, source checkout | `~/.local/share/cybexos/runtime/` | Reconciled from the selected verified release | +| Vendor, source checkout | `~/.local/share/cybexos/releases/` and `current` | Staged and atomically selected by the source updater | +| User | `~/.config/cybexos/shell.json` | Shell preferences; preserved by package/release updates | | User | `~/.config/cybexos/hypr/` | Optional `user.lua`, `hypridle.conf`, and `hyprlock.conf` overrides | -| User | `~/.config/cybexos/displays.json` | Settings → Displays choices per physical monitor; written only by that page after a confirmed trial, read by the vendor `displays.lua`, never written by Ansible | -| User | `~/.local/share/cybexos/themes/` | Reserved user theme packages; never reconciled or pruned | -| User | `~/.local/share/cybexos/plugins/` | API 1 widget packages; never reconciled or pruned | -| User | `~/.config/cybexos/plugins.json` | Separate widget enablement and preferences; never written by Ansible | +| User | `~/.config/cybexos/displays.json` | Settings → Displays choices per physical monitor | +| User | `~/.local/share/cybexos/themes/` | User theme packages; not reconciled or pruned | +| User | `~/.local/share/cybexos/plugins/` | API 1 widget packages; not reconciled or pruned | +| User | `~/.config/cybexos/plugins.json` | Widget enablement and preferences; not written by Ansible | | User | `~/.local/share/cybexos/plugin-data/` | Persistent widget data; retained on update and uninstall | | State | `~/.local/state/cybexos/` | Health, update, migration, and shell runtime state | +| State, machine | `/var/lib/cybexos/` | Reconciliation state, backups, and hardware setup status | -The session always starts Hyprland with the vendor entry point. Vendor modules -load first, then the saved Settings → Displays choices; -`~/.config/cybexos/hypr/user.lua`, when present, loads last. Bindings it -adds with a `Group: Label` description appear in the Super+K cheatsheet -([keyboard shortcuts](../keyboard-shortcuts.md)). -The idle and lock services prefer their same-named user configuration files -and otherwise use vendor defaults. A bad user override may break that component -but is never silently replaced by an update. Without a user `hypridle.conf`, -the idle service applies the timeouts from Settings → System → Idle: the -runtime resolver renders them from `shell.json` into -`$XDG_RUNTIME_DIR/cybexos/hypridle.conf` at each start (falling back to -the vendor file), and the shell restarts `hypridle.service` after a change is -saved. Without a user `hyprlock.conf`, the lock uses the lock screen the -system theme renders in the shell's colours, font and wallpaper -(`~/.local/state/cybexos/theme/hyprlock.conf`, state rather than -configuration) when that file is present and carries the renderer's header, -and the vendor file otherwise (docs/system-theme.md). +On the RPM path, package upgrades defer versioned account and machine policy +updates to `cybexos-reconcile.service` and its timer. Reconciliation preserves +existing edits by backing up files before updating them; inspect status with +`sudo /usr/libexec/cybexos-reconcile --status` and request a retry with +`sudo /usr/libexec/cybexos-reconcile --retry`. The user initialization payload +uses versioned defaults, separately from unversioned tool-seed data. Hardware +setup status is recorded at `/var/lib/cybexos/hardware-status.json` as +`pending`, `completed`, or `failed`; a pending camera setup resumes after a +same-kernel reboot. The welcome window displays this status. + +On the source-checkout path, the session starts Hyprland with the vendor entry +point. Vendor modules load first, then saved Settings → Displays choices, and +`~/.config/cybexos/hypr/user.lua`, when present, loads last. Bindings it adds +with a `Group: Label` description appear in the Super+K cheatsheet +([keyboard shortcuts](../keyboard-shortcuts.md)). The idle and lock services +prefer their same-named user configuration files and otherwise use vendor +defaults. User overrides are not silently replaced by release updates. Quickshell starts with an explicit `qs -p` path. The legacy `~/.config/quickshell` and `~/.config/hypr` trees are not runtime inputs after @@ -46,42 +50,45 @@ translated automatically. ## Development source switch -`cybex dev enable /absolute/path/to/checkout` selects live Quickshell -sources and static Hyprland modules from a validated, user-owned Git checkout. -Rendered machine modules continue to come from the installed runtime. The -command records only the canonical path and reloads managed desktop components; -it never fetches, resets, merges, commits, or writes inside the checkout. +`cybex dev enable /absolute/path/to/checkout` selects live Quickshell sources +and static Hyprland modules from a validated, user-owned Git checkout on the +source-checkout path. Rendered machine modules continue to come from the +installed runtime. The command records only the canonical path and reloads +managed desktop components; it never fetches, resets, merges, commits, or +writes inside the checkout. -Use `cybex dev status` to show the active source and -`cybex dev disable` to return to the verified vendor runtime. Internet -updates continue to stage and activate releases while development mode is on; -they do not modify the selected checkout or user-owned paths. +Use `cybex dev status` to show the active source and `cybex dev disable` to +return to the verified vendor runtime. Internet updates continue to stage and +activate releases while development mode is on; they do not modify the selected +checkout or user-owned paths. ISO installations use RPM updates instead of this +release switch. ## Enforcement rules -- Deployment may prune only a vendor-owned runtime root. +- ISO package upgrades own files under `/usr/share/cybexos`; do not edit those + deployed vendor files in place. +- Source deployment may prune only its vendor-owned runtime root. - Normal convergence must not copy, template, link, or remove children below - the user-owned roots in the table, except to create an absent directory or - perform an explicitly non-overwriting legacy migration. + user-owned roots, except to create an absent directory or perform an + explicitly non-overwriting legacy migration. - Uninstall removes vendor runtime and integration artifacts, not user-owned Quickshell, Hyprland, theme, or plugin trees. -- The ownership-preservation test runs in the source and release gates and - simulates an N to N+1 update with byte-for-byte user sentinels. +- Ownership-preservation checks simulate updates with user data sentinels. ## Customization compatibility File ownership and runtime compatibility are separate requirements. The [user widget API](user-widgets.md) gives personal QML a versioned interface, independent preferences, and real-engine compatibility fixtures. Agents use -that interface for personal widgets; a distro checkout is for changing the +that interface for personal widgets; a source checkout is for changing the vendor implementation. A future refactor must retain supported API adapters. The distro-wide target is vendor defaults followed by explicit user choices. -New defaults apply to settings without an explicit choice; they must not -erase user choices even when those choices equal an old default. Migration -must preserve unknown fields, retain a recoverable original, and avoid -downgrading data on rollback. A new API or schema needs a compatibility plan -and upgrade/rollback fixtures before it is released. +New defaults apply to settings without an explicit choice; they must not erase +user choices even when those choices equal an old default. Migration must +preserve unknown fields, retain a recoverable original, and avoid downgrading +data on rollback. A new API or schema needs a compatibility plan and +upgrade/rollback fixtures before it is released. That target is not yet enforced for every application. Remaining work: @@ -96,7 +103,7 @@ That target is not yet enforced for every application. Remaining work: - Includes need application-specific precedence tests. Git and Kitty commonly use later values; SSH commonly uses the first obtained value. The current SSH include at the beginning can take precedence over personal choices. -- Extend release tests beyond file sentinels: verify settings behavior, an +- Extend release checks beyond file sentinels: verify settings behavior, an enabled API fixture, service overrides, app defaults, failed updates, and rollback against supported previous releases. Preserve user-created package and service additions when optional distro features change. diff --git a/docs/iso-releases.md b/docs/iso-releases.md new file mode 100644 index 00000000..620dc540 --- /dev/null +++ b/docs/iso-releases.md @@ -0,0 +1,206 @@ +# ISO and desktop RPM releases + +CybexOS ISO releases and source archive releases share a version tag but have +separate build gates and artifacts. A trusted Debian 13 PXE runner builds the +ISO and desktop RPM and exercises the installer in disposable guests. It +transfers only the build artifacts and qualification reports. A separate +GitHub-hosted signing job prepares the signed desktop assets; private signing +material never goes to the PXE host. Signed RPM repository metadata is deployed +to GitHub Pages after the GitHub Release has been published and confirmed +immutable. + +The desktop update endpoint is +[`https://digitalpals.github.io/CybexOS/44/x86_64`](https://digitalpals.github.io/CybexOS/44/x86_64). +Pages serves `update-channel.json`, the public key, signed release and +`repodata`; the RPMs themselves are assets of the matching immutable GitHub +Release. The channel configuration is [stable.json](../image/channels/stable.json), +which pins signing fingerprint +`16C60642B7278AECE3A933C354220839FDF7099E`. + +## Repository and runner setup + +The GitHub repository already has immutable releases enabled and GitHub Pages +configured. The Pages site is empty until a release is published. The +`desktop-release` Actions environment and its `CYBEXOS_RPM_SIGNING_KEY` secret +are configured. The secret contains the RPM signing subkey and is used only by +the GitHub-hosted Fedora 44 signing job. The PXE runner receives no private +signing material. The primary private key remains in a protected local +keyring; move it to offline custody before public release. Never print or copy +signing material into the checkout or expose it in workflow arguments or +logs. + +Before the first public release: + +- The repository includes its MIT license. This covers repository code; it + does not settle redistribution rights for third-party software and bundled + assets. Complete that audit before public distribution; see + [licensing and asset provenance](licensing.md). +- Use the trusted Debian 13 x86_64 PXE operator account with `/dev/kvm`, + at least 180 GiB staging space and 24 GiB available RAM, the + `image/build --preflight` dependencies, a Chromium browser, write access to + `/data/pxe/iso`, and an active `iventoy.service`. The workflow provides Node + 24 and isolated `playwright-core`. The operator also needs authenticated + `gh` access with permission to manage this repository's runners, Python + 3.12 or newer, the GitHub runner's native .NET dependencies, and a reachable + systemd user manager. Keep signing material on GitHub's hosted signing job. +- Repository variable `CYBEXOS_BASELINE_ISO` is configured as + `/data/pxe/iso/CybexOS-Live-44-20260926T055804Z-dbdd33d6.iso`. Keep this older + supported ISO and its matching `.sha256` sidecar in place. The qualification + job requires a regular, checksum-verified ISO under `/data/pxe/iso`. +- Start an ephemeral runner only for the reviewed queued run below, and pass all release gates. The qualification + has to pass on the exact release build; configuring Pages, the baseline + variable, and signing environment alone does not make a public desktop + channel available. + +The PXE machine does not keep a public-repository runner listening. Once a +reviewed `release.yml` or `desktop-release.yml` run has its qualification job +queued, use a clean checkout whose `HEAD` exactly matches that run. From the +unprivileged PXE operator's authenticated session: + +```bash +gh auth status --hostname github.com +systemctl --user show --property=Version --value +run_id=REVIEWED_RUN_ID +./image/release-runner --run-id "$run_id" +./image/release-runner --run-id "$run_id" --execute \ + --work-root /data/cybexos-runners +``` + +The first helper command only validates the API run, job, source commit and +checkout. `--execute` downloads the current Linux x64 runner with the SHA-256 +pin supplied by GitHub's API and registers one ephemeral runner. Its only +label is `cybexos-iso-RUN_ID`; it has no generic `self-hosted`, `linux`, `x64`, +or `cybexos-iso` labels. The workflow requests that exact run-specific label. +The helper refuses pull requests, other source repositories, non-release +workflows, refs outside `main` or version tags, dirty or mismatched checkouts, +an existing runner for the run, or another queued job targeting its label. +No runner has been registered by this setup; registration is an explicit +operator action when a reviewed job is queued. + +The listener runs in a transient user systemd service with a seven-hour limit +(`--timeout` accepts 60 seconds through eight hours). Cancellation allows +120 seconds for job cleanup, then systemd terminates the complete service +cgroup, including build and VM processes that created separate sessions. +The helper stops and verifies this exact unit before unregistering the runner +and removing its unique task directory. If it cannot confirm the unit stopped, +it reports and retains that directory for diagnosis instead of deleting live +VM files. A failed API cleanup reports the exact registration to remove. +The registration token stays out of command arguments and logs, and the runner +gets an isolated home and environment without the operator's GitHub tokens. + +This is a trusted-code runner, not a sandbox. Do not approve or queue untrusted +workflows while it is active: someone allowed to execute a workflow could +intentionally request the known run-specific label. Signing keys are available +only to the separate GitHub-hosted `sign` job and never to this PXE listener. + +The release checks the source contract, image-source contract, and a +twice-converged generic Fedora VM before calling +[desktop-release.yml](../.github/workflows/desktop-release.yml) on the trusted +PXE runner. Its `qualify` job builds the ISO with the public stable channel, +publishes the completed testing ISO and checksum to iVentoy, and runs five +QEMU guests with isolated disposable disks. It uploads only the raw build +artifacts and bounded qualification reports as `cybexos-qualified-desktop` for +two days. The `qualify` job also retains compact qualification evidence for +fourteen days. The separate GitHub-hosted `sign` job downloads that exact +artifact and produces `cybexos-desktop-release`, retaining the prepared assets +for two days. The signing job needs a +Docker-capable GitHub-hosted runner with at least 24 GiB free staging space. + +| Scenario | Encryption | Keyboard and locale | Timezone | +| --- | --- | --- | --- | +| `encrypted-us` | LUKS | US / `en_US.UTF-8` | UTC | +| `plain-us` | Off | US / `en_US.UTF-8` | UTC | +| `encrypted-nl` | LUKS | Dutch / `nl_NL.UTF-8` | Europe/Amsterdam | +| `plain-nl` | Off | Dutch / `nl_NL.UTF-8` | Europe/Amsterdam | + +Each of the four fresh scenarios boots the exact candidate ISO and completes +the graphical installer. A fifth guest uses the older baseline ISO, creates a +recovery point, applies the exact candidate desktop RPM, then boots and restores +that pre-upgrade point and verifies that the baseline RPM is active again. This +older-image upgrade and recovery qualification is separate from the four +fresh-install scenarios. + +A release is rejected unless qualification reports identify the SHA-256 of the +exact ISO and candidate RPM and all five scenarios pass. Reports are retained +as bounded workflow artifacts; the desktop assets are retained briefly for the +publishing job. Do not treat source fixtures, a local RPM build, or an earlier +ISO boot as a substitute for these release gates. + +## Published assets and ISO reconstruction + +The publishing job creates the tagged source archive and its offline-verifiable +provenance bundle, then uploads those with the desktop release assets and +`SHA256SUMS` to a draft GitHub Release. It publishes the draft after uploading +all assets, then polls until GitHub marks the release immutable; failure to +confirm immutability fails the workflow after publication. Only after this job +passes does a stable tag deploy signed metadata to Pages. A prerelease tag +publishes downloadable assets but does not update the stable Pages repository. +The ISO is split into files below GitHub's per-asset size limit. `assets/` also +contains the part manifest, original ISO SHA-256 file, reconstruction script, +qualification reports, signed RPMs, and `desktop-SHA256SUMS`. + +Download `desktop-SHA256SUMS` and every file it names into one directory, then +verify and reconstruct without replacing an existing output: + +```bash +sha256sum -c desktop-SHA256SUMS +python3 reconstruct-iso.py CybexOS-Live-44-BUILD-ID.iso.parts.json +sha256sum -c CybexOS-Live-44-BUILD-ID.iso.sha256 +``` + +The reconstruction tool checks each ordered part against the JSON manifest, +then checks the complete ISO size and SHA-256 before publishing the output. +It refuses to overwrite an existing ISO. Keep the manifest, parts, and helper +in the same directory. Check the signing fingerprint independently before +trusting the RPM or channel metadata. + +After the immutable GitHub Release is available, the Pages job deploys the +signed metadata snapshot. Metadata points each package URL at that release's +RPM asset, so Pages never needs to host the large RPM. Publication is ordered: +the Pages deployment waits until the immutable Release and RPM assets exist. +Until both jobs complete successfully, the channel is not ready for enrollment. + +## Enroll the installed RPM channel + +An ISO built without `--update-channel` bundles a disabled channel. The release +gate explicitly enables the pinned stable channel. Check the installed system +with read-only diagnostics; channel status describes local configuration and +does not test remote availability: + +```bash +cybex doctor --json +cybex update-channel status --json +``` + +For enrollment, obtain `update-channel.json` and `CYBEXOS-desktop.asc` from the +same published release's verified assets, keeping them together in one +folder. Review the URL and compare the configuration's full fingerprint with +the independently trusted value above. Validate connectivity and signatures +without changing the system: + +```bash +cybex update-channel enroll /path/to/release/update-channel.json \ + --fingerprint 16C60642B7278AECE3A933C354220839FDF7099E --check +``` + +When the check succeeds, explicitly enable the repository: + +```bash +sudo cybex update-channel enroll /path/to/release/update-channel.json \ + --fingerprint 16C60642B7278AECE3A933C354220839FDF7099E +cybex update-channel status --json +``` + +Enrollment verifies the public key fingerprint and signed repository metadata, +then pins the public key and repository configuration locally. It does not +install an update. The ordinary system package update path can install the +signed desktop RPM once the channel is ready. `cybex update --check` is a +source-checkout command and is not supported for ISO installations. Key +rotation requires a separately reviewed migration; do not enroll a different +fingerprint as a routine update. + +The current GitHub repository has not published its first public release and +the Pages endpoint is still empty. The repository has selected the MIT +License for its code, but the bundled software and asset redistribution audit +still applies. The explicitly started ephemeral runner and complete release workflow must +pass before signed desktop metadata is available to users. diff --git a/docs/licensing.md b/docs/licensing.md index 93feb2d8..514fd732 100644 --- a/docs/licensing.md +++ b/docs/licensing.md @@ -1,17 +1,12 @@ # Licensing and asset provenance -There is currently no repository-root `LICENSE` or `COPYING` file. Repository -visibility and a Git commit history do not themselves grant permission to copy, -modify, or redistribute the original configuration code. This document records -that boundary; it does not choose a software license on the owner's behalf. +The repository's original code and configuration are licensed under the MIT +License; see the repository-root `LICENSE`. That grant applies to CybexOS +copyrighted code and does not replace licenses or permissions for third-party +software, assets, product names, or marks included in a source archive, ISO, or +RPM. -## Repository code and configuration - -The owner must choose the intended terms, confirm that every contributor can -license their contribution on those terms, and add the corresponding canonical -license text at the repository root. If different directories need different -terms, add unambiguous per-directory notices and a root summary. Until then, -downstream users should not infer an open-source license. +## Third-party software and branding Files copied or downloaded from other projects remain under their upstream terms. In particular: @@ -23,10 +18,13 @@ terms. In particular: - the Cybex role checks out a pinned upstream artwork revision and then overlays repository-local theme files; and - product names, logos, and brand SVGs may also be subject to trademark rules, - independently of any software license eventually selected here. + independently of the MIT License. -A repository-wide software license must not be presented as relicensing those -third-party materials. +A public source archive, desktop RPM, or ISO bundles more than original CybexOS +code. Complete and document the software and asset redistribution audit for the +actual release payload before public distribution. The MIT License does not +itself grant rights to redistribute third-party packages, artwork, fonts, +wallpapers, or trademarks. ## Repository assets diff --git a/docs/releasing.md b/docs/releasing.md index 542b5f5d..c486c8d9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,20 +1,47 @@ # Publishing CybexOS releases -Public updates are built from semantic-version Git tags by -`.github/workflows/release.yml`. The workflow will not publish unless the full -source contract and a twice-converged generic Fedora VM both pass. +CybexOS has two release surfaces. Source-checkout releases publish a +versioned source archive; ISO releases publish a signed desktop RPM, bootable +ISO, and RPM update metadata. Both use a semantic-version Git tag and share +the repository source contract and generic Fedora VM gate. The release also +runs the image-source contract, then requires trusted PXE-host qualification +before publication. See [ISO and desktop RPM releases](iso-releases.md) for +runner setup, signing, the installer matrix, reconstruction, and channel +enrollment. -## One-time repository setup +The source archive is published by `.github/workflows/release.yml` after all +required jobs pass. Its prerequisite job checks the tag against `VERSION`, +requires a non-empty `CYBEXOS_BASELINE_ISO` repository variable and a root +license file, and validates semantic versioning. The ISO job additionally +validates that the baseline file and checksum sidecar exist on the PXE host. -1. Enable immutable releases in the GitHub repository settings. -2. Keep Actions permitted to create attestations and write release contents; - the workflow grants only those job-level permissions. -3. Protect the default branch and require the source and generic-VM checks. +## Source archive setup + +1. Immutable GitHub Releases are enabled for the repository. Keep Actions + permitted to create attestations and write release contents; the workflow + grants only those job-level permissions. +2. The `desktop-release` environment and its `CYBEXOS_RPM_SIGNING_KEY` secret + provide signing access only to the GitHub-hosted signing job. The PXE + qualification runner never receives that secret. The `github-pages` + environment deploys stable repository metadata after release publication. +3. Keep `CYBEXOS_BASELINE_ISO` set to the verified older ISO and configure the + on-demand trusted self-hosted PXE runner as described in + [the ISO release guide](iso-releases.md). +4. Branch protection requires pull requests, up-to-date branches, and the + GitHub Actions checks `Fedora 44 source contract` and `Image tooling, + installer fixtures and account isolation`. Admins are subject to the rule; + force pushes and branch deletion are disabled. The generic Fedora VM is a + release-tag gate, not a required branch check. The updater refuses a release when GitHub reports `immutable: false`, even if the archive checksum is otherwise correct. -## Release checklist +GitHub Pages is configured at the desktop update URL but is currently empty; +the first stable ISO release workflow will deploy signed metadata there. +Prerelease tags publish downloadable assets but do not update the stable Pages +repository. + +## Source archive release checklist 1. Review `release-manifest.json`, `VERSION`, the Fedora release, configuration schema, minimum updater version, and all dependency pins. @@ -45,7 +72,7 @@ deployed from the failed candidate stay in place; the run records `~/.local/share/cybexos/current/install` restores the previous release's files in the meantime. -## What the workflow publishes +## What the source workflow publishes The release contains a versioned source archive, its provenance bundle `cybexos-VERSION.tar.zst.sigstore.jsonl`, and `SHA256SUMS`. The checksum @@ -71,6 +98,9 @@ SHA-256 digest form the updater trust boundary. Updating needs no GitHub login, and a release without its bundle is refused. Files copied from an arbitrary branch or mutable URL are not accepted as updates. -Until the first release is published, `cybex update --check` reports that no -release exists on the channel, and updates install Fedora and Flatpak -packages only. That is expected, not a failure. +Until a source archive release is published, source-checkout +`cybex update --check` reports that no release exists on its channel. On ISO +installations, use `cybex update-channel status --json`; that command's channel +status is separate from source archive discovery. See [ISO and desktop RPM +releases](iso-releases.md) for channel enrollment after the first stable +desktop release has been published. diff --git a/docs/xps-2026-hardware.md b/docs/xps-2026-hardware.md index 0f886467..19887124 100644 --- a/docs/xps-2026-hardware.md +++ b/docs/xps-2026-hardware.md @@ -109,11 +109,12 @@ OV08X40 sensor driver, and `intel/ipu/ipu7ptl_fw.bin`. Those remain owned by the normal Fedora kernel and `intel-vsc-firmware` packages. Verification rejects an out-of-tree replacement for either `intel_ipu7` or `intel_ipu7_isys`. -Two missing hardware companions use DKMS: +Missing hardware companions use DKMS: - Intel IPU7 PSYS, compiled in PSYS-only mode against Fedora's stock core ABI. -- Intel CVS, which acquires Panther Lake's camera power/ownership path before - the OV08X40 sensor probes. +- Intel CVS only on kernels which do not supply it. Fedora's native CVS is + retained when present; its runtime PM and media bridge manage camera + ownership. The legacy CVS DKMS build refuses to shadow a native module. RPM Fusion's `akmod-v4l2loopback` supplies the third out-of-tree module. Intel's HAL, redistributable IPU75XA libraries, and `icamerasrc` are built into the @@ -142,8 +143,19 @@ BTF with `pahole` and compares the private `ipu7_device`, `ipu7_bus_device`, and tied to an exact kernel version. If Fedora changes that internal ABI, it emits `IPU7_STOCK_ABI_CHANGED` and refuses the optional camera build/start while leaving the stock modules and normal kernel update path untouched. -It also refuses DKMS if a future Fedora kernel starts shipping PSYS or CVS -itself, preventing the optional bundle from shadowing a new native companion. +The guard also covers callback, shared queue, and firmware boot structures. +The reviewed Fedora 7.2 layout accounts for the public `auxiliary_device` +growing by eight bytes: the PSYS build uses the target kernel headers and the +private structure offsets are checked as a complete signature. Unrecognized +layouts still fail closed. It refuses PSYS DKMS if Fedora starts shipping +PSYS itself. Native CVS does not require replacement or an extra reboot when +installing PSYS for the first time. + +With native CVS the graph is `OV08X40 -> Intel CVS -> IPU7 CSI2`. A narrow HAL +patch resolves the sensor's actual I2C address across this bridge and programs +its sink/source formats and links. Older direct sensor-to-CSI configurations +remain supported. Merely registering the sensor does not prove frame capture; +validate processed frames after installation. The complete build-input hash is embedded in the RPM release, its installed manifest, and both DKMS package versions. Consequently, changing a source pin, @@ -236,6 +248,12 @@ smoke test explicitly: XPS_CAMERA_FRAME_TEST=1 /usr/local/libexec/xps-ipu7-camera-check ``` +The frame check skips the relay's initial splash buffers and verifies three +complete, changing images in memory; it saves no images. An identical/covered +scene can make this proof inconclusive. The relay disables its producer's +last-sample retention so GStreamer mmap consumers copy and immediately requeue +loopback buffers across the splash-to-camera transition. + The Fedora package's unused generic `icamerasrc` generator trigger is removed on this hardware; the role's `ipu7` relay is the only camera relay instance. The suspend hook stops only the userspace relay before sleep and restarts it diff --git a/image/Containerfile.tests b/image/Containerfile.tests index 2005b0f5..8847325f 100644 --- a/image/Containerfile.tests +++ b/image/Containerfile.tests @@ -1,7 +1,7 @@ FROM fedora:44 RUN dnf install -y python3-pyside6 python3-jinja2 python3-pyyaml python3-gobject-base glib2 \ ShellCheck pykickstart qt6-qtdeclarative-devel desktop-file-utils \ - nodejs24 gnupg2 git ripgrep ansible-core \ + nodejs24 gnupg2 git ripgrep ansible-core rpm-build rpm-sign createrepo_c \ && dnf clean all ENV QT_QPA_PLATFORM=offscreen WORKDIR /source diff --git a/image/INSTALLER.md b/image/INSTALLER.md index c7996ab6..105203f3 100644 --- a/image/INSTALLER.md +++ b/image/INSTALLER.md @@ -84,6 +84,10 @@ These are the Fedora 44 versions used as the source reference. validation task, then review. CybexOS sets both selected disks and `DrivesToClear` to the one chosen disk. It obtains candidate disks from `GetUsableDisks` and excludes protected/non-disk devices. +- [Storage scan interface](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/storage/storage_interface.py) + provides `ScanDevicesWithTask` for an explicit rescan. A rescan invalidates + the prior review token. Existing partition details come from read-only + `lsblk`; Anaconda remains the authority for disk selection. - [Disk initialization interface](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/storage/disk_initialization/initialization_interface.py) defines the clearing scope. [PartitioningRequest](https://github.com/rhinstaller/anaconda/blob/anaconda-44.30/pyanaconda/modules/common/structures/partitioning.py) supplies Btrfs scheme `1`, LUKS2, and encryption policy. @@ -112,7 +116,9 @@ guards or invoke a real installation. | Command | Input | Result | | --- | --- | --- | -| `inventory` | `{}` | Available disks, layouts, locales, timezones and any detected one, payload space requirement | +| `inventory` | `{}` | Available disks with model, capacity, serial, WWN and existing partitions; layouts, locales, timezones and payload space requirement | +| `rescan` | `{}` | Invalidates any review, asks Anaconda to scan again, and returns a fresh inventory | +| `diagnostics` | `{}` | Allowlisted installer phase, progress, worker service state and backend readiness; no raw logs or personal data | | `geolocate` | `{}` | Timezone from Anaconda's geolocation task, or empty; changes no selection | | `keyboard` | `{"keyboard":"us"}` | Applied live keyboard and boot keymap | | `plan` | Account fields below | Review token, disk identity, account policy, disk actions, warnings | @@ -122,7 +128,8 @@ guards or invoke a real installation. | `reboot` | `{}` | Reboots only when installation state is complete | `plan` fields are `username`, `password`, `confirm`, `keyboard`, `locale`, -`timezone`, `hostname`, `disk`, and optional boolean `encrypted` (default true). +`timezone`, `hostname`, `disk`, optional boolean `encrypted` (default true), +and optional boolean `passwordless_wheel` (default false). `disk` is an Anaconda disk name such as `vda`, not an arbitrary path. `install` input is `{"token":"…","confirmed_disk":"vda","erase_confirmed":true}`. The final record is `{"event":"result","ok":true,"data":{…}}`, or @@ -130,7 +137,7 @@ The final record is `{"event":"result","ok":true,"data":{…}}`, or `worker` is an internal systemd entry point and does not accept user settings. The review token expires after 30 minutes. Before installation, the controller -rechecks disk identity, selected disks, applied partitioning, the exact set of +rechecks disk identity and existing partitions, selected disks, applied partitioning, the exact set of pending actions, and storage validation. The set is compared without order: `GetActions()` re-sorts blivet's list on every call, and its topological sort reverses independent actions each time. A rejected confirmation returns to disk @@ -154,6 +161,10 @@ token before commit, task path, and progress. The browser uses a disposable runtime profile with password saving, form history, and crash-session restore disabled. Exceptions returned to the UI exclude raw DBus parameters. The welcome launcher requests `--nosave=all_ks` to avoid saving generated account metadata. +Status and exported diagnostics expose only allowlisted phase, progress and +service fields. Anaconda's free-form task messages and journals are never +copied into the browser export because they may contain personal data. A failed +installation stays blocked pending diagnosis; the page never retries disk writes. The existing post-install hook removes the live account, temporary permissions, live installer page/helpers/browser configuration, and live services. It calls @@ -166,6 +177,11 @@ non-chroot post hook invokes `cybexos-installer-target`, which checks the actual before writing `/etc/cybexos/login.json`. This versioned nonsecret policy names the installed user, requested autologin and `live: false`. `/etc/cybexos/installation.json` records installation and keyring policy metadata. +The target hook removes any wheel sudo rule inherited from the live image. +Fresh offline configuration also defaults `passwordless_wheel` to false, so a +stock Advanced installation requires an administrator password. The guided +installer changes that policy and installs a mode-0440 sudoers rule only after +the user explicitly selects passwordless sudo and confirms the review. The temporary live account has a separate policy with `live: true` and a root-owned `/run/cybexos-live-session` marker. The login helper accepts this @@ -186,8 +202,24 @@ validation. Node fixtures cover UI state, confirmation, navigation locks, and failure recovery. Image tests load the welcome QML offscreen in both modes and drive its wallpaper row against a scripted shell. The headless browser fixture exercises the three pages with a mocked backend. - -Still required before an ISO can be called working: actual Cockpit loading and +`image/test_qualification.py` checks URL and disk identity guards without +starting a VM. + +The opt-in `image/qualify` harness drives the guest's actual Cockpit page +through a local SSH tunnel and host Playwright. It discovers the guest URL from +the live browser command line, selects only the disk with the fixed disposable +serial, and verifies that a second attached disk's image hash is unchanged. +`--scenario` selects encrypted/plain storage and US/NL (or DE) keyboard and +locale. A `--candidate-rpm` must have a newer installed RPM version; the +optional `--recovery-check` creates a point before upgrade, boots its GRUB +entry, restores it and verifies the baseline RPM returned. The report at +`OUTPUT/qualification.json` records the exact ISO and candidate RPM SHA-256, +scenario, checks and final status. Credentials are passed to the browser driver +on stdin and excluded from its output. Source tests do not establish a +successful VM installation. + +Still required before an ISO can be called working: execution and observation +of actual Cockpit loading and authorization, Fedora DBus behavior, UEFI/BIOS boot, encrypted and unencrypted installation, keyboard input at LUKS unlock, autologin, post-script ordering, offline payload completeness, driver/hardware behavior, and clean installed diff --git a/image/QUALIFICATION-2026-09-26-LIFECYCLE.md b/image/QUALIFICATION-2026-09-26-LIFECYCLE.md new file mode 100644 index 00000000..b66a7288 --- /dev/null +++ b/image/QUALIFICATION-2026-09-26-LIFECYCLE.md @@ -0,0 +1,222 @@ +# ISO lifecycle qualification, 26 September 2026 + +Status: the corrected replacement ISO passed all five UEFI QEMU scenarios +(88 checks), is checksum-verified, and is listed in iVentoy. This report does +not qualify a public release. + +## Candidate and evidence + +The candidate was built from clean source +`a991a97d099936298edfa7ad4c1747a3d0261f74` (`source_dirty=false`). Its source +archive SHA-256 is +`a0f0462f828f637c68c5dcb8233a78e5f32541223aef1b76f60cdc0d040bb1cc`. +The [build manifest](qualification-results/2026-09-26/build.json) binds the +source and artifacts. Each final report's `source_revision` records its +qualification harness revision. +The later `bd04954` change only fixes qualification-tool socket paths; it does +not change the installed ISO runtime. + +Artifacts retained on `john@10.10.0.7` for private installation and upgrade review: + +| Artifact | Path | Bytes | +| --- | --- | ---: | +| Private testing ISO | `/data/pxe/iso/CybexOS-Live-44-20260926T150946Z-99900b99.iso` | 7,633,059,840 | +| Matching unsigned testing RPM | `/data/cybexos-candidate-rpm-20260926-a991a97/cybexos-desktop-0.0.0~dev-1.20260926150946.ga991a97d0999.fc44.x86_64.rpm` | 1,720,330,844 | + +Each has an adjacent SHA-256 sidecar (112 bytes for the ISO; 139 bytes for +the RPM). Six compact JSON files in `image/qualification-results/2026-09-26/` +retain 8,929 bytes of build provenance and final qualification evidence. Digests: + +```text +ISO 7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635 +RPM e42fa8fdab34eab40d32e93fea9b8c9399abdff1c5be40d8f60207984c41ed8b +``` + +Build and served-copy checksums passed. iVentoy refresh returned success, +the new filename was listed, PXE reported running, and the service was active. + +| Final UEFI scenario | Status | +| --- | --- | +| Unencrypted US | Passed: [12 checks](qualification-results/2026-09-26/plain-us.json), harness `bd04954` | +| Encrypted Dutch | Passed: [25 checks](qualification-results/2026-09-26/encrypted-nl.json), harness `bd04954` | +| Encrypted US | Passed: [25 checks](qualification-results/2026-09-26/encrypted-us.json), harness `a991a97` | +| Unencrypted Dutch | Passed: [12 checks](qualification-results/2026-09-26/plain-nl.json), harness `bd04954` | +| Older-ISO RPM upgrade and GRUB recovery | Passed: [14 checks](qualification-results/2026-09-26/upgrade-recovery.json), harness `bd04954` | + +Every scenario uses disposable serial-identified installation and guard disks, +with outbound guest networking blocked. The guard disk must remain unchanged. +A pass requires installed boot without the ISO, desktop/application checks, +selected locale/timezone/keyboard, enforcing SELinux, and removal of live-only +privileges. Fresh installs must require a sudo password. Fresh encrypted scenarios +also exercise login/keyring recovery. Upgrade/recovery checks must preserve +user Kitty edits, shell preferences and a home-directory marker. + +The prior-image baseline is +`/data/pxe/iso/CybexOS-Live-44-20260926T055804Z-dbdd33d6.iso`. +It deliberately uses an installer-only session target. Its explicit legacy +qualification path starts the full desktop only after checking that target +and its marker; fresh candidates retain strict normal-startup requirements. + +Physical PXE client boot, Secure Boot, and this laptop's post-upgrade hardware +behavior are outside the QEMU qualification scope. + +## Reference workstation + +The reference is a Dell XPS 14 DA14260 running Fedora 44 from the ISO/RPM path. +Its runtime is under `/usr/share/cybexos`, without a source-checkout `current` +symlink. It uses encrypted Btrfs and enforcing SELinux; Secure Boot is disabled. +The battery was plugged in and paused at its 75–80% preservation thresholds. + +These observations informed the RPM channel, account migrations, hardware +continuation, diagnostics, and charge-limit status changes. The workstation +was inspected but was not upgraded, reconfigured, or rebooted for these tests. + +## Other verification + +| Check | Result | +| --- | --- | +| Initial local repository suite (historical) | All 17 stages passed, including 1,157 JavaScript tests. The opt-in live Quickshell stage was skipped. Later GTK and harness corrections have focused regression checks and current hosted CI coverage. | +| GitHub checks | Both required Fedora source/image checks passed at runtime/harness head `bd04954`. Checks for the final documentation and evidence commit are attached to [PR #1](https://github.com/DigitalPals/CybexOS/pull/1/checks). | +| Real RPM signing integration | Disposable RPM signed using a signing-subkey-only keyring; independent RPM/repository signatures, metadata binding, and tamper rejection passed. Nothing was installed or published. | +| Generic Fedora 44 VM at `86f185f` | First convergence: 135 changes. Second convergence: zero changes (`ok 220`). Uninstall: 22 changes; adopted files restored and project state removed. | +| Ephemeral PXE runner service | Actual transient user-service startup and cleanup passed. No GitHub runner was registered. | +| Reference workstation shell | Final guard passed its start/end checks: active managed MainPID 41596 was the sole Quickshell process, with a clean current-invocation journal. | +| Harness regressions | Sequential Quickshell IPC clients, unknown/persistent extras, localized console prompts, browser preflight, and bounded redacted audit failures passed focused tests. | +| GTK provisioning at `a991a97` | 26 focused tests passed, including real Ansible offline skipping and inherited-descriptor regressions. The actual task also passed against installed gsettings/dconf in private HOME/XDG directories and a private dconf profile: first run applied dark defaults, second made no changes, and explicit light/custom-theme choices survived. Temporary files and private processes were removed; workstation settings were untouched. | + +The generic VM predates later archive, installer and harness corrections; +it does not substitute for final ISO qualification. Its 1.2 GiB staging was +removed. + +## Findings and qualification corrections + +- Source archives omitted the bundled agent skill. The archive and an + extracted-tree packaging regression now include it. +- Anaconda's common-locales shortlist omitted Dutch. The installer now + enumerates its full API inventory: 180 available locales across 85 languages. +- Offline provisioning assumed `/var/lib/systemd/linger` existed. It now + creates the root-owned directory before the account marker; a real Ansible + regression covers a missing parent and a second idempotent run. +- A controlled backend lock reproduced the visible initialization-busy error. + The browser driver retries only that initial state through the UI, within + the original deadline and before any disk action. Other failures stop. +- Console bootstrapping now distinguishes echoed commands from output, + confirms Bash before Bash-specific setup, and recognizes the observed Dutch + sudo OCR error only with the sudo prefix and disposable account name. +- Welcome can start another Quickshell IPC client while the audit waits for + an earlier one. The guard now rescans and inspects every observed extra PID, + with a bound and the same final sole-managed-PID requirement. The historical + extra PID from the failed US run could not be classified retrospectively. +- Missing browser dependencies now fail before a VM/output directory is + created. Installed-audit failures retain bounded, password-redacted details + without echoing whole Python heredocs over the useful traceback. +- The US installed audit reported `Installed timezone differs` even though + Anaconda had selected UTC correctly. This Fedora workstation uses hardlinks + for `UTC` and `Etc/UTC`; the audit now compares file identity rather than + resolved path names. A generated-code regression covers hardlink and symlink + aliases and rejects a different zone. +- Recovery verification now requires the exact requested snapshot ID, matching + the snapshot tool's `recoveryBoot` string. Its regression uses the actual + index producer. The user-preference fixture also handles an omitted bar + position as the desktop's effective `top` default. +- Recovery uses an in-memory root overlay, whose encryption ancestry cannot be + verified by normal login policy. The real older-ISO run upgraded successfully + and preserved user choices, then its harness incorrectly expected autologin + on recovery boot. Recovery qualification now unlocks the encrypted disk, + waits for the SDDM greeter, signs in with the fixture password, and requires a + working desktop before restore. Session readiness precedes VT discovery, + because SSH can become available before SDDM has created a login session. +- A later recovery run verified the exact snapshot and completed its restore + command, then SSH disconnected during poweroff. The harness failed before + waiting for QEMU to exit. Shutdown now accepts SSH exit 255 only after a + guest marker confirms successful preparation and QEMU exits with status 0 + within the existing deadline. Sync, temporary-access cleanup, authentication, + timeout and abnormal-exit failures remain fatal. +- An earlier encrypted-US run timed out waiting for SSH after its first cold + reboot. The retained logs did not establish a cause. Readiness failures now + record bounded, password-redacted SSH and screen diagnostics and remove the + temporary screenshot. A standalone retry passed all 25 checks, + including SSH readiness and keyring recovery after that cold reboot. +- A replacement test launch failed before boot because its QMP socket path + exceeded Linux's Unix-socket pathname limit; normal nested release-runner + paths could do the same. The harness now allocates a short private socket + directory, records its actual location in `vm.json`, and removes it after + confirmed guest exit. Real socket-bind regressions cover long output paths, + repeated boots, retained diagnostic logs, and startup failures. The ISO + payload is unaffected. + +Modified diagnostic guests and superseded images cannot count as final proof. +Their useful findings are recorded here instead of retaining large artifacts. + +A later baseline run established a runtime provisioning defect: the GTK-default +task used `dbus-run-session -- gsettings` inside Bash command substitution. +An activated `gvfsd-fuse` inherited the output pipe, keeping Bash and offline +Ansible provisioning blocked after the settings command returned. The guest's +pipe holders were verified before terminating that one daemon to continue +diagnosis. That modified guest is excluded from final qualification. The first +`a1713ff` candidate had passed four fresh scenarios but packaged the same +defective task; it was superseded by the candidate above, which passed a new +complete qualification matrix. The fix skips GTK bus initialization +offline, leaving appearance defaults to the first desktop session. Live +provisioning uses a bounded private bus and separate regular-file captures for +each call, so a surviving service cannot hold Ansible's pipes open or corrupt +the next settings read. The modified diagnostic guest subsequently completed +the RPM upgrade, exact recovery boot and restore, baseline-version check, and +user-choice preservation; it remains excluded from final qualification. + +## Release configuration and limits + +The signed repository destination, public key, protected branch checks, +immutable-release setting, Pages configuration, signing environment, and +baseline variable were independently verified through the GitHub API. No +repository runners are registered. Signing runs on a separate hosted Fedora +container; the PXE runner receives no private signing key. See +[the release guide](../docs/iso-releases.md) for on-demand runners and gates. + +No production desktop RPM or public repository metadata has been published. +Project code is MIT; bundled third-party software and artwork retain their own +terms. Their redistribution/provenance review remains a public-release gate. + +This private qualification build explicitly includes the pinned stable channel +with its repository enabled. Because Pages has no metadata yet, ordinary DNF +operations on an installation of this candidate can fail on that repository. +For private testing before publication, disable only this repository: + +```bash +sudo sed -i 's/^enabled=1$/enabled=0/' /etc/yum.repos.d/cybexos-desktop.repo +``` + +This preserves signature checks and the pinned key. After publication, verified +channel enrollment re-enables it. `cybex update-channel status` checks local +configuration, not remote availability. Ordinary builds without +`--update-channel` ship a disabled desktop channel. + +The protected primary signing keyring is retained locally at +`/home/john/.local/state/cybexos/release-signing/DigitalPals-CybexOS` +(76 KiB, owner-only directory). Only its signing subkey was uploaded to the +GitHub environment secret. The public fingerprint is +`16C60642B7278AECE3A933C354220839FDF7099E`. + +## Cleanup + +All task build/cache/dependency staging (29 GiB), disposable VM disks, logs, +screenshots, temporary harness worktrees, and QMP socket directories were +removed after the five scenarios finished. The superseded task ISOs +`CybexOS-Live-44-20260926T114433Z-a107c90a.iso` and +`CybexOS-Live-44-20260926T123305Z-901ef7db.iso`, their checksums, and old RPM +directories `/data/cybexos-candidate-rpm-20260926` and +`/data/cybexos-candidate-rpm-20260926-a1713ff` were removed. Superseded local +results and diagnostic traces were removed after recording their findings. + +The final retained artifacts and signing keyring are listed above. The original +baseline ISO/checksum, Alpine ISO, unrelated remote checkout, and pre-existing +remote image outputs were preserved. The local image-output directory is +absent. Final process, mount, temporary-directory and worktree checks found no +task leftovers; directory inventory and disk usage were verified. + +After removing the last superseded ISO, iVentoy refresh returned +`result: success`. PXE reported `running`, `iventoy.service` was `active`, and +the image tree contained exactly the qualified candidate, original baseline, +and Alpine. The removed image was absent. Candidate ISO, baseline ISO, and +matching RPM checksum verification all passed again. No service restart was +needed. diff --git a/image/README.md b/image/README.md index 145789d8..c2c334ae 100644 --- a/image/README.md +++ b/image/README.md @@ -5,17 +5,14 @@ application set. The proposed boot path is **Cybex firmware menu → Cybex Plymouth → live desktop/welcome → three-screen installer**. After an encrypted installation, it is **disk unlock → automatic login → desktop**. -The September 23 implementation has source, Qt, backend-fixture and browser -checks only. **No ISO was built or booted for these changes.** The earlier -image in [VALIDATION.md](VALIDATION.md) predates this implementation and does -not qualify it. See [IMPLEMENTATION-2026-09-23.md](IMPLEMENTATION-2026-09-23.md) -for changes and remaining integration checks, and the historical -[audit](AUDIT-2026-09-23.md) for the original findings. -The September 24 source also replaces GDM with SDDM and shares the workstation's -login policy. Earlier GDM boot results do not qualify this migration. -The replacement desktop RPM built successfully in a disposable Fedora VM. -ISO creation was then deferred at the user's request; no new ISO was completed -or booted, and temporary build artifacts were removed. +Current ISO installation, desktop RPM upgrade, and recovery results are in the +[September 26 qualification report](QUALIFICATION-2026-09-26-LIFECYCLE.md), +including exact image hashes, harness revisions, and remaining release limits. +The earlier encrypted-login results are recorded in the +[September 25 installation audit](INSTALL-AUDIT-2026-09-25.md). +Earlier implementation and audit notes are historical: the +[September 23 implementation](IMPLEMENTATION-2026-09-23.md) and +[September 23 audit](AUDIT-2026-09-23.md). ## Installation experience @@ -97,8 +94,9 @@ The image package includes the reviewed Ansible baseline, account, Fish and XPS hardware tasks under `/usr/share/cybexos/provision`. Package selection includes the shared baseline and hardware firmware lists. Anaconda applies account settings, service enablement, the firewall and hardware detection in -its offline target. The installed account therefore starts with Fish and -passwordless wheel sudo; an explicit saved `passwordless_wheel: false` wins. +its offline target. The installed account starts with Fish and password-required +sudo. The installer offers an explicit opt-in to passwordless sudo; existing +saved choices are preserved on later configuration runs. Both Codex and Claude use the interactive Fish aliases in the shared config. The installer records its choices in `/etc/cybexos/config.yml`, in the same @@ -136,8 +134,20 @@ After boot, `cybexos-hardware-setup.timer` applies the detected hardware role when network access is available. Failed setup remains visible in the service journal. A known camera ABI mismatch is cached for that kernel and provisioning payload, avoiding repeated builds; a new kernel or changed payload retries it. -`cybex doctor` reports a cached incompatibility, and `cybex repair --hardware` -allows an explicit retry. Successful hardware setup is also keyed to the kernel. +`cybex doctor --json` reports installed-system diagnostics. Hardware setup +status lives in `/var/lib/cybexos/hardware-status.json` and is shown in the +welcome window; a pending camera setup resumes after rebooting into the same +kernel. Successful hardware setup is also keyed to the kernel. + +ISO installations use RPM upgrades for desktop changes. The default image has +no enabled desktop RPM update channel; inspect channel state with +`cybex update-channel status --json`. Enrolling a channel requires its reviewed +public configuration and complete signing-key fingerprint. RPM upgrades +schedule versioned account and machine policy through +`cybexos-reconcile.service` and its timer. Inspect or retry that work with +`sudo /usr/libexec/cybexos-reconcile --status` or `--retry`. `cybex repair` +reapplies the policy bundled with the installed RPM; it does not deploy a +source checkout. For installations made before this integration, preview and apply the migration from a reviewed checkout: @@ -164,9 +174,16 @@ of optional Steam/network services, and selected files use the Flatpak document portal. The session target avoids the implicit ordering cycle with vendor services that start after `graphical-session.target`. -This migration does not publish a CybexOS RPM update repository. A signed public -channel is still required for future desktop RPM delivery; see the release -instructions below. Fedora, vendor and Flatpak updates work independently. +The release tooling prepares immutable-tagged desktop RPM releases and signed +repository metadata for +[`https://digitalpals.github.io/CybexOS/44/x86_64`](https://digitalpals.github.io/CybexOS/44/x86_64). +The public channel configuration is `image/channels/stable.json`. The signing +environment and baseline ISO are configured; Pages remains empty until a +reviewed release passes its gates using an on-demand PXE runner. See the +[release instructions](../docs/releasing.md). The repository code is MIT +licensed; a separate third-party software and asset redistribution audit is +still required before public distribution. Until publication and channel +enrollment, Fedora, vendor and Flatpak updates work independently. ## Source checks: no ISO or VM @@ -276,9 +293,11 @@ versions come from `VERSION`, with a timestamp/revision release suffix and installed provenance. Epoch 1 permits upgrading the older hardcoded alpha version. Existing checkout installations retain their source updater. -A default build ships a **disabled** desktop update channel. Enabling actual -desktop updates requires your HTTPS repository URL and existing signing key. -Prepare a public configuration before building, for example: +A build without `--update-channel` ships a **disabled** desktop update channel. +The release gate explicitly enables `image/channels/stable.json`; a custom or +private channel can also be supplied with `--update-channel` at build time. +Local `cybex update-channel status` output does not prove that remote metadata +is available. For a custom channel, prepare a public configuration, for example: ```json { @@ -311,14 +330,21 @@ verifies the RPM signatures using only the public key, signs/verifies metadata and the release manifest, writes checksums, and atomically publishes a new local directory. Original RPMs and the system RPM keyring remain untouched. `--gnupghome` can select an existing signing keyring. Hosting/deployment is a -separate action; the tool does not upload anything or create signing keys. -No real signed repository was created for this implementation. - -## Future ISO qualification and PXE publication - -These commands are opt-in operations, **not part of source checks**. They were -not executed for the September 23 changes. Completed testing ISOs belong in -`/data/pxe/iso`; keep incomplete builds outside that tree. +separate action; the tool does not upload anything or create signing keys. The +public GitHub Pages/RPM release tooling, baseline ISO variable, and signing +environment are configured. First publication still requires a reviewed +queued release, an operator-started ephemeral PXE runner, and passing release +gates. The repository-code MIT license does not resolve the separate +third-party software and asset redistribution audit. See +[the release instructions](../docs/releasing.md). + +## ISO qualification and PXE publication + +These commands are opt-in operations, **not part of source checks**. The +qualification report above identifies the exact tested runtime source and +artifacts; later runtime changes require a new build and qualification. +Completed testing ISOs belong in `/data/pxe/iso`; keep incomplete builds outside +that tree. On the iVentoy host, `image/publish-pxe /path/to/artifacts` verifies the artifact set and prints a plan. Adding `--execute` copies the ISO/checksum through @@ -346,11 +372,13 @@ The harness detects Fedora/Debian UEFI firmware (raw or qcow2), uses bounded readiness checks and blocks guest outbound networking. `--firmware bios` selects BIOS. An adjacent `ISO-FILENAME.iso.sha256` must verify before a VM can start. The smoke test checks the live desktop/applications; qualification -also installs to its newly created, serial-identified virtual disk, reboots -without the ISO, unlocks it and checks encryption, autologin, desktop defaults, -SELinux and live-account cleanup. It verifies an encrypted GNOME login keyring -without requesting an unlock, stores a synthetic secret, and confirms that -secret is available after another cold boot. Logout, compositor crash and +also installs to its newly created, serial-identified virtual disk, checks that +a second guard disk is unchanged, and reboots without the ISO. It verifies the +selected locale, timezone and keyboard, desktop defaults, SELinux and +live-account cleanup. Plain scenarios require password login; encrypted +scenarios check disk unlock and autologin, then verify an encrypted GNOME login +keyring without requesting an unlock, store a synthetic secret, and confirm +that secret is available after another cold boot. Logout, compositor crash and SDDM restart must return to a greeter; password login must restore both the desktop and keyring. Another cold boot temporarily disables the cached-password PAM module in the disposable guest, checks that the vault stays locked, then @@ -358,14 +386,16 @@ verifies recovery through normal password login. The final cold boot injects a single early launcher failure in the guest. It requires a working greeter, a consumed autologin attempt, disabled autologin after restarting SDDM, and successful password-login recovery; the original launcher is then restored. -It drives the backend; the -browser fixture separately covers frontend flow. A passing fixture is not a -boot result. - -Installation qualification uses four virtual CPUs, 16 GiB RAM and a new -100 GiB sparse disk. It performs one live boot and four installed cold boots; -the three logout/crash/restart cases reuse the running installation. The -installer deadline defaults to 30 minutes (`--install-timeout 1800`); boot, +Chromium and Playwright operate the actual guest Cockpit installer through a +restricted SSH tunnel. The separate browser smoke fixture provides source +checks; it does not replace the graphical installation and boot results. + +Installation qualification uses four virtual CPUs, 16 GiB RAM and two new +100 GiB sparse disks: an installation disk and an untouched guard. Fresh encrypted +scenarios perform one live boot and four installed cold boots; the three +logout/crash/restart cases reuse the running installation. Plain scenarios +perform one live boot and one installed boot. The installer deadline defaults +to 30 minutes (`--install-timeout 1800`); boot, application seeding and recovery have separate bounded waits. Runtime has not yet been benchmarked. @@ -381,12 +411,8 @@ does not establish that PXE publication and refresh succeeded. `vm.json`. By default cleanup removes disks, credentials, screenshots and VM logs; small JSON reports remain. `--keep-artifacts` is only for unresolved diagnostics, and retained paths/sizes must be reported and later cleaned. -The 2026-09-25 UEFI qualification passed a fresh encrypted installation, -installed-account defaults, cold reboot, logout/crash/manager-restart recovery, -and the encrypted-keyring fallback cases; see -[the installation audit](INSTALL-AUDIT-2026-09-25.md). The graphical bootstrap -waits for the desktop and a terminal execution marker before private input; -it stops instead of blindly retrying passwords. +The graphical bootstrap waits for the desktop and a terminal execution marker +before private input; it stops instead of blindly retrying passwords. Physical GPUs, Secure Boot, international early-boot password entry, screen lock, suspend/resume and real application/account credential behavior need separate checks. @@ -400,7 +426,9 @@ NVIDIA drivers are not bundled. Investigate graphics with `lspci -nnk`, `hyprctl monitors all` and `journalctl -b -k`; basic-graphics recovery intentionally disables normal modesetting. -This remains a private alpha. The repository has no software license; -`LicenseRef-Not-Licensed` grants no distribution rights. Public distribution -requires the project's licensing/redistribution decisions, actual update -hosting and successful release/hardware qualification. +The repository's original code and configuration are MIT-licensed. That does +not grant redistribution rights for third-party packages, artwork, fonts, +trademarks, or bundled images. The ISO and its application payload still need +a documented redistribution audit before public release; see +[licensing and asset provenance](../docs/licensing.md). A passing VM gate +also does not qualify physical hardware or Secure Boot. diff --git a/image/applications b/image/applications index cb77287e..7daf153a 100755 --- a/image/applications +++ b/image/applications @@ -40,7 +40,10 @@ PACKAGE_TASKS = ( ("roles/base/tasks/main.yml", {"Install Docker packages when selected"}), # Firmware must be available at the first physical boot, before the # detected hardware role runs its machine-specific configuration. - ("roles/xps-2026/tasks/packages.yml", {"Install explicit Panther Lake firmware, media, and regulatory packages"}), + ("roles/xps-2026/tasks/packages.yml", { + "Install explicit Panther Lake firmware, media, and regulatory packages", + "Install full Panther Lake video-codec acceleration", + }), ("roles/desktop/tasks/main.yml", {"Install stable Hyprland and desktop integration packages", "Install Fedora fuzzel and matugen rather than COPR variants"}), ) @@ -60,7 +63,8 @@ def package_names(): for path, names in PACKAGE_TASKS: for task in yaml.safe_load((ROOT / path).read_text()): if task.get("name") in names: - packages.extend(task["ansible.builtin.dnf"]["name"]) + selected = task["ansible.builtin.dnf"]["name"] + packages.extend([selected] if isinstance(selected, str) else selected) packages += ["tailscale", "java-25-openjdk-devel", "rpmfusion-free-release", "rpmfusion-nonfree-release"] return sorted(set(packages)), defaults["apps_flatpaks"] + defaults["apps_steam_flatpaks"] diff --git a/image/browser-smoke.cjs b/image/browser-smoke.cjs index 9014906a..6805d974 100644 --- a/image/browser-smoke.cjs +++ b/image/browser-smoke.cjs @@ -6,6 +6,7 @@ const fs = require("node:fs/promises"); const http = require("node:http"); const path = require("node:path"); const { chromium } = require("playwright-core"); +const { waitForInitialSetup } = require("./real_browser_qualification.cjs"); const root = path.join(__dirname, "live-rootfs/usr/share/cockpit/cybexos-installer"); const transport = ` @@ -20,11 +21,34 @@ window.cockpit = { then(callback) { done = callback; return this; }, input(raw) { const data = JSON.parse(raw || "{}"), command = args[1]; - fixtureRequests.push({ command, hasPassword: !!data.password, timezone: data.timezone }); + fixtureRequests.push({ command, hasPassword: !!data.password, + locale: data.locale, timezone: data.timezone, encrypted: data.encrypted }); + const busyRemaining = Number(sessionStorage.getItem("fixtureBusyRemaining") || "0"); + if (command === "inventory" && busyRemaining > 0) { + sessionStorage.setItem("fixtureBusyRemaining", String(busyRemaining - 1)); + setTimeout(() => { + stream(JSON.stringify({ event: "result", ok: false, + error: "Another installer operation is still running." }) + "\\n"); + done(); + }, 5); + return this; + } + const initialError = command === "inventory" && sessionStorage.getItem("fixtureInitialError"); + if (initialError && initialError !== "done") { + sessionStorage.setItem("fixtureInitialError", "done"); + setTimeout(() => { + stream(JSON.stringify({ event: "result", ok: false, + error: "Anaconda inventory failed." }) + "\\n"); + done(); + }, 5); + return this; + } let result; if (command === "status") result = { phase: fixturePhase, message: "Fixture progress" }; - else if (command === "inventory") result = { - disks: [{ name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe" }], + else if (command === "inventory" || command === "rescan") result = { + disks: [{ name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", + serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", + partitions: [{ path: "/dev/vda1", size: 2147483648, filesystem: "vfat" }] }], keyboards: [{ id: "us", label: "English (US)" }, { id: "nl", label: "Dutch" }], locales: ["en_US.UTF-8", "nl_NL.UTF-8"], locale: "en_US.UTF-8", keyboard: "us", timezones: ["America/Argentina/Buenos_Aires", "Europe/Amsterdam", "UTC"], @@ -34,9 +58,12 @@ window.cockpit = { else if (command === "keyboard") result = { keyboard: data.keyboard, boot_keyboard: data.keyboard }; else if (command === "plan") result = { phase: "review", token: "fixture-token", - disk: { name: "vda", path: "/dev/vda", model: "Fixture NVMe" }, + disk: { name: "vda", path: "/dev/vda", size: 107374182400, model: "Fixture NVMe", + serial: "FIXTURE-SERIAL", wwn: "FIXTURE-WWN", + partitions: [{ path: "/dev/vda1", size: 2147483648, filesystem: "vfat" }] }, account: { username: data.username, encrypted: data.encrypted, - locale: data.locale, timezone: data.timezone }, + locale: data.locale, timezone: data.timezone, + passwordless_wheel: data.passwordless_wheel }, boot_keyboard: data.keyboard, actions: [{ "action-description": "Create", "object-description": "encrypted Btrfs", "device-name": "vda" }], warnings: [] @@ -59,7 +86,11 @@ window.cockpit = { fixturePhase = "complete"; sessionStorage.setItem("fixturePhase", fixturePhase); }, 100); - } else result = { phase: "setup" }; + } else if (command === "diagnostics") result = { + schema: 1, installer: { phase: fixturePhase, message: "Installation did not finish." }, + worker: { ActiveState: "failed", Result: "exit-code" }, backend_ready: true + }; + else result = { phase: "setup" }; setTimeout(() => { stream(JSON.stringify({ event: "result", ok: true, data: result }) + "\\n"); done(); @@ -106,6 +137,22 @@ async function main() { headless: true, args: ["--disable-dev-shm-usage"], }); + const busyPage = await browser.newPage(); + await busyPage.addInitScript(() => sessionStorage.setItem("fixtureBusyRemaining", "4")); + await busyPage.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); + await waitForInitialSetup(busyPage, 5000, [10, 20, 40, 80, 160, 320, 320]); + assert.equal(await busyPage.locator("#setup").isVisible(), true); + assert.equal(await busyPage.evaluate(() => fixtureRequests.filter(request => request.command === "inventory").length), 5); + await busyPage.close(); + + const otherErrorPage = await browser.newPage(); + await otherErrorPage.addInitScript(() => sessionStorage.setItem("fixtureInitialError", "other")); + await otherErrorPage.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); + await assert.rejects(waitForInitialSetup(otherErrorPage, 250)); + assert.equal(await otherErrorPage.locator("#setup").isVisible(), false); + assert.equal(await otherErrorPage.evaluate(() => fixtureRequests.filter(request => request.command === "inventory").length), 1); + await otherErrorPage.close(); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); await page.emulateMedia({ reducedMotion: "reduce" }); const errors = []; @@ -115,8 +162,17 @@ async function main() { await page.waitForFunction(() => !document.querySelector("#password").disabled); await screenshot(page, "installer-setup"); await page.fill("#username", "alice"); + assert.equal(await page.inputValue("#disk"), ""); + assert.equal(await page.isChecked("#passwordless-wheel"), false); + await page.fill("#username", "root"); await page.fill("#password", "fixture secret 123"); await page.fill("#confirm", "fixture secret 123"); + await page.getByRole("button", { name: "Choose install location" }).click(); + assert.match(await page.textContent("#error"), /System names are reserved/); + await page.fill("#username", "alice"); + await page.fill("#password", "fixture secret 123"); + await page.fill("#confirm", "fixture secret 123"); + await page.check("#passwordless-wheel"); await page.selectOption("#keyboard", "nl"); await page.waitForFunction(() => !document.querySelector("#password").disabled); assert.equal(await page.inputValue("#password"), ""); @@ -129,13 +185,23 @@ async function main() { await page.fill("#keyboard-test", "ordinary test characters"); await page.fill("#password", "fixture secret 123"); await page.fill("#confirm", "fixture secret 123"); + await page.locator("#account-form details > summary").click(); + await page.selectOption("#locale", "nl_NL.UTF-8"); + await page.selectOption("#timezone", "Europe/Amsterdam"); await page.getByRole("button", { name: "Choose install location" }).click(); await page.locator("#location").waitFor({ state: "visible" }); assert.equal(await page.isChecked("#encrypted"), true); + await page.click("#rescan-disks"); + await page.waitForFunction(() => !document.querySelector("#rescan-disks").disabled); + assert.equal(await page.inputValue("#disk"), ""); await screenshot(page, "installer-location"); await page.selectOption("#disk", "vda"); + assert.match(await page.textContent("#disk-details"), /FIXTURE-SERIAL/); + assert.match(await page.textContent("#disk-details"), /\/dev\/vda1.*2\.0 GiB/); await page.getByRole("button", { name: "Review installation" }).click(); await page.locator("#review").waitFor({ state: "visible" }); + assert.match(await page.textContent("#summary"), /Administrator commands do not ask for a password/); + assert.match(await page.textContent("#summary"), /FIXTURE-WWN/); assert.equal(await page.isDisabled("#install"), true); await screenshot(page, "installer-review"); await page.check("#erase"); @@ -153,13 +219,44 @@ async function main() { assert.equal(await page.inputValue("#confirm"), ""); assert.equal(await page.evaluate(() => fixtureRequests.filter(request => request.command === "install").length), 2); assert.equal(await page.evaluate(() => fixtureRequests.find(request => request.command === "plan").timezone), "Europe/Amsterdam"); + assert.equal(await page.evaluate(() => fixtureRequests.find(request => request.command === "plan").locale), "nl_NL.UTF-8"); await page.reload(); await page.getByRole("heading", { name: "Your workspace is ready." }).waitFor(); + await page.evaluate(() => sessionStorage.setItem("fixturePhase", "failed-install")); + await page.reload(); + await page.getByRole("heading", { name: "Installation needs attention." }).waitFor(); + assert.equal(await page.isVisible("#failure-help"), true); + const downloadPromise = page.waitForEvent("download"); + await page.click("#save-diagnostics"); + const download = await downloadPromise; + assert.equal(download.suggestedFilename(), "cybexos-installer-diagnostics.json"); + assert.equal(await page.evaluate(() => fixtureRequests.some(request => request.command === "diagnostics")), true); await page.setViewportSize({ width: 390, height: 844 }); await screenshot(page, "installer-narrow"); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true); + const plain = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + plain.on("pageerror", error => errors.push(error.message)); + await plain.goto(`http://127.0.0.1:${server.address().port}/cockpit/@localhost/cybexos-installer/index.html`); + await plain.locator("#setup").waitFor({ state: "visible" }); + await plain.waitForFunction(() => !document.querySelector("#password").disabled); + await plain.fill("#username", "qualification"); + await plain.fill("#password", "fixture secret 123"); + await plain.fill("#confirm", "fixture secret 123"); + await plain.locator("#account-form details > summary").click(); + await plain.selectOption("#locale", "en_US.UTF-8"); + await plain.selectOption("#timezone", "UTC"); + await plain.getByRole("button", { name: "Choose install location" }).click(); + await plain.locator("#location").waitFor({ state: "visible" }); + await plain.selectOption("#disk", "vda"); + await plain.locator("#disk-form details > summary").click(); + await plain.uncheck("#encrypted"); + await plain.getByRole("button", { name: "Review installation" }).click(); + await plain.locator("#review").waitFor({ state: "visible" }); + assert.match(await plain.textContent("#summary"), /unencrypted/); + assert.equal(await plain.evaluate(() => fixtureRequests.find(request => request.command === "plan").encrypted), false); + await plain.close(); assert.deepEqual(errors, []); - console.log("PASS: three-screen flow, keyboard change, timezone detection, rejected-install recovery, encryption default, erase confirmation, password clearing, progress/reload recovery, narrow layout"); + console.log("PASS: three-screen flow, keyboard/locale/timezone choices, encrypted and plain disk paths, rejected-install recovery, erase confirmation, password clearing, progress/reload recovery, narrow layout"); } finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); diff --git a/image/browser_qualification.py b/image/browser_qualification.py new file mode 100644 index 00000000..15c37d49 --- /dev/null +++ b/image/browser_qualification.py @@ -0,0 +1,122 @@ +"""Connect a host browser to the guest's actual loopback Cockpit instance.""" +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import time +from urllib.parse import urlsplit + +from vm_testing import free_port, run + +DRIVER = Path(__file__).with_name('real_browser_qualification.cjs') +DISCOVER = r''' +from pathlib import Path +for proc in Path('/proc').iterdir(): + if not proc.name.isdigit(): + continue + try: + args = proc.joinpath('cmdline').read_bytes().split(b'\0') + except (PermissionError, FileNotFoundError, ProcessLookupError): + continue + if not any(arg.endswith(b'/cybexos-installer-browser') for arg in args): + continue + for arg in args: + if arg.startswith(b'http://127.0.0.1') or arg.startswith(b'http://localhost'): + print(arg.decode('ascii')) + raise SystemExit(0) +raise SystemExit(1) +''' + + +def validate_guest_url(value): + parsed = urlsplit(value) + if (parsed.scheme != 'http' or parsed.hostname not in ('127.0.0.1', 'localhost') + or parsed.username or parsed.password or parsed.query or parsed.fragment + or parsed.path not in ('/cockpit/@localhost/cybexos-installer/index.html', + '/cockpit/@localhost/anaconda-webui/index.html')): + raise ValueError('Guest installer URL was not the expected loopback Cockpit page') + port = parsed.port or 80 + if not 1 <= port <= 65535: + raise ValueError('Guest installer port is invalid') + return port + + +def discover_guest_port(vm, timeout=90): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = subprocess.run([*vm.ssh, 'python3 -'], input=DISCOVER, text=True, + capture_output=True, timeout=10) + if result.returncode == 0: + urls = [line for line in result.stdout.splitlines() if line.strip()] + if len(urls) != 1: + raise RuntimeError('Guest installer URL discovery was ambiguous') + return validate_guest_url(urls[0]) + vm.alive() + time.sleep(2) + raise RuntimeError('Guest installer browser URL was not discovered') + + +def wait_tunnel(port, process, timeout=20): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError('Installer Cockpit tunnel exited unexpectedly') + try: + with socket.create_connection(('127.0.0.1', port), timeout=1): + return + except OSError: + time.sleep(0.2) + raise RuntimeError('Installer Cockpit tunnel did not open') + + +def browser_dependencies(): + requested = os.environ.get('CYBEXOS_BROWSER') + browser = (shutil.which(requested) or requested) if requested else next((shutil.which(name) for name in + ('brave-origin', 'chromium', 'chromium-browser', 'google-chrome') if shutil.which(name)), None) + if not browser or not os.access(browser, os.X_OK): + raise RuntimeError('Install a Chromium browser or set CYBEXOS_BROWSER for graphical qualification') + check = subprocess.run(['node', '-e', + 'if(Number(process.versions.node.split(".")[0])<20) process.exit(1); require("playwright-core");'], + capture_output=True, text=True, timeout=10) + if check.returncode: + raise RuntimeError('Graphical qualification requires Node.js >=20 and playwright-core (see image/README.md)') + return browser + + +def qualify_browser(vm, *, password, target_disk, unused_disk, encrypted, keyboard, locale, + timezone, install_timeout, require_policy_controls=True): + browser = browser_dependencies() + remote_port = discover_guest_port(vm) + local_port = free_port() + tunnel = subprocess.Popen([*vm.ssh[:-1], '-N', '-L', + f'127.0.0.1:{local_port}:127.0.0.1:{remote_port}', vm.ssh[-1]], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + wait_tunnel(local_port, tunnel) + payload = {'url': f'http://127.0.0.1:{local_port}/cockpit/@localhost/cybexos-installer/index.html', + 'browser': browser, 'password': password, 'target_disk': target_disk, + 'target_serial': 'CYBEXOS-QUALIFY', 'unused_disk': unused_disk, + 'encrypted': encrypted, 'keyboard': keyboard, 'locale': locale, + 'timezone': timezone, 'install_timeout_ms': install_timeout * 1000, + 'require_policy_controls': require_policy_controls} + try: + result = run(['node', str(DRIVER)], input=json.dumps(payload), text=True, + capture_output=True, timeout=install_timeout + 240) + except subprocess.CalledProcessError as error: + # Playwright prints the failed assertion/locator to stderr. Keep + # the useful part without ever including the fixture password. + detail = ((error.stderr or '') + '\n' + (error.stdout or '')).replace(password, '[redacted]') + raise RuntimeError(f'Installer browser driver failed: {detail[-5000:]}') from error + data = json.loads(result.stdout) + if data.get('check') != 'graphical-installer' or data.get('selected_disk') != target_disk: + raise RuntimeError('Browser driver did not confirm the selected disposable disk') + return data + finally: + tunnel.terminate() + try: + tunnel.wait(timeout=5) + except subprocess.TimeoutExpired: + tunnel.kill() + tunnel.wait() diff --git a/image/build b/image/build index 5367431b..b933504e 100755 --- a/image/build +++ b/image/build @@ -31,8 +31,9 @@ def run(args, **kwargs): def source_archive(destination, additions=None): roots = ["image", "roles/desktop", "assets/scripts", "assets/EDM115-newline2.omp.json", "assets/desktop-contract.json", "assets/wallpapers", "assets/PROVENANCE.json", - "roles/boot/files", "roles/boot/defaults/main.yml", "inventory/group_vars/all.yml", "VERSION", + "roles/boot/files", "roles/boot/defaults/main.yml", "inventory/group_vars/all.yml", "VERSION", "LICENSE", "roles/dotfiles", "roles/apps", "roles/base", "roles/xps-2026", + "agent-skills/cybexos", "scripts/manage-agent-skills", "assets/nautilus-localsend.py"] excluded = {"image/update-channel.json", "image/update-key.asc", "image/build-provenance.json"} with tarfile.open(destination, "w:gz") as archive: diff --git a/image/channels/CYBEXOS-desktop.asc b/image/channels/CYBEXOS-desktop.asc new file mode 100644 index 00000000..f48cabad --- /dev/null +++ b/image/channels/CYBEXOS-desktop.asc @@ -0,0 +1,16 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEarerDxYJKwYBBAHaRw8BAQdAmmD5CaWuawHOraUCulVZjFjAT0iUKf9mFBPL +2LkA9B20O0N5YmV4T1MgUlBNIFJlbGVhc2UgU2lnbmluZyA8bm9yZXBseUBkaWdp +dGFscGFscy5naXRodWIuaW8+iJkEExYKAEEWIQQWxgZCtyeK7OOpM8NUIgg5/fcJ +ngUCarerDwIbAQUJBaOagAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRBU +Igg5/fcJno2eAP98NYS4dX6fxbK0yUPM1Nb+aeH2v8P1d5WoeH2xn8j5cQD8C+lz +ETO5o+5dSmRbCJPv7bDfC9sWBNznW6uLqylJvwm4MwRqt6sPFgkrBgEEAdpHDwEB +B0DMqZcTm/bd+Yk8hrqRjWBi7/1yoAQ/tvfcJfYui627k4j1BBgWCgAmFiEEFsYG +QrcniuzjqTPDVCIIOf33CZ4FAmq3qw8CGwIFCQPCZwAAgQkQVCIIOf33CZ52IAQZ +FgoAHRYhBG6cYYz9wTHrUwGfYUyvtRx29FzVBQJqt6sPAAoJEEyvtRx29FzVk+sB +APoBywasY4Zv5bWN8czeindBYEauMAn7loIa62SwjOJHAP41NgT7cnuIF4moDHyp +TI83TSC9EB420/rWpPc5f3MqAtI4AQC73TWynBM+ACwHKf0n9qt4BXfjPAI/YQss +LlVC6dplXQEA06MRepyNZvoAzorba/isdLw/OVeoUsnvbF2AOl12SAc= +=61Bo +-----END PGP PUBLIC KEY BLOCK----- diff --git a/image/channels/stable.json b/image/channels/stable.json new file mode 100644 index 00000000..3b39ae44 --- /dev/null +++ b/image/channels/stable.json @@ -0,0 +1,5 @@ +{ + "baseurl": "https://digitalpals.github.io/CybexOS/44/x86_64", + "fingerprint": "16C60642B7278AECE3A933C354220839FDF7099E", + "key_file": "CYBEXOS-desktop.asc" +} diff --git a/image/cybexos-desktop.spec b/image/cybexos-desktop.spec index b90fe388..09f138c1 100644 --- a/image/cybexos-desktop.spec +++ b/image/cybexos-desktop.spec @@ -5,9 +5,9 @@ Epoch: 1 Version: 0.1.0 Release: 0.1.alpha%{?dist} Summary: CybexOS Hyprland and Quickshell desktop -# No repository license has been selected. These are private evaluation -# artifacts; this label does not grant redistribution rights. -License: LicenseRef-Not-Licensed +# CybexOS code is MIT. Bundled upstream software/artwork retains its own terms; +# aggregate redistribution clearance is still pending (docs/licensing.md). +License: MIT AND LicenseRef-CybexOS-Bundled-Components URL: https://github.com/DigitalPals/CybexOS Source0: desktop.tar BuildArch: x86_64 @@ -18,7 +18,7 @@ Obsoletes: fedora-config-desktop < %{epoch}:%{version}-%{release} # filesystem separately. Avoid spending minutes recompressing user toolchains. %global _binary_payload w3.zstdio %global _binary_filedigest_algorithm 8 -Requires: bash coreutils util-linux systemd python3 ansible-core +Requires: bash coreutils util-linux systemd python3 ansible-core gnupg2 Requires: sddm sddm-wayland-generic systemd-pam gnome-keyring-pam Requires: hyprland hyprland-guiutils quickshell hypridle hyprlock hyprpolkitagent hyprsunset Requires: xdg-desktop-portal-hyprland xdg-desktop-portal-gtk xdg-utils @@ -56,6 +56,7 @@ mkdir -p %{buildroot} cp -a usr opt etc %{buildroot}/ %files +%license /usr/share/licenses/cybexos-desktop/LICENSE %config(noreplace) /etc/yum.repos.d/cybexos-desktop.repo %config(noreplace) /etc/fonts/conf.d/49-cybexos-defaults.conf /opt/cybexos-apps/ @@ -88,14 +89,23 @@ cp -a usr opt etc %{buildroot}/ /usr/lib/dracut/dracut.conf.d/90-cybexos-recovery.conf /usr/lib/dracut/modules.d/90cybexos-recovery/ /usr/lib/systemd/system/cybexos-recovery-refresh.service +/usr/lib/systemd/system/cybexos-reconcile.service +/usr/lib/systemd/system/cybexos-reconcile.timer /usr/lib/systemd/system/cybexos-hardware-setup.service /usr/lib/systemd/system/cybexos-hardware-setup.timer /usr/lib/firewalld/zones/cybexos.xml %posttrans +# Record work only inside the RPM transaction. A timer runs it after RPM releases +# its transaction lock, and repeats only for changed payloads/new accounts. +/usr/libexec/cybexos-reconcile --queue +systemctl enable --now --no-block cybexos-reconcile.timer >/dev/null 2>&1 || : # The SDDM RPM owns /etc/pam.d/sddm-autologin. Install the shared policy after -# all package payloads are present, preserving its initial configuration once. -/usr/libexec/cybexos-login-prepare --install-pam +# all package payloads are present on a fresh installation. Upgrades use the +# ownership-aware deferred policy so local PAM edits are retained. +if [ "$1" -eq 1 ]; then + /usr/libexec/cybexos-login-prepare --install-pam +fi # Bootable recovery points are refreshed at every boot; enabling is idempotent. systemctl enable cybexos-recovery-refresh.service >/dev/null 2>&1 || : systemctl enable cybexos-hardware-setup.timer >/dev/null 2>&1 || : diff --git a/image/desktop_payload.py b/image/desktop_payload.py index aa16047e..dc06f16b 100644 --- a/image/desktop_payload.py +++ b/image/desktop_payload.py @@ -113,3 +113,23 @@ def split_seed(vendor, contract): if file.is_file() and not file.is_symlink()) (vendor / "seed-groups.json").write_text(json.dumps({"totalBytes": total}) + "\n") return total + + +def prepare_managed_defaults(vendor): + """Version only vendor fragments; application stores and user settings stay seeded once.""" + paths = ( + '.config/fish/conf.d/50-cybexos.fish', + '.config/kitty/cybexos.conf', + '.local/share/nautilus-python/extensions/localsend.py', + ) + selected = [] + for relative in paths: + for tree in ('essential-seed', 'user-seed', 'final-seed'): + source = vendor / tree / relative + if source.is_file() and not source.is_symlink(): + destination = vendor / 'managed-seed' / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(source, destination) + selected.append(relative) + break + (vendor / 'managed-defaults.json').write_text(json.dumps(selected) + '\n') diff --git a/image/github_release.py b/image/github_release.py new file mode 100644 index 00000000..2bd3d326 --- /dev/null +++ b/image/github_release.py @@ -0,0 +1,314 @@ +"""Prepare verified GitHub Release assets and a small GitHub Pages RPM repository.""" +import argparse +import hashlib +import gzip +import json +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +from urllib.parse import urljoin +import xml.etree.ElementTree as ET + +from release_metadata import fingerprint, public_key +from release_repository import rename_new_directory, sha256, verify_signed_rpm + +ASSET_LIMIT = 2 * 1024 ** 3 +ISO_PART_SIZE = 1900 * 1024 ** 2 + + +def github_url(repository, tag): + if not re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', repository): + raise ValueError('Use an owner/repository GitHub name') + if not re.fullmatch(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', tag): + raise ValueError('Use a versioned vX.Y.Z release tag') + return f'https://github.com/{repository}/releases/download/{tag}' + + +def verify_checksums(directory, filename="SHA256SUMS"): + manifest = directory / filename + checked = set() + for line in manifest.read_text().splitlines(): + match = re.fullmatch(r'([0-9a-f]{64}) (.+)', line) + if not match: + raise ValueError('Invalid checksum manifest') + if not re.fullmatch(r'[A-Za-z0-9_.+~^/-]+', match[2]): + raise ValueError('Checksum filenames must be safe ASCII paths') + name = Path(match[2]) + if name.is_absolute() or '..' in name.parts or name.as_posix() in checked: + raise ValueError('Unsafe or repeated checksum filename') + candidate = directory / name + if candidate.is_symlink() or not candidate.is_file() or not candidate.resolve().is_relative_to(directory.resolve()): + raise ValueError('Checksum file escapes the artifact directory') + if sha256(candidate) != match[1]: + raise ValueError(f'Artifact checksum mismatch: {name}') + checked.add(name.as_posix()) + inventory = set() + for path in directory.rglob('*'): + if path.is_symlink() or (not path.is_dir() and not path.is_file()): + raise ValueError('Artifact trees must contain only regular files and directories') + if path.is_file() and path != manifest: + inventory.add(path.relative_to(directory).as_posix()) + if checked != inventory: + raise ValueError('Checksum inventory does not cover the complete artifact directory') + return checked + + +def verify_release(directory, expected, temporary): + public_key(directory / 'CYBEXOS-desktop.asc', expected) + gpg_home = temporary / 'gpg' + gpg_home.mkdir(mode=0o700) + gpg = ['gpg', '--no-options', '--homedir', str(gpg_home), '--batch', '--no-autostart'] + subprocess.run([*gpg, '--import', str(directory / 'CYBEXOS-desktop.asc')], check=True, capture_output=True) + for relative in ('release.json', 'repodata/repomd.xml'): + subprocess.run([*gpg, '--verify', str(directory / (relative + '.asc')), str(directory / relative)], + check=True, capture_output=True) + + +def split_iso(source, destination, chunk_size=ISO_PART_SIZE): + """Keep the original filename/checksum, with a deterministic reconstruction manifest.""" + if not source.name.isascii() or not re.fullmatch(r'[A-Za-z0-9._-]+\.iso', source.name): + raise ValueError('ISO filename must be ASCII without spaces') + if not isinstance(chunk_size, int) or not 0 < chunk_size < ASSET_LIMIT: + raise ValueError('ISO part size must be positive and below the asset limit') + digest = hashlib.sha256() + parts = [] + with source.open('rb') as stream: + number = 0 + while True: + remaining = chunk_size + name = f'{source.name}.part-{number:03d}' + part = destination / name + part_digest = hashlib.sha256() + size = 0 + with part.open('xb') as output: + while remaining: + block = stream.read(min(8 * 1024 * 1024, remaining)) + if not block: + break + output.write(block) + digest.update(block) + part_digest.update(block) + size += len(block) + remaining -= len(block) + if not size: + part.unlink() + break + parts.append({'file': name, 'bytes': size, 'sha256': part_digest.hexdigest()}) + number += 1 + if not parts: + raise ValueError('ISO is empty') + record = {'format': 1, 'file': source.name, 'bytes': sum(item['bytes'] for item in parts), + 'sha256': digest.hexdigest(), 'parts': parts} + (destination / (source.name + '.parts.json')).write_text(json.dumps(record, indent=2) + '\n') + (destination / (source.name + '.sha256')).write_text(f"{record['sha256']} {source.name}\n") + return record + + +def verify_metadata(directory, packages): + """Bind authenticated repomd metadata to the exact RPMs we will upload.""" + namespace = {'r': 'http://linux.duke.edu/metadata/repo', + 'p': 'http://linux.duke.edu/metadata/common'} + root = ET.parse(directory / 'repodata/repomd.xml').getroot() + seen, primary = set(), None + for entry in root.findall('r:data', namespace): + location = entry.find('r:location', namespace) + checksum = entry.find('r:checksum', namespace) + if location is None or checksum is None or checksum.get('type') != 'sha256': + raise ValueError('Repository metadata requires SHA-256 locations') + relative = location.get('href', '') + path = Path(relative) + if path.is_absolute() or '..' in path.parts or len(path.parts) != 2 or path.parts[0] != 'repodata': + raise ValueError('Repository metadata location escapes repodata') + if relative in seen: + raise ValueError('Repeated repository metadata location') + seen.add(relative) + source = directory / path + if sha256(source) != checksum.text: + raise ValueError('Signed repository metadata checksum mismatch') + if entry.get('type') == 'primary': + if primary is not None: + raise ValueError('Repeated primary repository metadata') + if source.suffix != '.gz': + raise ValueError('Primary metadata must use gzip compression') + with gzip.open(source, 'rb') as stream: + content = stream.read(64 * 1024 * 1024 + 1) + if len(content) > 64 * 1024 * 1024: + raise ValueError('Primary repository metadata exceeds the size limit') + primary = ET.fromstring(content) + if primary is None: + raise ValueError('Repository is missing primary metadata') + listed = set() + records = {package['sha256']: package for package in packages} + if len(records) != len(packages): + raise ValueError('Repeated package digest') + for package in primary.findall('p:package', namespace): + checksum = package.find('p:checksum', namespace) + location = package.find('p:location', namespace) + if checksum is None or checksum.get('type') != 'sha256' or location is None: + raise ValueError('Primary package requires a SHA-256 checksum and URL') + record = records.get(checksum.text) + if record is None or checksum.text in listed: + raise ValueError('Primary metadata does not match the release packages') + listed.add(checksum.text) + base = location.get('{http://www.w3.org/XML/1998/namespace}base', '') + if urljoin(base, location.get('href', '')) != record['url']: + raise ValueError('Primary metadata package URL differs from this GitHub Release') + version = package.find('p:version', namespace) + if (package.findtext('p:name', namespaces=namespace) != record['name'] + or package.findtext('p:arch', namespaces=namespace) != record['arch'] + or version is None + or any(version.get(key) != record[field] for key, field in + (('epoch', 'epoch'), ('ver', 'version'), ('rel', 'release')))): + raise ValueError('Primary metadata package identity differs from the manifest') + if listed != set(records): + raise ValueError('Primary metadata omits a release package') + allowed = seen | {'repodata/repomd.xml', 'repodata/repomd.xml.asc'} + present = {str(path.relative_to(directory)) for path in (directory / 'repodata').rglob('*') if path.is_file()} + if present != allowed: + raise ValueError('Repository contains unreferenced metadata files') + + +def verify_qualifications(paths, iso_digest, packages): + """Require fresh installer and prior-release upgrade/recovery evidence.""" + scenarios = {'encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl'} + fresh, upgrade, reports = set(), False, [] + candidates = {package['unsigned_input_sha256'] for package in packages} + for path in paths: + path = Path(path) + if path.stat().st_size > 1024 * 1024: + raise ValueError('Qualification report exceeds the size limit') + report = json.loads(path.read_text()) + if not isinstance(report, dict) or report.get('status') != 'passed': + raise ValueError('Every qualification report must have passed') + checks = report.get('checks') + if not isinstance(checks, list) or not all(isinstance(item, str) for item in checks): + raise ValueError('Qualification checks must be a list of completed check names') + prior = report.get('iso_sha256', '') + if not isinstance(prior, str) or not re.fullmatch(r'[0-9a-f]{64}', prior): + raise ValueError('Qualification must identify the tested ISO SHA-256') + if prior == iso_digest and 'graphical-installer' in checks and report.get('scenario') in scenarios: + fresh.add(report['scenario']) + if (prior != iso_digest and report.get('candidate_rpm_sha256') in candidates + and {'installed-rpm-upgrade', 'recovery-boot-restore'} <= set(checks)): + upgrade = True + reports.append(report) + if fresh != scenarios: + raise ValueError('Release requires all four fresh ISO installer qualifications: ' + ', '.join(sorted(scenarios - fresh))) + if not upgrade: + raise ValueError('Release requires a different prior ISO with the exact candidate RPM upgrade and recovery qualification') + return reports + + +def prepare(signed, artifacts, destination, repository, tag, expected, qualifications=()): + signed, artifacts, destination = Path(signed), Path(artifacts), Path(destination).absolute() + expected = fingerprint(expected) + downloads = github_url(repository, tag) + if destination.exists() or destination.is_symlink(): + raise ValueError('Output must be a new directory') + repository_files = verify_checksums(signed) + artifact_files = verify_checksums(artifacts) + manifest = json.loads((signed / 'release.json').read_text()) + channel = json.loads((signed / 'update-channel.json').read_text()) + owner, project = repository.split('/') + pages_url = f'https://{owner.lower()}.github.io/{project}/44/x86_64' + if (manifest.get('format') != 1 or manifest.get('baseurl') != pages_url + or channel != {'baseurl': pages_url, 'fingerprint': expected, 'key_file': 'CYBEXOS-desktop.asc'}): + raise ValueError('Release channel must match this exact GitHub Pages repository') + if manifest['fingerprint'] != expected: + raise ValueError('Release manifest uses an unexpected signing key') + packages = manifest['packages'] + if not isinstance(packages, list) or not packages: + raise ValueError('Release contains no RPMs') + package_names = set() + for package in packages: + if package.get('name') != 'cybexos-desktop' or package.get('arch') != 'x86_64' or package.get('version') != tag[1:].replace('-', '~', 1): + raise ValueError('RPM identity must match the release tag and desktop architecture') + path = signed / package['file'] + if not re.fullmatch(r'Packages/[A-Za-z0-9][A-Za-z0-9._+~^-]*\.rpm', package['file']) or path.name in package_names: + raise ValueError('RPM filenames must be safe, distinct release assets') + package_names.add(path.name) + for key in ('sha256', 'unsigned_input_sha256'): + if not isinstance(package.get(key), str) or not re.fullmatch(r'[0-9a-f]{64}', package[key]): + raise ValueError('Release package is missing a complete SHA-256 identity') + if package['file'] not in repository_files or package.get('url') != downloads + '/' + path.name: + raise ValueError('RPM metadata must reference this exact GitHub Release') + if path.stat().st_size >= ASSET_LIMIT: + raise ValueError('Desktop RPM exceeds the GitHub 2 GiB asset limit; split the package before release') + if sha256(path) != package['sha256']: + raise ValueError('Signed manifest RPM digest mismatch') + isos = [artifacts / name for name in artifact_files if name.endswith('.iso')] + if len(isos) != 1: + raise ValueError('Exactly one checksum-verified ISO is required') + iso_digest = sha256(isos[0]) + reports = verify_qualifications(qualifications, iso_digest, packages) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='.cybexos-github-', dir=destination.parent) as directory: + work = Path(directory) + verify_release(signed, expected, work) + verify_metadata(signed, packages) + stage = work / 'output' + assets = stage / 'assets' + pages = stage / 'pages/44/x86_64' + assets.mkdir(parents=True) + pages.mkdir(parents=True) + for package in packages: + source = signed / package['file'] + copied = assets / source.name + shutil.copyfile(source, copied) + if sha256(copied) != package['sha256']: + raise ValueError('RPM changed during release preparation') + verify_signed_rpm(copied, signed / 'CYBEXOS-desktop.asc', work) + for name in ('release.json', 'release.json.asc', 'CYBEXOS-desktop.asc', 'update-channel.json'): + content = (signed / name).read_bytes() + if name == 'update-channel.json' and json.loads(content) != channel: + raise ValueError('Channel configuration changed during release preparation') + # Both delivery locations must contain the same snapshot; the + # copied Pages signatures are verified again below. + (assets / name).write_bytes(content) + (pages / name).write_bytes(content) + shutil.copytree(signed / 'repodata', pages / 'repodata') + iso = split_iso(isos[0], assets) + if iso['sha256'] != iso_digest: + raise ValueError('ISO changed during release preparation') + shutil.copyfile(Path(__file__).with_name('reconstruct-iso'), assets / 'reconstruct-iso.py') + (assets / 'qualification-reports.json').write_text(json.dumps(reports, indent=2) + '\n') + copied_verify = work / 'copied-verification' + copied_verify.mkdir() + verify_release(pages, expected, copied_verify) + verify_metadata(pages, packages) + # Metadata checksums contain only files hosted by Pages, never the large RPMs. + for target in (assets, pages): + files = sorted(path for path in target.rglob('*') if path.is_file()) + checksum_name = 'desktop-SHA256SUMS' if target == assets else 'SHA256SUMS' + (target / checksum_name).write_text(''.join(f'{sha256(path)} {path.relative_to(target)}\n' for path in files)) + (stage / 'pages/.nojekyll').touch() + (stage / 'pages/index.html').write_text('' + 'CybexOS updates

CybexOS desktop updates

' + '

Signed Fedora 44 x86_64 repository. Use the public channel configuration and independently verify its signing fingerprint.

' + 'Channel configuration\n') + rename_new_directory(stage, destination) + return destination + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--signed-repository', type=Path, required=True) + parser.add_argument('--artifacts', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--repository', default='DigitalPals/CybexOS') + parser.add_argument('--tag', required=True) + parser.add_argument('--fingerprint', required=True) + parser.add_argument('--qualification', type=Path, action='append', required=True, + help='Passed VM qualification JSON; repeat for four installer scenarios and prior-release upgrade/recovery') + args = parser.parse_args() + try: + print(prepare(args.signed_repository, args.artifacts, args.output, + args.repository, args.tag, args.fingerprint, args.qualification)) + except (OSError, ValueError, KeyError, TypeError, ET.ParseError, subprocess.SubprocessError) as error: + parser.exit(1, f'github-release: {error}\n') + + +if __name__ == '__main__': + main() diff --git a/image/installer-tests/model.test.mjs b/image/installer-tests/model.test.mjs index 6773bed2..2c186182 100644 --- a/image/installer-tests/model.test.mjs +++ b/image/installer-tests/model.test.mjs @@ -7,6 +7,12 @@ test("account validation handles mismatch, username and a long non-ASCII phrase" assert.throws(() => wizard.account({username: "Alice", password: "long fixture password", confirm: "long fixture password"})); assert.throws(() => wizard.account({username: "alice", password: "long fixture password", confirm: "other"})); assert.equal(wizard.account({username: "alice", password: "lang wachtwoord café", confirm: "lang wachtwoord café"}).username, "alice"); + for (const username of ["root", "liveuser", "sddm", "chrony"]) { + assert.throws(() => wizard.account({username, password: "long fixture password", confirm: "long fixture password"}), /reserved/); + } + for (const password of ["long\nfixture password", "x".repeat(513)]) { + assert.throws(() => wizard.account({username: "alice", password, confirm: password})); + } }); test("review and installation require a plan and explicit erase confirmation", () => { const wizard = new Wizard(); diff --git a/image/library/cybexos_managed_file.py b/image/library/cybexos_managed_file.py new file mode 100644 index 00000000..9a972865 --- /dev/null +++ b/image/library/cybexos_managed_file.py @@ -0,0 +1,88 @@ +#!/usr/bin/python3 +"""Maintain vendor defaults only while their bytes still match our last write.""" +import hashlib +import json +import os +from pathlib import Path +import tempfile + + +def atomic(path, data, mode=0o600): + fd, temporary = tempfile.mkstemp(prefix='.cybexos-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.fchmod(stream.fileno(), mode) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def manage(destination, content, ledger, absent=False, mode=0o644, check=False): + """Unknown/custom files and symlinks are never adopted or overwritten. + + Persist ownership before publishing new bytes, recording both old and new + digests, so interrupted publication is recoverable on the next invocation. + """ + destination, ledger = Path(destination), Path(ledger) + previous = json.loads(ledger.read_text()) if ledger.exists() else {} + key = str(destination) + old = previous.get(key, []) + if isinstance(old, str): + old = [old] + if destination.is_symlink() or any(p.is_symlink() for p in destination.parents): + return {'changed': False, 'preserved': True} + if destination.exists() and not destination.is_file(): + return {'changed': False, 'preserved': True} + current = destination.read_bytes() if destination.exists() else None + digest = hashlib.sha256(current).hexdigest() if current is not None else None + desired = None if absent else hashlib.sha256(content).hexdigest() + if digest is not None and digest != desired and digest not in old: + return {'changed': False, 'preserved': True} + # A user deletion is an override once we have adopted an existing file. + if current is None and old: + return {'changed': False, 'preserved': True} + changed = current != (None if absent else content) + if check: + return {'changed': changed, 'preserved': False} + ledger.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if changed and current is not None: + backup = ledger.parent / 'backups' / hashlib.sha256(key.encode()).hexdigest() / digest + backup.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if not backup.exists(): + atomic(backup, current, destination.stat().st_mode & 0o777) + previous[key] = list(dict.fromkeys(x for x in (digest, desired) if x)) + atomic(ledger, (json.dumps(previous, sort_keys=True) + '\n').encode()) + if changed: + if absent: + destination.unlink(missing_ok=True) + else: + destination.parent.mkdir(parents=True, exist_ok=True) + atomic(destination, content, mode) + previous[key] = [desired] if desired else [] + atomic(ledger, (json.dumps(previous, sort_keys=True) + '\n').encode()) + return {'changed': changed, 'preserved': False} + + +def main(): + from ansible.module_utils.basic import AnsibleModule + module = AnsibleModule(argument_spec={ + 'dest': {'type': 'path', 'required': True}, + 'content': {'type': 'str', 'default': ''}, + 'state': {'choices': ['present', 'absent'], 'default': 'present'}, + 'mode': {'type': 'str', 'default': '0644'}, + }, supports_check_mode=True) + try: + result = manage(module.params['dest'], module.params['content'].encode(), + '/var/lib/cybexos/reconcile/managed-files.json', + absent=module.params['state'] == 'absent', + mode=int(module.params['mode'], 8), check=module.check_mode) + except (OSError, ValueError) as error: + module.fail_json(msg=str(error)) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/image/live-rootfs/usr/libexec/cybexos-installer-backend b/image/live-rootfs/usr/libexec/cybexos-installer-backend index b6ac8b85..c05d2cf6 100755 --- a/image/live-rootfs/usr/libexec/cybexos-installer-backend +++ b/image/live-rootfs/usr/libexec/cybexos-installer-backend @@ -63,8 +63,11 @@ def validate_account(data, choices): encrypted = data.get('encrypted', True) if not isinstance(encrypted, bool): raise Invalid('The encryption choice is invalid.') + passwordless_wheel = data.get('passwordless_wheel', False) + if not isinstance(passwordless_wheel, bool): + raise Invalid('The sudo password choice is invalid.') return dict(username=username, keyboard=keyboard, locale=locale, timezone=timezone, - hostname=hostname, encrypted=encrypted) + hostname=hostname, encrypted=encrypted, passwordless_wheel=passwordless_wheel) def action_set(actions): @@ -78,6 +81,7 @@ def action_set(actions): def fingerprint(disk): identity = {key: disk.get(key) for key in ('name', 'path', 'size', 'serial', 'wwn', 'device_id')} + identity['partitions'] = sorted(disk.get('partitions', []), key=lambda item: item.get('path', '')) return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() @@ -139,7 +143,38 @@ def installation_status(store, run=subprocess.run): state.update(phase='failed-install', message='The installation monitor stopped unexpectedly. Anaconda may still be writing the disk. Keep this session open and inspect the installer diagnostics before restarting.') # This status reader never writes: the worker alone owns progress # and completion. Its concurrent final update must win this race. - return state + return public_status(state) + + +def public_status(state): + """Never send persisted account, disk plan, or Anaconda text to the browser.""" + allowed = ('setup', 'planning', 'review', 'failed-plan', 'installing', 'complete', 'failed-install') + phase = state.get('phase') if state.get('phase') in allowed else 'failed-install' + messages = { + 'installing': 'Anaconda is installing CybexOS. Keep this session open.', + 'complete': 'CybexOS is installed. Restart and remove the installation medium.', + 'failed-install': 'Installation did not finish. Save diagnostics and check the installer logs before restarting.', + } + result = {'phase': phase, 'message': messages.get(phase, '')} + for key in ('step', 'total'): + if type(state.get(key)) is int and 0 <= state[key] <= 100000: + result[key] = state[key] + return result + + +def diagnostics(store, run=subprocess.run): + """Export useful status without raw logs, credentials, disk IDs, or usernames.""" + state = store.read() + service = run(['systemctl', 'show', 'cybexos-installer-worker.service', + '--property=ActiveState,SubState,Result', '--no-pager'], + capture_output=True, text=True, timeout=10) + fields = {} + for line in service.stdout.splitlines(): + key, separator, value = line.partition('=') + if separator and key in ('ActiveState', 'SubState', 'Result') and re.fullmatch(r'[a-z-]{1,32}', value): + fields[key] = value + return {'schema': 1, 'installer': public_status(state), 'worker': fields, + 'backend_ready': Path('/run/anaconda/backend_ready').exists()} class Installer: @@ -153,6 +188,13 @@ class Installer: def inventory(self): return self.backend.inventory() + def rescan(self): + self.require_idle() + # Invalidate every review before the device tree is refreshed. + self.state.write({'phase': 'setup'}) + self.backend.rescan() + return self.inventory() + def keyboard(self, data): self.require_idle() layout = data.get('keyboard', '') @@ -218,7 +260,7 @@ class Installer: state.update(phase='failed-install', message='The installation worker could not start. Review its status before restarting the installer.') self.state.write(state) raise - return state + return public_status(state) def worker(self): state = self.state.read() @@ -226,10 +268,11 @@ class Installer: raise Invalid('No new confirmed installation is queued.') def progress(step, total, message): - state.update(step=step, total=total, message=message) + # Anaconda's free-form task text can contain paths or user data. + state.update(step=step, total=total) self.state.write(state) self.emit({'event': 'progress', 'phase': 'installing', 'step': step, - 'total': total, 'message': message}) + 'total': total, 'message': 'Installing CybexOS…'}) try: self.backend.run_install(state, progress, self.state) @@ -296,14 +339,34 @@ class Anaconda: if not device.get('is-disk') or device.get('protected'): continue attrs = device.get('attrs', {}) + partitions = [] + try: + report = subprocess.run(['lsblk', '--json', '--bytes', '--output', + 'PATH,SIZE,TYPE,FSTYPE', '--', device['path']], + check=True, capture_output=True, text=True, timeout=8) + def visit(nodes): + for node in nodes: + if node.get('type') == 'part': + partitions.append({'path': node.get('path', ''), 'size': node.get('size', 0), + 'filesystem': node.get('fstype') or ''}) + visit(node.get('children', [])) + visit(json.loads(report.stdout).get('blockdevices', [])) + except (OSError, ValueError, TypeError, subprocess.SubprocessError): + # Anaconda remains the authority for whether this disk is safe to select. + pass disks.append(dict(name=name, path=device['path'], size=device['size'], model=attrs.get('model') or device.get('description') or name, serial=attrs.get('serial', ''), wwn=attrs.get('wwn', ''), - device_id=device.get('device-id', ''), removable=device.get('removable', False))) + device_id=device.get('device-id', ''), removable=device.get('removable', False), + partitions=partitions)) localization = self.proxy('Localization') keyboards = plain(localization.GetKeyboardLayouts()) keyboard_choices = [{'id': item['layout-id'], 'label': item['description']} for item in keyboards] - locales = list(localization.GetCommonLocales()) + # Common locales are only Anaconda's short list of popular choices. + # Enumerate its supported languages and regional locales so installed + # translations such as Dutch remain selectable. + locales = sorted({locale for language in localization.GetLanguages() + for locale in localization.GetLocales(language)}) if localization.Language and localization.Language not in locales: locales.append(localization.Language) timezone = self.proxy('Timezone') @@ -338,6 +401,9 @@ class Anaconda: initialization.DrivesToClear = [] initialization.DevicesToClear = [] + def rescan(self): + self.task('Storage', self.proxy('Storage').ScanDevicesWithTask(), timeout=300) + def keyboard(self, layout): localization = self.proxy('Localization') localization.XLayouts = [layout] @@ -441,10 +507,10 @@ def check_live_environment(require_backend=True): def main(): os.umask(0o077) command = sys.argv[1] if len(sys.argv) == 2 else '' - if command not in ('inventory', 'keyboard', 'plan', 'install', 'status', 'geolocate', 'reset', 'advanced', + if command not in ('inventory', 'rescan', 'diagnostics', 'keyboard', 'plan', 'install', 'status', 'geolocate', 'reset', 'advanced', 'worker', 'reboot'): raise Invalid('Unknown installer request.') - check_live_environment(require_backend=command not in ('status', 'reboot')) + check_live_environment(require_backend=command not in ('status', 'diagnostics', 'reboot')) RUNTIME.mkdir(mode=0o700, parents=True, exist_ok=True) # Serialize planning and commit. Status remains readable during installation, # and a slow geolocation lookup, which changes no selection, never blocks setup. @@ -456,6 +522,8 @@ def main(): raise Invalid('Another installer operation is still running.') from None if command == 'status': return installation_status(State()) + if command == 'diagnostics': + return diagnostics(State()) if command == 'reboot': if State().read().get('phase') != 'complete': raise Invalid('Wait for installation to finish before restarting.') @@ -475,6 +543,8 @@ def main(): installer = Installer(Anaconda(), State(), emit) if command == 'inventory': return installer.inventory() + if command == 'rescan': + return installer.rescan() if command == 'geolocate': return installer.geolocate() if command in ('reset', 'advanced'): diff --git a/image/live-rootfs/usr/libexec/cybexos-installer-target b/image/live-rootfs/usr/libexec/cybexos-installer-target index 682bfeaf..5f92e822 100755 --- a/image/live-rootfs/usr/libexec/cybexos-installer-target +++ b/image/live-rootfs/usr/libexec/cybexos-installer-target @@ -54,26 +54,38 @@ def root_is_encrypted(root, run=subprocess.run): return False -def record_autologin(root, username, autologin): +def record_install_choices(root, username, autologin, passwordless_wheel): """Keep the saved installation choices in step with the verified decision. Provisioning inside the target recorded config.yml before encryption could - be verified from outside it; only its one autologin line changes here. + be verified from outside it; update only the confirmed policy lines. """ path = root / 'etc/cybexos/config.yml' - try: - text = path.read_text() - except FileNotFoundError: - return + text = path.read_text() if not re.search(r"^primary_user: '" + re.escape(username) + "'$", text, re.MULTILINE): + raise RuntimeError('The installed configuration does not match the selected account.') + updated = text + for key, choice in (('desktop_autologin', autologin), ('passwordless_wheel', passwordless_wheel)): + pattern = rf'^{key}: (?:true|false)$' + if len(re.findall(pattern, updated, re.MULTILINE)) != 1: + raise RuntimeError(f'The installed configuration has no unique {key} choice.') + updated = re.sub(pattern, f'{key}: {str(choice).lower()}', updated, count=1, flags=re.MULTILINE) + temporary = path.with_name('.config.yml.cybexos-installer') + temporary.write_text(updated) + temporary.chmod(0o644) + temporary.replace(path) + + +def apply_sudo_policy(root, passwordless_wheel): + path = root / 'etc/sudoers.d/10-wheel-nopasswd' + if not passwordless_wheel: + path.unlink(missing_ok=True) return - updated = re.sub(r'^desktop_autologin: (?:true|false)$', - 'desktop_autologin: ' + ('true' if autologin else 'false'), text, count=1, flags=re.MULTILINE) - if updated != text: - temporary = path.with_name('.config.yml.cybexos-installer') - temporary.write_text(updated) - temporary.chmod(0o644) - temporary.replace(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name('.10-wheel-nopasswd.cybexos-installer') + temporary.write_text('%wheel ALL=(ALL:ALL) NOPASSWD: ALL\n') + temporary.chmod(0o440) + temporary.replace(path) def finalize(root, policy, encrypted): @@ -83,6 +95,9 @@ def finalize(root, policy, encrypted): raise RuntimeError('Installation policy does not name a valid account.') if not isinstance(account.get('encrypted'), bool) or not isinstance(encrypted, bool): raise RuntimeError('Installation policy does not name a valid encryption choice.') + if type(account.get('passwordless_wheel', False)) is not bool: + raise RuntimeError('Installation policy does not name a valid sudo choice.') + passwordless_wheel = account.get('passwordless_wheel', False) users = [line.split(':') for line in (root / 'etc/passwd').read_text().splitlines()] if not any(row[0] == username and 1000 <= int(row[2]) < 65534 for row in users): raise RuntimeError('The installed administrator account is missing.') @@ -97,7 +112,8 @@ def finalize(root, policy, encrypted): path.write_text(json.dumps({'version': 1, 'user': username, 'autologin': autologin, 'live': False}) + '\n') path.chmod(0o644) - record_autologin(root, username, autologin) + record_install_choices(root, username, autologin, passwordless_wheel) + apply_sudo_policy(root, passwordless_wheel) # The prepared live /etc/sddm.conf can be copied with the live filesystem. # The installed boot must regenerate it from its own verified policy. (root / 'etc/sddm.conf').unlink(missing_ok=True) @@ -107,6 +123,7 @@ def finalize(root, policy, encrypted): record.parent.mkdir(parents=True, exist_ok=True) record.write_text(json.dumps({'username': username, 'encrypted': encrypted, 'autologin': autologin, + 'passwordless_wheel': passwordless_wheel, 'passwordsInitiallyShared': True, 'keyring': 'encrypted-boot-passphrase-or-prompt'}) + '\n') record.chmod(0o644) diff --git a/image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks b/image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks index a032c24b..a73ee887 100644 --- a/image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks +++ b/image/live-rootfs/usr/share/anaconda/post-scripts/90-cybexos.ks @@ -5,6 +5,10 @@ if getent passwd liveuser >/dev/null; then userdel --remove liveuser fi rm -f /etc/sudoers.d/cybexos-live +# The live filesystem can contain the image build's wheel policy. Every fresh +# target starts with password-required sudo; the confirmed guided choice is +# applied by the target helper after offline provisioning. +rm -f /etc/sudoers.d/10-wheel-nopasswd rm -f /etc/polkit-1/rules.d/49-cybexos-live.rules rm -f /var/lib/AccountsService/users/liveuser rm -f /etc/systemd/system/multi-user.target.wants/cybexos-live.service diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html b/image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html index 1832b9c9..c2d2fe34 100644 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html +++ b/image/live-rootfs/usr/share/cockpit/cybexos-installer/index.html @@ -36,6 +36,8 @@

Your next
workspace.

Use at least 12 characters. Your chosen layout will also be used at the boot unlock screen.

+ +

For your security, sudo asks for your account password by default. Enable this only if you want passwordless administrator commands.

Language and computer details @@ -50,6 +52,8 @@

Your next
workspace.

Choose a disk for CybexOS. The entire selected disk will be erased.

+ +
Choose a disk to see its identity and existing partitions.
Encrypted Btrfs

Your files stay protected while the computer is powered off. CybexOS creates the system partitions automatically.

Advanced options @@ -75,6 +79,11 @@

Your next
workspace.

Space for what matters.

Your desktop, tools, and applications are included on the installation medium. No download is needed to get started.

+ diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.css b/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.css index fe54028f..c136daf6 100644 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.css +++ b/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.css @@ -97,6 +97,9 @@ input:focus, select:focus, button:focus-visible, a:focus-visible, summary:focus- margin: 24px 0; } .card p { margin-bottom: 0; font-size: 14px; } +#disk-details { white-space: pre-line; line-height: 1.6; overflow-wrap: anywhere; } +#rescan-disks { margin-top: 12px; } +#failure-help button { margin-top: 12px; } details { border-top: 1px solid var(--line); margin: 24px 0; padding-top: 16px; } summary { cursor: pointer; font-size: 14px; color: var(--muted); } button, a { font: inherit; } @@ -135,7 +138,7 @@ footer { .erase { padding: 20px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; } dl { display: grid; grid-template-columns: 130px 1fr; gap: 14px; padding: 24px; background: var(--surface); border-radius: 16px; } dt { color: var(--muted); font-size: 14px; } -dd { margin: 0; overflow-wrap: anywhere; } +dd { margin: 0; overflow-wrap: anywhere; white-space: pre-line; } #error { padding: 17px 20px; border: 1px solid var(--accent-text); background: var(--surface); color: var(--accent-text); border-radius: 12px; margin-bottom: 22px; } #busy { padding: 20px; color: var(--muted); } #actions { padding-left: 18px; font-size: 13px; color: var(--muted); } diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.js b/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.js index 6ece386a..4fe95d00 100644 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.js +++ b/image/live-rootfs/usr/share/cockpit/cybexos-installer/installer.js @@ -7,6 +7,7 @@ let polling; let installationComplete = false; let appliedKeyboard = null; let timezoneChosen = false; +let availableDisks = []; function fail(error) { $("error").textContent = error.message || "The installer could not complete this operation."; @@ -59,11 +60,38 @@ async function run(message, work) { function options(id, values, selected) { $(id).replaceChildren(...values.map(value => { const option = document.createElement("option"); - option.value = value.id || value; option.textContent = value.label || value; + option.value = typeof value === "object" ? value.id : value; + option.textContent = typeof value === "object" ? value.label : value; return option; })); if (selected) $(id).value = selected; } +function size(bytes) { + return Number.isFinite(Number(bytes)) ? `${(Number(bytes) / 1024**3).toFixed(1)} GiB` : "Unknown size"; +} +function partitionSummary(disk) { + if (!disk.partitions?.length) return "No existing partitions reported"; + return disk.partitions.map(partition => + `${partition.path || "Partition"} (${size(partition.size)}${partition.filesystem ? `, ${partition.filesystem}` : ""})`).join("; "); +} +function diskIdentity(disk) { + return [`${disk.model} · ${disk.path} · ${size(disk.size)}`, + `Serial: ${disk.serial || "Not reported"}`, + `WWN: ${disk.wwn || "Not reported"}`, + `Existing partitions: ${partitionSummary(disk)}`].join("\n"); +} +function showSelectedDisk() { + const disk = availableDisks.find(item => item.name === $("disk").value); + $("disk-details").textContent = disk ? diskIdentity(disk) : "Choose a disk to see its identity and existing partitions."; +} +function showDisks(disks) { + availableDisks = disks; + options("disk", [{id: "", label: "Select a disk…"}, ...disks.map(disk => ({ + id: disk.name, label: `${disk.model} — ${size(disk.size)} — ${disk.path}${disk.removable ? " (removable)" : ""}` + }))]); + $("disk").value = ""; + showSelectedDisk(); +} function timezoneOptions(zones, selected) { const groups = new Map(); for (const zone of zones) { @@ -104,7 +132,8 @@ function waitForBackend() { }); } function account() { - return Object.fromEntries(["username", "password", "confirm", "keyboard", "locale", "timezone", "hostname"].map(id => [id, $(id).value])); + return {...Object.fromEntries(["username", "password", "confirm", "keyboard", "locale", "timezone", "hostname"].map(id => [id, $(id).value])), + passwordless_wheel: $("passwordless-wheel").checked}; } function showProgress(data) { wizard.page = "progress"; @@ -121,6 +150,7 @@ function showProgress(data) { clearInterval(polling); $("progress-title").textContent = "Installation needs attention."; } + $("failure-help").hidden = data.phase !== "failed-install"; render(); } function monitor() { @@ -145,9 +175,7 @@ async function initialize() { timezoneOptions(data.timezones, data.timezone); if (data.detected_timezone) showDetectedTimezone(data.detected_timezone); else detectTimezone(data.timezones); - options("disk", [{id: "", label: "Select a disk…"}, ...data.disks.map(disk => ({ - id: disk.name, label: `${disk.model} — ${(disk.size / 1024**3).toFixed(1)} GiB — ${disk.path}${disk.removable ? " (removable)" : ""}` - }))]); + showDisks(data.disks); initializing = false; const keyboard = await call("keyboard", {keyboard: $("keyboard").value}); appliedKeyboard = keyboard.keyboard; @@ -168,6 +196,11 @@ $("timezone").addEventListener("change", () => { timezoneChosen = true; $("timezone-status").textContent = ""; }); +$("disk").addEventListener("change", showSelectedDisk); +$("rescan-disks").addEventListener("click", () => run("Rescanning disks with Anaconda…", async () => { + const data = await call("rescan"); + showDisks(data.disks); +})); $("account-form").addEventListener("submit", event => { event.preventDefault(); try { @@ -184,9 +217,10 @@ $("disk-form").addEventListener("submit", event => { const plan = await call("plan", data); wizard.plan = plan; wizard.page = "review"; $("erase").checked = false; - const fields = {Disk: `${plan.disk.model} · ${plan.disk.path}`, Account: plan.account.username, + const fields = {Disk: diskIdentity(plan.disk), Account: plan.account.username, Storage: plan.account.encrypted ? "Encrypted Btrfs · LUKS2" : "Btrfs · unencrypted", "At startup": plan.account.encrypted ? "Unlock your disk → automatic login" : "Sign in to your account", + Sudo: plan.account.passwordless_wheel ? "Administrator commands do not ask for a password" : "Administrator commands ask for your account password", "Boot keyboard": plan.boot_keyboard, Language: plan.account.locale, Timezone: plan.account.timezone}; $("summary").replaceChildren(...Object.entries(fields).flatMap(([key, value]) => { const term = document.createElement("dt"), detail = document.createElement("dd"); @@ -234,6 +268,14 @@ $("advanced").addEventListener("click", () => run("Opening the full installer… window.location.href = "../anaconda-webui/index.html"; })); $("refresh-status").addEventListener("click", () => run("Checking installation status…", async () => showProgress(await call("status")))); +$("save-diagnostics").addEventListener("click", () => run("Preparing redacted diagnostics…", async () => { + const report = await call("diagnostics"); + const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2) + "\n"], {type: "application/json"})); + const link = document.createElement("a"); + link.href = url; link.download = "cybexos-installer-diagnostics.json"; + document.body.append(link); link.click(); link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +})); $("retry").addEventListener("click", initialize); $("reboot").addEventListener("click", () => run("Restarting…", () => call("reboot"))); window.addEventListener("beforeunload", event => { diff --git a/image/live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs b/image/live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs index fa926bef..6e489db2 100644 --- a/image/live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs +++ b/image/live-rootfs/usr/share/cockpit/cybexos-installer/model.mjs @@ -1,4 +1,6 @@ // Pure state and validation shared by the real UI and fixture tests. +const reserved = new Set(["root", "liveuser", "bin", "daemon", "adm", "mail", "ftp", "nobody", + "dbus", "systemd", "gdm", "sddm", "sshd", "polkitd", "chrony"]); export class Wizard { constructor() { this.page = "setup"; this.busy = false; this.plan = null; } move(page) { @@ -14,8 +16,11 @@ export class Wizard { try { return await work(); } finally { this.busy = false; } } account(data) { - if (!/^[a-z_][a-z0-9_-]{0,30}$/.test(data.username)) throw new Error("Choose a valid lowercase username."); - if (data.password.length < 12) throw new Error("Use at least 12 characters for your password."); + if (typeof data.username !== "string" || !/^[a-z_][a-z0-9_-]{0,30}$/.test(data.username) || reserved.has(data.username)) + throw new Error("Choose a username of 1–31 lowercase letters, numbers, underscores or dashes, starting with a letter. System names are reserved."); + if (typeof data.password !== "string" || Array.from(data.password).length < 12 || Array.from(data.password).length > 512) + throw new Error("Use a password of 12–512 characters."); + if (/[\x00-\x1f\x7f]/.test(data.password)) throw new Error("The password cannot contain control characters."); if (data.password !== data.confirm) throw new Error("The passwords do not match."); return data; } diff --git a/image/login_qualification.py b/image/login_qualification.py index 2a40d4e0..d84b7b2f 100644 --- a/image/login_qualification.py +++ b/image/login_qualification.py @@ -6,7 +6,7 @@ import json import time -from vm_testing import run +from vm_testing import poweroff_guest, run USER_ENV = ('export XDG_RUNTIME_DIR=/run/user/$(id -u); ' @@ -239,14 +239,10 @@ def wait_login_state(vm, desktop, timeout=90, stable_for=0): def reboot_installed(vm, password, root_script, expect_desktop=True): - root_script(vm, 'sync\nsystemctl poweroff --no-block\n', password) - vm.ssh_ready = False - vm.process.wait(timeout=60) - if vm.console: - vm.console.close() + poweroff_guest(vm, password, root_script, timeout=60) vm.start(user='qualification') vm.unlock_disk(password) - vm.wait_ssh(setup=False) + vm.wait_ssh(setup=False, redactions=(password,)) if expect_desktop: vm.wait_desktop() wait_login_state(vm, desktop=expect_desktop, stable_for=0 if expect_desktop else 10) diff --git a/image/package b/image/package index 48722d38..3ffa5e8f 100755 --- a/image/package +++ b/image/package @@ -14,7 +14,7 @@ import urllib.request import jinja2 import yaml from application_payload import relativize_seed_links, include_applications -from desktop_payload import prepare_defaults, prepare_session, split_seed +from desktop_payload import prepare_defaults, prepare_session, split_seed, prepare_managed_defaults from provision_payload import prepare_provision from release_metadata import install_update_channel, render_spec @@ -64,6 +64,7 @@ def main(): target.chmod(0o755 if executable else 0o644) runtime = "usr/share/cybexos/runtime" + copy("LICENSE", "usr/share/licenses/cybexos-desktop/LICENSE") quickshell = ROOT / "roles/desktop/files/quickshell" for source in sorted(quickshell.rglob("*")): if not source.is_file() or source.is_symlink() or "__pycache__" in source.parts or source.suffix == ".pyc": @@ -83,8 +84,6 @@ def main(): if name == "input.lua": content = content.replace('kb_layout = "us"', 'kb_layout = os.getenv("CYBEXOS_KEYBOARD_LAYOUT") or "us"') content = content.replace('kb_variant = ""', 'kb_variant = os.getenv("CYBEXOS_KEYBOARD_VARIANT") or ""') - if name == "looknfeel.lua": - content += '\nhl.window_rule({ match = { class = [[^cybex$]] }, float = true, center = true, size = { 920, 620 } })\n' write(f"{runtime}/hypr/{name}", content) contract = json.loads((ROOT / "assets/desktop-contract.json").read_text()) helpers = contract["userHelpers"] + ["cybexos-runtime", "cybexos-update-run"] @@ -125,9 +124,9 @@ def main(): write(f"{seed}/.local/share/applications/dev-{box['name']}.desktop", desktop.replace("{{ primary_home }}/.local/bin/", "")) # Systemd's %h resolves at service start, after account creation. - hermes = environment.from_string((ROOT / "roles/desktop/templates/hermes-menubar-bridge.service.j2").read_text()).render(primary_home="%h") + hermes = environment.from_string((ROOT / "roles/desktop/templates/hermes-menubar-bridge.service.j2").read_text()).render(primary_home="%h", hermes_bridge_executable="/usr/libexec/cybexos-hermes-menubar-bridge") write("usr/lib/systemd/user/hermes-menubar-bridge.service", hermes) - copy("roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py", f"{seed}/.local/libexec/hermes-menubar-bridge", True) + copy("roles/desktop/files/hermes-menubar-bridge/hermes_bridge.py", "usr/libexec/cybexos-hermes-menubar-bridge", True) # Voxtype's RPM owns its system user unit. The session target starts that # unit; do not collide with it by packaging our per-user Ansible template. write(f"{seed}/.npmrc", "prefix=${HOME}/.npm-global\n") @@ -164,6 +163,8 @@ def main(): prepare_defaults(ROOT, payload, environment, inventory) prepare_session(ROOT, payload, inventory) prepare_provision(ROOT, payload) + copy("image/library/cybexos_managed_file.py", "usr/share/cybexos/lib/managed_files.py") + copy("image/release_metadata.py", "usr/share/cybexos/lib/release_metadata.py") copy("roles/apps/files/cybexos-repository-policy", "usr/libexec/cybexos-repository-policy", True) # The workstation's font family mappings; roles/apps/tasks/fonts.yml # installs the same file on checkout deployments. @@ -196,6 +197,7 @@ def main(): destination.chmod(0o644) relativize_seed_links(payload / seed, seed_home) split_seed(payload / "usr/share/cybexos", contract) + prepare_managed_defaults(payload / "usr/share/cybexos") manifest = {} for file in sorted(payload.rglob("*")): if file.is_symlink(): @@ -206,6 +208,10 @@ def main(): manifest[str(file.relative_to(payload))] = hashlib.file_digest(stream, "sha256").hexdigest() elif file.is_dir(): file.chmod(0o755) + # The complete payload identity changes on every effective package change. + identity = hashlib.sha256(json.dumps(manifest, sort_keys=True).encode()).hexdigest() + write("usr/share/cybexos/reconcile-version", identity + "\n") + manifest["usr/share/cybexos/reconcile-version"] = hashlib.sha256((identity + "\n").encode()).hexdigest() (build / "package-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") sources = build / "rpmbuild/SOURCES" sources.mkdir(parents=True) diff --git a/image/prepare-github-release b/image/prepare-github-release new file mode 100755 index 00000000..84d7911a --- /dev/null +++ b/image/prepare-github-release @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from github_release import main + +if __name__ == '__main__': + main() diff --git a/image/provision.yml b/image/provision.yml index 6cee50f6..8bbf28c6 100644 --- a/image/provision.yml +++ b/image/provision.yml @@ -18,20 +18,25 @@ - name: Apply the common installed-system policy ansible.builtin.include_role: name: base - tasks_from: image - when: not cybexos_hardware_only | bool + tasks_from: "{{ (cybexos_reconcile | default(false) | bool) | ternary('reconcile', 'image') }}" + when: + - not cybexos_hardware_only | bool - name: Apply the shared Fish configuration ansible.builtin.include_role: name: dotfiles tasks_from: shell-defaults - when: not cybexos_hardware_only | bool + when: + - not cybexos_hardware_only | bool + - not cybexos_reconcile | default(false) | bool - name: Prefer reliable hardware video decoding in mpv ansible.builtin.include_role: name: apps tasks_from: mpv - when: not cybexos_hardware_only | bool + when: + - not cybexos_hardware_only | bool + - not cybexos_reconcile | default(false) | bool # The workstation installer manages personal defaults for accounts that # have none of their own; every image account starts from the seed. A @@ -39,13 +44,17 @@ - name: Manage personal defaults on image installations by default ansible.builtin.set_fact: manage_personal_dotfiles: true - when: not cybexos_hardware_only | bool + when: + - not cybexos_hardware_only | bool + - not cybexos_reconcile | default(false) | bool - name: Apply the shared session environment ansible.builtin.include_role: name: dotfiles tasks_from: environment - when: not cybexos_hardware_only | bool + when: + - not cybexos_hardware_only | bool + - not cybexos_reconcile | default(false) | bool # Runs before the account is seeded: the Kitty include and fragment it # writes are what the seed would provide, so the seed leaves them alone. @@ -53,7 +62,9 @@ ansible.builtin.include_role: name: dotfiles tasks_from: personal - when: not cybexos_hardware_only | bool + when: + - not cybexos_hardware_only | bool + - not cybexos_reconcile | default(false) | bool - name: Link the packaged CybexOS agent skill for coding agents ansible.builtin.include_role: @@ -67,7 +78,9 @@ ansible.builtin.include_role: name: desktop tasks_from: portals - when: not cybexos_hardware_only | bool + when: + - not cybexos_hardware_only | bool + - not cybexos_reconcile | default(false) | bool # The compose installs the CPU-default selector; the image builder's own # selection never reaches the image. A GPU-less machine keeps the CPU. @@ -79,6 +92,7 @@ apps_voxtype_gpu_optional: true when: - not cybexos_hardware_only | bool + - not cybexos_reconcile | default(false) | bool - features.developer_tools | bool # The package's user units carry the inventory's feature defaults. The @@ -102,6 +116,18 @@ Environment=CYBEXOS_CONNECTED_WIDGETS={{ (features.connected_widgets | bool) | ternary('1', '0') }} Environment=CYBEXOS_DEVELOPER_TOOLS={{ (features.developer_tools | bool) | ternary('1', '0') }} register: image_shell_features + when: not cybexos_reconcile | default(false) | bool + + - name: Reconcile the configured shell features while preserving local edits + cybexos_managed_file: + dest: /etc/systemd/user/quickshell.service.d/50-cybexos-features.conf + mode: "0644" + content: | + [Service] + Environment=CYBEXOS_CONNECTED_WIDGETS={{ (features.connected_widgets | bool) | ternary('1', '0') }} + Environment=CYBEXOS_DEVELOPER_TOOLS={{ (features.developer_tools | bool) | ternary('1', '0') }} + register: image_reconcile_shell_features + when: cybexos_reconcile | default(false) | bool - name: Inspect the Hermes bridge mask ansible.builtin.stat: @@ -128,6 +154,7 @@ register: image_hermes_unmasked when: - features.connected_widgets | bool + - not cybexos_reconcile | default(false) | bool - image_hermes_mask.stat.islnk | default(false) - image_hermes_mask.stat.lnk_source | default('') == '/dev/null' @@ -139,13 +166,15 @@ failed_when: false when: - not cybexos_offline | bool - - image_shell_features is changed or image_hermes_masked is changed or image_hermes_unmasked is changed + - image_shell_features is changed or image_reconcile_shell_features is changed or image_hermes_masked is changed or image_hermes_unmasked is changed - name: Detect hardware during offline installation as well as repair ansible.builtin.include_role: name: xps-2026 tasks_from: detect - when: not cybexos_hardware_only | bool + when: + - not cybexos_hardware_only | bool + - not cybexos_reconcile | default(false) | bool - name: Create the machine policy directory ansible.builtin.file: @@ -158,7 +187,9 @@ dest: /etc/cybexos/hardware.json content: "{{ {'xps_2026': xps_2026_is_supported | bool} | to_json }}\n" mode: "0644" - when: not cybexos_hardware_only | bool + when: + - not cybexos_hardware_only | bool + - not cybexos_reconcile | default(false) | bool - name: Read the hardware account identity ansible.builtin.getent: @@ -182,3 +213,13 @@ - xps_2026_camera_enabled | bool - xps_2026_has_ovti08f4 | bool - xps_2026_has_ipu7 | bool + + - name: Record the hardware role outcome for the boot-aware setup wrapper + ansible.builtin.copy: + dest: /var/lib/cybexos/hardware-result.json + mode: "0644" + content: >- + {{ {'reboot_required': xps_2026_camera_reboot_required | default(false) | bool, + 'camera_ready': xps_2026_camera_ready | default(false) | bool, + 'preserved_previous': xps_2026_camera_preserve_previous | default(false) | bool} | to_json }} + when: cybexos_hardware_only | bool diff --git a/image/provision_payload.py b/image/provision_payload.py index 87894e4b..a1c476a9 100644 --- a/image/provision_payload.py +++ b/image/provision_payload.py @@ -4,7 +4,7 @@ def prepare_provision(root, payload): destination = payload / 'usr/share/cybexos/provision' - for relative in ('image/provision.yml', 'image/provision.cfg', 'inventory/group_vars/all.yml', + for relative in ('image/provision.yml', 'image/provision.cfg', 'image/library', 'inventory/group_vars/all.yml', 'roles/base', 'roles/xps-2026', 'roles/apps/tasks/mpv.yml', 'roles/apps/tasks/voxtype-backend.yml', 'roles/apps/handlers/main.yml', 'roles/dotfiles/tasks/shell-defaults.yml', diff --git a/image/pxe_publish.py b/image/pxe_publish.py index f1abfc80..9d9c2d0d 100644 --- a/image/pxe_publish.py +++ b/image/pxe_publish.py @@ -7,6 +7,7 @@ import subprocess import tempfile import time +import urllib.error import urllib.request from urllib.parse import urlsplit @@ -63,39 +64,63 @@ def __init__(self, url, contract, timeout=180, request=None, sleep=time.sleep, c if parsed.scheme != "http" or parsed.hostname not in ("127.0.0.1", "localhost", "::1"): raise ValueError("Run this tool on the PXE server and use its loopback iVentoy URL") self.url, self.contract, self.timeout = url, contract, timeout - self.request = request or self._request + self.request = request self.sleep, self.clock = sleep, clock required = {"pxe_status_pointer", "pxe_running_value", "refresh_busy_pointer", "refresh_idle_value", "refresh_busy_value"} if not required.issubset(contract) or contract["refresh_idle_value"] == contract["refresh_busy_value"]: raise ValueError("Incomplete iVentoy API contract") - def _request(self, method): + def _request(self, method, timeout): request = urllib.request.Request(self.url, json.dumps({"method": method}).encode(), {"Content-Type": "application/json"}) - with urllib.request.urlopen(request, timeout=30) as response: + with urllib.request.urlopen(request, timeout=timeout) as response: return json.load(response) - def wait_idle(self): - deadline = self.clock() + self.timeout + def _query(self, method, deadline): + remaining = deadline - self.clock() + if remaining <= 0: + raise RuntimeError("iVentoy refresh verification deadline exceeded") + response = (self.request(method) if self.request is not None else + self._request(method, min(30, remaining))) + if self.clock() >= deadline: + raise RuntimeError("iVentoy refresh verification deadline exceeded") + return response + + def _pause(self, deadline): + self.sleep(max(0, min(2, deadline - self.clock()))) + + def wait_idle(self, deadline=None, retry_timeouts=False): + deadline = self.clock() + self.timeout if deadline is None else deadline while self.clock() < deadline: - if not validate_status(self.request("query_status"), self.contract): + try: + status = self._query("query_status", deadline) + except (TimeoutError, urllib.error.URLError) as error: + # The accepted scan can temporarily block iVentoy's HTTP + # thread. Retry only this read, never an ambiguous refresh + # mutation, malformed JSON, or another transport error. + timed_out = isinstance(error, TimeoutError) or isinstance(error.reason, TimeoutError) + if not retry_timeouts or not timed_out: + raise + self._pause(deadline) + continue + if not validate_status(status, self.contract): return - self.sleep(2) - raise RuntimeError("iVentoy remained busy; published files are retained for a later refresh") + self._pause(deadline) + raise RuntimeError("iVentoy did not confirm an idle, running PXE service before the deadline; published files are retained") def refresh(self, filename): deadline = self.clock() + self.timeout while self.clock() < deadline: - self.wait_idle() - result = self.request("refresh_img_list") + self.wait_idle(deadline) + result = self._query("refresh_img_list", deadline) if result.get("result") == "success": break if result.get("result") != "busy": raise RuntimeError(f"iVentoy rejected refresh: {result.get('result')!r}") - self.sleep(2) + self._pause(deadline) else: raise RuntimeError("iVentoy refresh request remained busy") - self.wait_idle() - tree = self.request("get_img_tree") + self.wait_idle(deadline, retry_timeouts=True) + tree = self._query("get_img_tree", deadline) if not isinstance(tree, list) or not tree_contains(tree, filename): raise RuntimeError("iVentoy did not list the published filename") diff --git a/image/qualification-results/2026-09-26/build.json b/image/qualification-results/2026-09-26/build.json new file mode 100644 index 00000000..6fff3486 --- /dev/null +++ b/image/qualification-results/2026-09-26/build.json @@ -0,0 +1,84 @@ +{ + "artifacts": { + "CybexOS-Live-44-20260926T150946Z-99900b99.iso": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", + "applications.json": "952ccf1a5e7673b01a0f2143668d70303f971065be25bd35d21d946d17689f36", + "build-provenance.json": "016a81a05571e2b095459a0d05c51666acb7611c31cbee4658ad5b8a65dfa581", + "builder-packages.txt": "c47a03ea52d880785d72c15396b6f66edcacced34b27fc5005e23a377bb7018d", + "cybexos-desktop-0.0.0~dev-1.20260926150946.ga991a97d0999.fc44.x86_64.rpm": "e42fa8fdab34eab40d32e93fea9b8c9399abdff1c5be40d8f60207984c41ed8b", + "mdview-0.0.1-1.fc44.x86_64.rpm": "2100ed7607ffb6b49fdaca46fd14d09da6f716bcdac8fa8596e5f051f199d83d", + "package-manifest.json": "6a99aaffc29a432d301f507a1439cfffdd6baa6d212472426d1413e71df4d8dc", + "packages.txt": "ac068e4d42f5502c65268c5f10ce76953c6502e4f25ad869bb678b5f2aa481d9", + "voxtype-0.7.5-1.x86_64.rpm": "50dd10fe8dcdd0f0d7a2796ca8d628e21810c81e2ae1c365075643e552e79c61" + }, + "build_id": "20260926T150946Z-99900b99", + "compression": "xz", + "download_cache_hits": 51, + "phases": [ + { + "phase": "Validate public update channel", + "seconds": 0.01, + "started_utc": "2026-09-26T15:09:46.164249+00:00", + "status": "complete" + }, + { + "phase": "Verify cached Fedora builder", + "seconds": 0.34, + "started_utc": "2026-09-26T15:09:46.178644+00:00", + "status": "complete" + }, + { + "phase": "Record source and prepare isolated VM", + "seconds": 0.98, + "started_utc": "2026-09-26T15:09:46.514244+00:00", + "status": "complete" + }, + { + "phase": "Wait for builder and restore verified downloads", + "seconds": 12.92, + "started_utc": "2026-09-26T15:09:47.491321+00:00", + "status": "complete" + }, + { + "phase": "Build applications, desktop RPM and compressed image", + "seconds": 1168.17, + "started_utc": "2026-09-26T15:10:00.409922+00:00", + "status": "complete" + }, + { + "phase": "Save verified download cache", + "seconds": 3.3, + "started_utc": "2026-09-26T15:29:28.582309+00:00", + "status": "complete" + }, + { + "phase": "Verify and atomically deliver completed artifacts", + "seconds": 55.94, + "started_utc": "2026-09-26T15:29:31.887078+00:00", + "status": "complete" + } + ], + "resources": { + "cpus": 8, + "free_gib": { + "/data/cybexos-candidate-20260926-installer-qualification/cache": 1624.6 + }, + "memory_mib": 24576, + "seed_tool": "cloud-localds" + }, + "source": { + "changes": [], + "configuration_sha256": "e0a03813306db3a615ac54a2b331c0d023fedee0d5b8d240c902509ff89808f6", + "revision": "a991a97d099936298edfa7ad4c1747a3d0261f74", + "source_archive_sha256": "a0f0462f828f637c68c5dcb8233a78e5f32541223aef1b76f60cdc0d040bb1cc" + }, + "source_dirty": false, + "source_epoch": 1790435325, + "source_revision": "a991a97d099936298edfa7ad4c1747a3d0261f74", + "status": "complete", + "tools": { + "python": "3.13.5 (main, Aug 10 2026, 12:06:59) [GCC 14.2.0]", + "qemu-img": "qemu-img version 10.0.13 (Debian 1:10.0.13+ds-0+deb13u1)", + "qemu-system-x86_64": "QEMU emulator version 10.0.13 (Debian 1:10.0.13+ds-0+deb13u1)" + }, + "utc": "2026-09-26T15:09:46.150792+00:00" +} diff --git a/image/qualification-results/2026-09-26/encrypted-nl.json b/image/qualification-results/2026-09-26/encrypted-nl.json new file mode 100644 index 00000000..7b87c0fc --- /dev/null +++ b/image/qualification-results/2026-09-26/encrypted-nl.json @@ -0,0 +1,38 @@ +{ + "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", + "candidate_rpm_sha256": null, + "checks": [ + "live-boot-and-offline-applications", + "graphical-installer", + "encrypted-installation", + "unused-disk-unchanged", + "installed-boot-without-iso", + "encrypted-btrfs", + "autologin", + "desktop-parity", + "selected-locale-timezone-keyboard", + "live-cleanup", + "selinux-enforcing", + "sudo-requires-password", + "initial-encrypted-keyring-unlocked-without-prompt", + "synthetic-keyring-secret-survives-cold-reboot", + "logout-requires-authentication", + "logout-password-recovery-and-keyring", + "compositor-crash-requires-authentication", + "compositor-crash-password-recovery-and-keyring", + "manager-restart-requires-authentication", + "manager-restart-password-recovery-and-keyring", + "missing-cached-password-preserves-locked-encrypted-keyring", + "fallback-password-login-unlocks-existing-keyring", + "first-autologin-launch-failure-consumes-boot-attempt", + "failed-first-autologin-manager-restart-requires-authentication", + "failed-first-autologin-password-login-recovers-desktop" + ], + "firmware": "uefi", + "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", + "network": "outbound-blocked", + "scenario": "encrypted-nl", + "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", + "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", + "status": "passed" +} diff --git a/image/qualification-results/2026-09-26/encrypted-us.json b/image/qualification-results/2026-09-26/encrypted-us.json new file mode 100644 index 00000000..80213bbc --- /dev/null +++ b/image/qualification-results/2026-09-26/encrypted-us.json @@ -0,0 +1,38 @@ +{ + "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", + "candidate_rpm_sha256": null, + "checks": [ + "live-boot-and-offline-applications", + "graphical-installer", + "encrypted-installation", + "unused-disk-unchanged", + "installed-boot-without-iso", + "encrypted-btrfs", + "autologin", + "desktop-parity", + "selected-locale-timezone-keyboard", + "live-cleanup", + "selinux-enforcing", + "sudo-requires-password", + "initial-encrypted-keyring-unlocked-without-prompt", + "synthetic-keyring-secret-survives-cold-reboot", + "logout-requires-authentication", + "logout-password-recovery-and-keyring", + "compositor-crash-requires-authentication", + "compositor-crash-password-recovery-and-keyring", + "manager-restart-requires-authentication", + "manager-restart-password-recovery-and-keyring", + "missing-cached-password-preserves-locked-encrypted-keyring", + "fallback-password-login-unlocks-existing-keyring", + "first-autologin-launch-failure-consumes-boot-attempt", + "failed-first-autologin-manager-restart-requires-authentication", + "failed-first-autologin-password-login-recovers-desktop" + ], + "firmware": "uefi", + "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", + "network": "outbound-blocked", + "scenario": "encrypted-us", + "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", + "source_revision": "a991a97d099936298edfa7ad4c1747a3d0261f74", + "status": "passed" +} diff --git a/image/qualification-results/2026-09-26/plain-nl.json b/image/qualification-results/2026-09-26/plain-nl.json new file mode 100644 index 00000000..726f13a8 --- /dev/null +++ b/image/qualification-results/2026-09-26/plain-nl.json @@ -0,0 +1,25 @@ +{ + "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", + "candidate_rpm_sha256": null, + "checks": [ + "live-boot-and-offline-applications", + "graphical-installer", + "plain-installation", + "unused-disk-unchanged", + "installed-boot-without-iso", + "plain-btrfs", + "password-login", + "desktop-parity", + "selected-locale-timezone-keyboard", + "live-cleanup", + "selinux-enforcing", + "sudo-requires-password" + ], + "firmware": "uefi", + "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", + "network": "outbound-blocked", + "scenario": "plain-nl", + "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", + "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", + "status": "passed" +} diff --git a/image/qualification-results/2026-09-26/plain-us.json b/image/qualification-results/2026-09-26/plain-us.json new file mode 100644 index 00000000..3405facd --- /dev/null +++ b/image/qualification-results/2026-09-26/plain-us.json @@ -0,0 +1,25 @@ +{ + "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", + "candidate_rpm_sha256": null, + "checks": [ + "live-boot-and-offline-applications", + "graphical-installer", + "plain-installation", + "unused-disk-unchanged", + "installed-boot-without-iso", + "plain-btrfs", + "password-login", + "desktop-parity", + "selected-locale-timezone-keyboard", + "live-cleanup", + "selinux-enforcing", + "sudo-requires-password" + ], + "firmware": "uefi", + "iso_sha256": "7b192a15375fd5f6132ce82626dbbce9171d6f297d4bbc4defee32340303d635", + "network": "outbound-blocked", + "scenario": "plain-us", + "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", + "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", + "status": "passed" +} diff --git a/image/qualification-results/2026-09-26/upgrade-recovery.json b/image/qualification-results/2026-09-26/upgrade-recovery.json new file mode 100644 index 00000000..6735627e --- /dev/null +++ b/image/qualification-results/2026-09-26/upgrade-recovery.json @@ -0,0 +1,28 @@ +{ + "bootstrap": "recognized disk-unlock prompt; bounded graphical SSH setup", + "candidate_rpm_sha256": "e42fa8fdab34eab40d32e93fea9b8c9399abdff1c5be40d8f60207984c41ed8b", + "checks": [ + "legacy-installer-only-session-desktop-bootstrap", + "live-boot-and-offline-applications", + "graphical-installer", + "encrypted-installation", + "unused-disk-unchanged", + "installed-boot-without-iso", + "encrypted-btrfs", + "autologin", + "desktop-parity", + "selected-locale-timezone-keyboard", + "live-cleanup", + "selinux-enforcing", + "installed-rpm-upgrade", + "recovery-boot-restore" + ], + "firmware": "uefi", + "iso_sha256": "9b6d480e558590dfc257b6b661f221e3c479c9703ebcefa5f5dad517018c235a", + "legacy_session_bootstrap": "activated full desktop target from baseline installer-only session", + "network": "outbound-blocked", + "scenario": "encrypted-us", + "scope": "QEMU fixture; does not qualify physical hardware or Secure Boot", + "source_revision": "bd049540852c2c6442e8d7ed694e2c27de4f3550", + "status": "passed" +} diff --git a/image/qualification.py b/image/qualification.py index b495e36c..eeaff41f 100644 --- a/image/qualification.py +++ b/image/qualification.py @@ -1,18 +1,30 @@ -"""Opt-in full installation qualification on one newly created QEMU disk.""" +"""Opt-in graphical installation and lifecycle qualification on disposable QEMU disks.""" import argparse import json from pathlib import Path import secrets import signal import subprocess +import sys import time from build_support import atomic_json, digest +from browser_qualification import browser_dependencies, qualify_browser from login_qualification import qualify_login -from vm_testing import QUALIFICATION_DISK_SERIAL, TestVM, require_test_iso, run +from upgrade_qualification import (create_recovery_point, prepare_user_choices, select_recovery_boot, + upgrade, verify_recovery_boot, verify_restored, verify_user_choices) +from vm_testing import QUALIFICATION_DISK_SERIAL, QUALIFICATION_UNUSED_SERIAL, TestVM, poweroff_guest, require_test_iso, run BACKEND = '/usr/libexec/cybexos-installer-backend' +SCENARIOS = { + 'encrypted-us': (True, 'us', 'en_US.UTF-8', 'UTC'), + 'plain-us': (False, 'us', 'en_US.UTF-8', 'UTC'), + 'encrypted-nl': (True, 'nl', 'nl_NL.UTF-8', 'Europe/Amsterdam'), + 'plain-nl': (False, 'nl', 'nl_NL.UTF-8', 'Europe/Amsterdam'), + 'encrypted-de': (True, 'de', 'de_DE.UTF-8', 'Europe/Berlin'), + 'plain-de': (False, 'de', 'de_DE.UTF-8', 'Europe/Berlin'), +} def backend(vm, action, payload=None): @@ -24,69 +36,197 @@ def backend(vm, action, payload=None): return results[-1]['data'] -def root_script(vm, script, password): +def root_script(vm, script, password, timeout=120): # Password stays in memory/stdin and is never part of a command line or log. if subprocess.run([*vm.ssh, "sudo -k -n true"], capture_output=True, timeout=15).returncode == 0: - return run([*vm.ssh, "sudo -n bash -e -s"], input=script, text=True, capture_output=True, timeout=120) - return run([*vm.ssh, "sudo -k -S -p '' bash -e -s"], input=password + '\n' + script, text=True, capture_output=True, timeout=120) - - -def wait_install(vm, timeout): - deadline = time.monotonic() + timeout - last = None - while time.monotonic() < deadline: - state = backend(vm, 'status') - phase = state.get('phase') - if phase == 'complete': - return state - if phase in ('failed', 'failed-install'): - raise RuntimeError('Installer reported failure; inspect its secret-free state and journal') - status = (phase, state.get('step'), state.get('message')) - if status != last: - print(f'Installer: {status}', flush=True) - last = status - vm.alive() - time.sleep(2) - raise RuntimeError('Installer completion deadline exceeded') + return run([*vm.ssh, "sudo -n bash -e -s"], input=script, text=True, capture_output=True, timeout=timeout) + return run([*vm.ssh, "sudo -k -S -p '' bash -e -s"], input=password + '\n' + script, text=True, capture_output=True, timeout=timeout) INSTALLED_AUDIT = r''' -! getent passwd liveuser +trap 'printf "Installed audit shell check failed at line %s\n" "$LINENO" >&2' ERR +if getent passwd liveuser; then + echo 'Installed audit failed: liveuser still exists' >&2 + exit 1 +else + test "$?" -eq 2 +fi for path in /etc/sudoers.d/cybexos-live /etc/polkit-1/rules.d/49-cybexos-live.rules /usr/lib/systemd/system/cybexos-live.service; do test ! -e "$path" done test "$(getenforce)" = Enforcing test "$(findmnt -n -o FSTYPE /)" = btrfs -lsblk -s -n -o TYPE "$(findmnt -n -o SOURCE / | cut -d '[' -f 1)" | grep -qx crypt +root_source=$(findmnt -n -o SOURCE /) +root_types=$(lsblk -s -n -o TYPE "${root_source%%\[*}") +if [ "${EXPECTED_ENCRYPTED}" = true ]; then + grep -qx crypt <<< "$root_types" +elif grep -qx crypt <<< "$root_types"; then + echo 'Installed audit failed: plain installation uses encryption' >&2 + exit 1 +fi systemctl is-active --quiet sddm.service test "$(systemctl show sddm.service -p KeyringMode --value)" = inherit -! rpm -q gdm >/dev/null +if rpm -q gdm >/dev/null; then + echo 'Installed audit failed: GDM is installed' >&2 + exit 1 +else + test "$?" -eq 1 +fi +if [ "${REQUIRE_SECURE_SUDO}" = true ]; then + grep -qx 'passwordless_wheel: false' /etc/cybexos/config.yml + test ! -e /etc/sudoers.d/10-wheel-nopasswd +fi python3 - <<'CHECK' import configparser,json +import os from pathlib import Path +import re +import subprocess +encrypted=os.environ['EXPECTED_ENCRYPTED'] == 'true' policy=json.loads(Path('/etc/cybexos/login.json').read_text()) -assert policy == {'version':1, 'user':'qualification', 'autologin':True, 'live':False} +assert policy == {'version':1, 'user':'qualification', 'autologin':encrypted, 'live':False}, 'Installed login policy differs' config=configparser.ConfigParser() config.read('/etc/sddm.conf') -assert config.get('Autologin','User') == 'qualification' -assert config.get('Autologin','Session') == 'hyprland-quickshell.desktop' -assert not config.getboolean('Autologin','Relogin') +assert config.get('Autologin','User',fallback='') == ('qualification' if encrypted else ''), 'SDDM login user differs' +if encrypted: + assert config.get('Autologin','Session') == 'hyprland-quickshell.desktop', 'SDDM session differs' + assert not config.getboolean('Autologin','Relogin'), 'SDDM permits repeated autologin' marker=Path('/run/cybexos-login/autologin-used') -assert marker.exists() and marker.stat().st_uid == 0 -assert not marker.stat().st_mode & 0o077 +if encrypted: + assert marker.exists() and marker.stat().st_uid == 0, 'Autologin marker missing or not root-owned' + assert not marker.stat().st_mode & 0o077, 'Autologin marker permissions are too broad' plymouth=configparser.ConfigParser() plymouth.read('/etc/plymouth/plymouthd.conf') -assert plymouth.get('Daemon','Theme') == 'cybex' +assert plymouth.get('Daemon','Theme') == 'cybex', 'Plymouth theme differs' contract=json.loads(Path('/usr/share/cybexos/desktop-contract.json').read_text()) settings=json.loads(Path('/home/qualification/.config/cybexos/shell.json').read_text()) for key,value in contract['shell'].items(): assert settings.get(key) == value, f'Desktop default mismatch: {key}' -assert not Path('/home/qualification/.config/cybexos/hypr/user.lua').exists() -assert Path('/home/qualification/.local/state/cybexos/offline-apps-seeded').exists() +assert not Path('/home/qualification/.config/cybexos/hypr/user.lua').exists(), 'Unexpected personal Hyprland override' +assert Path('/home/qualification/.local/state/cybexos/offline-apps-seeded').exists(), 'Offline application seed marker missing' +expected_locale=os.environ['EXPECTED_LOCALE'] +expected_timezone=os.environ['EXPECTED_TIMEZONE'] +expected_keyboard=os.environ['EXPECTED_KEYBOARD'] +expected_boot_keymap=os.environ['EXPECTED_BOOT_KEYMAP'] +locale=dict(line.split('=', 1) for line in Path('/etc/locale.conf').read_text().splitlines() + if line.startswith('LANG=')) +assert locale['LANG'].strip('"') == expected_locale, 'Installed locale differs' +# Fedora's UTC aliases can be hardlinks, whose resolved path names differ. +assert Path('/etc/localtime').samefile(Path('/usr/share/zoneinfo') / expected_timezone), 'Installed timezone differs' +console=dict(line.split('=', 1) for line in Path('/etc/vconsole.conf').read_text().splitlines() + if line.startswith('KEYMAP=')) +assert console['KEYMAP'].strip('"') == expected_boot_keymap, 'Installed console keymap differs' +localectl=subprocess.check_output(['localectl', 'status'], text=True, + env={**os.environ, 'LC_ALL': 'C'}) +assert re.search(r'^\s*X11 Layout:\s*' + re.escape(expected_keyboard) + r'\s*$', localectl, re.M), 'Installed X11 keyboard differs' CHECK ''' +DESKTOP_KEYBOARD_AUDIT = r''' +import json +import os +import subprocess +import sys + +expected = sys.argv[1] +manager = subprocess.check_output(['systemctl', '--user', 'show-environment'], text=True) +for line in manager.splitlines(): + if line.startswith(('HYPRLAND_INSTANCE_SIGNATURE=', 'WAYLAND_DISPLAY=')): + key, value = line.split('=', 1) + os.environ[key] = value +assert os.environ.get('HYPRLAND_INSTANCE_SIGNATURE') +result = subprocess.check_output(['hyprctl', '-j', 'getoption', 'input:kb_layout'], text=True) +assert json.loads(result)['str'] == expected, 'Desktop keyboard differs from installed choice' +''' + + +def installed_audit(encrypted, require_secure_sudo, keyboard, boot_keymap, locale, timezone): + return (f'export EXPECTED_ENCRYPTED={str(encrypted).lower()}\n' + f'export REQUIRE_SECURE_SUDO={str(require_secure_sudo).lower()}\n' + f'export EXPECTED_KEYBOARD={keyboard}\n' + f'export EXPECTED_BOOT_KEYMAP={boot_keymap}\n' + f'export EXPECTED_LOCALE={locale}\n' + f'export EXPECTED_TIMEZONE={timezone}\n' + INSTALLED_AUDIT) + + +def verify_installed_audit(vm, encrypted, require_secure_sudo, keyboard, boot_keymap, + locale, timezone, password): + script = installed_audit(encrypted, require_secure_sudo, keyboard, boot_keymap, locale, timezone) + try: + return root_script(vm, script, password) + except subprocess.CalledProcessError as error: + details = '\n'.join(str(part or '') for part in (error.stdout, error.stderr)) + details = details.replace(password, '[redacted]')[-3000:].strip() + raise RuntimeError(f'Installed audit failed (exit {error.returncode}): {details}') from error + + +def qualification_disks(vm): + output = run([*vm.ssh, 'lsblk -dn -o NAME,SERIAL'], text=True, capture_output=True, timeout=15).stdout + devices = {} + for line in output.splitlines(): + parts = line.split() + if len(parts) == 2 and parts[1] in (QUALIFICATION_DISK_SERIAL, QUALIFICATION_UNUSED_SERIAL): + if parts[1] in devices: + raise RuntimeError('Duplicate qualification disk serial in guest') + devices[parts[1]] = parts[0] + if set(devices) != {QUALIFICATION_DISK_SERIAL, QUALIFICATION_UNUSED_SERIAL}: + raise RuntimeError('Both serial-identified disposable disks must be visible in the guest') + return devices[QUALIFICATION_DISK_SERIAL], devices[QUALIFICATION_UNUSED_SERIAL] + + +def focus_installed_desktop(vm, password, autologin): + """Return from the verified text console to the graphical session.""" + from login_qualification import wait_login_state + # On subsequent boots SSH can become ready before SDDM creates its VT. + wait_login_state(vm, desktop=autologin) + script = '''python3 - <<'PY' +import subprocess +for line in subprocess.check_output(['loginctl', 'list-sessions', '--no-legend', '--no-pager'], text=True).splitlines(): + identifier = line.split()[0] + values = dict(item.split('=', 1) for item in subprocess.check_output( + ['loginctl', 'show-session', identifier, '-p', 'Class', '-p', 'Name', '-p', 'Type', '-p', 'VTNr'], text=True).splitlines()) + if ((values.get('Class') == 'greeter' and not AUTOLOGIN) or + (values.get('Class') == 'user' and values.get('Name') == 'qualification' and + values.get('Type') == 'wayland' and AUTOLOGIN)) and values.get('VTNr', '').isdigit(): + print(values['VTNr']) + raise SystemExit(0) +raise SystemExit(1) +PY +''' + script = script.replace('import subprocess\n', f'import subprocess\nAUTOLOGIN = {autologin!r}\n', 1) + vt = int(run([*vm.ssh, 'bash -s'], input=script, text=True, capture_output=True, + timeout=20).stdout.strip()) + if not 1 <= vt <= 6: + raise RuntimeError('Installed greeter was not on an expected virtual terminal') + vm.keypress(f'ctrl+alt+f{vt}') + if autologin: + vm.wait_desktop() + wait_login_state(vm, desktop=True) + return + time.sleep(2) + vm.type(password + '\n') + vm.wait_desktop() + wait_login_state(vm, desktop=True) + + +def boot_installed(vm, password, encrypted, *, autologin=None): + if autologin is None: + autologin = encrypted + vm.start(user='qualification') + if encrypted: + vm.unlock_disk(password) + try: + vm.wait_ssh(timeout=12, setup=False, redactions=(password,)) + except RuntimeError: + vm.bootstrap_installed_ssh(password) + focus_installed_desktop(vm, password, autologin) + + +def poweroff_installed(vm, password): + poweroff_guest(vm, password, root_script) + + def main(): # Register here as well as direct module execution: image/qualify imports # this function, so module __main__ hooks alone do not protect cleanup. @@ -96,6 +236,10 @@ def main(): parser.add_argument('iso', type=Path) parser.add_argument('--output', type=Path, required=True) parser.add_argument('--firmware', choices=('uefi', 'bios'), default='uefi') + parser.add_argument('--scenario', choices=tuple(SCENARIOS), default='encrypted-us') + parser.add_argument('--candidate-rpm', type=Path, help='Newer desktop RPM for an offline N to N+1 upgrade') + parser.add_argument('--recovery-check', action='store_true', help='Boot and restore the pre-upgrade recovery point') + parser.add_argument('--legacy-installer', action='store_true', help='Permit baseline installer without new policy/rescan controls') parser.add_argument('--execute-vm', action='store_true') parser.add_argument('--erase-disposable-disk', action='store_true', help='Explicitly allow installation onto the new task-owned virtual disk') parser.add_argument('--keep-artifacts', action='store_true', help='Retain task-owned disk/logs for unresolved diagnostics') @@ -103,66 +247,121 @@ def main(): args = parser.parse_args() if not args.execute_vm or not args.erase_disposable_disk: parser.error('qualification requires --execute-vm --erase-disposable-disk; no VM or installer starts without both') + if args.recovery_check and not args.candidate_rpm: + parser.error('--recovery-check requires --candidate-rpm') iso = require_test_iso(args.iso) - vm = TestVM(args.output, args.firmware) - password = secrets.token_urlsafe(24) - report = {'iso_sha256': digest(iso), 'firmware': args.firmware, 'status': 'failed', 'checks': [], + if args.candidate_rpm and (args.candidate_rpm.is_symlink() or not args.candidate_rpm.is_file() + or args.candidate_rpm.suffix != '.rpm'): + parser.error('--candidate-rpm must be a regular .rpm file') + # Direct invocations need the same early host check as release-gate. + # Missing browser/Node dependencies must not consume a live boot or disk. + browser_dependencies() + encrypted, keyboard, locale, timezone = SCENARIOS[args.scenario] + vm = TestVM(args.output, args.firmware, guard_disk=True) + # Hex uses the same physical keys under US, NL and DE layouts, including + # the early boot unlock and text-console bootstrap paths. + password = secrets.token_hex(24) + report = {'iso_sha256': digest(iso), + 'candidate_rpm_sha256': digest(args.candidate_rpm) if args.candidate_rpm else None, + 'scenario': args.scenario, 'firmware': args.firmware, 'status': 'failed', 'checks': [], 'network': 'outbound-blocked', 'bootstrap': 'recognized disk-unlock prompt; bounded graphical SSH setup', 'scope': 'QEMU fixture; does not qualify physical hardware or Secure Boot'} try: vm.prepare() + untouched_sha = digest(vm.unused_disk) vm.start(iso) vm.wait_ssh() + if args.legacy_installer: + # The retained N image deliberately starts an installer-only + # session target in live mode. Activate its full desktop target + # only in this disposable older guest for the shell/application + # audit. New images must pass normal startup without this branch. + live_shell = subprocess.run( + [*vm.ssh, 'systemctl --user is-active --quiet quickshell.service'], + capture_output=True, timeout=15) + if live_shell.returncode: + installer_only = subprocess.run( + [*vm.ssh, 'test -e /run/cybexos-install-mode && ' + 'systemctl --user is-active --quiet cybexos-install-session.target'], + capture_output=True, timeout=15) + if installer_only.returncode: + raise RuntimeError('Legacy guest shell is inactive outside the expected installer-only session') + run([*vm.ssh, 'systemctl --user start hyprland-session.target'], timeout=30) + report['legacy_session_bootstrap'] = 'activated full desktop target from baseline installer-only session' + report['checks'].append('legacy-installer-only-session-desktop-bootstrap') vm.audit() report['checks'].append('live-boot-and-offline-applications') - serial = run([*vm.ssh, 'lsblk -dn -o SERIAL /dev/vda'], text=True, capture_output=True).stdout.strip() - if serial != QUALIFICATION_DISK_SERIAL: - raise RuntimeError('Refusing installation: guest target is not the newly created qualification disk') + target_disk, unused_disk = qualification_disks(vm) run([*vm.ssh, 'XDG_RUNTIME_DIR=/run/user/1000 systemd-run --user --collect --unit=cybexos-qualification-anaconda /usr/bin/liveinst --nosave=all_ks']) - deadline = time.monotonic() + 120 - while True: - try: - backend(vm, 'inventory') - break - except (RuntimeError, subprocess.CalledProcessError): - if time.monotonic() >= deadline: - raise RuntimeError('Anaconda backend readiness timed out') from None - time.sleep(2) - plan = backend(vm, 'plan', {'username': 'qualification', 'password': password, 'confirm': password, - 'keyboard': 'us', 'locale': 'en_US.UTF-8', 'timezone': 'UTC', 'hostname': 'cybexos-test', - 'disk': 'vda', 'encrypted': True}) - if plan.get('disk', {}).get('name') != 'vda' or not plan.get('token'): - raise RuntimeError('Installer plan did not confirm the disposable target') - backend(vm, 'install', {'token': plan['token'], 'confirmed_disk': 'vda', 'erase_confirmed': True}) - wait_install(vm, args.install_timeout) - report['checks'].append('encrypted-installation') + browser_result = qualify_browser(vm, password=password, target_disk=target_disk, unused_disk=unused_disk, + encrypted=encrypted, keyboard=keyboard, locale=locale, timezone=timezone, + install_timeout=args.install_timeout, require_policy_controls=not args.legacy_installer) + report['checks'].append('graphical-installer') + report['checks'].append('encrypted-installation' if encrypted else 'plain-installation') # Audit live state before a graceful shutdown; target mounts are left # to Anaconda/systemd. Do not assume /mnt/sysroot survives completion. vm.audit(applications=False) vm.stop(graceful=True) + if digest(vm.unused_disk) != untouched_sha: + raise RuntimeError('The unused disposable guard disk changed during installation') + report['checks'].append('unused-disk-unchanged') (vm.work / 'known_hosts').unlink(missing_ok=True) - vm.start(user='qualification') # Intentionally no ISO/CD-ROM attached. - vm.unlock_disk(password) - vm.wait_ssh(setup_password=password) + boot_installed(vm, password, encrypted) # No ISO/CD-ROM attached. vm.audit() - root_script(vm, INSTALLED_AUDIT, password) - report['checks'] += ['installed-boot-without-iso', 'encrypted-btrfs', 'autologin', 'desktop-parity', 'live-cleanup', 'selinux-enforcing'] - qualify_login(vm, password, root_script, report, secrets.token_urlsafe(32)) + verify_installed_audit(vm, encrypted, not args.legacy_installer, + keyboard, browser_result['boot_keyboard'], locale, timezone, password) + run([*vm.ssh, f'python3 - {keyboard}'], input=DESKTOP_KEYBOARD_AUDIT, + text=True, capture_output=True, timeout=20) + report['checks'] += ['installed-boot-without-iso', 'encrypted-btrfs' if encrypted else 'plain-btrfs', + 'autologin' if encrypted else 'password-login', 'desktop-parity', + 'selected-locale-timezone-keyboard', 'live-cleanup', 'selinux-enforcing'] + if not args.legacy_installer: + sudo = subprocess.run([*vm.ssh, 'sudo -k -n true'], capture_output=True, timeout=15) + if sudo.returncode == 0: + raise RuntimeError('Fresh installation unexpectedly allows passwordless sudo') + report['checks'].append('sudo-requires-password') + if encrypted and not args.candidate_rpm: + qualify_login(vm, password, root_script, report, secrets.token_urlsafe(32)) + if args.candidate_rpm: + preference = prepare_user_choices(vm, password, root_script) + point = create_recovery_point(vm, password, root_script) if args.recovery_check else None + sha, versions = upgrade(vm, args.candidate_rpm, password, root_script) + if sha != report['candidate_rpm_sha256']: + raise RuntimeError('Candidate RPM checksum changed during qualification') + report['checks'].append('installed-rpm-upgrade') + verify_user_choices(vm, password, root_script, preference) + if point: + select_recovery_boot(vm, point, password, root_script) + poweroff_installed(vm, password) + # The recovery overlay cannot prove encrypted root ancestry, + # so login policy requires a password even after LUKS unlock. + boot_installed(vm, password, encrypted, autologin=False) + verify_recovery_boot(vm, point, password, root_script) + poweroff_installed(vm, password) + boot_installed(vm, password, encrypted) + verify_restored(vm, versions['installed'], password, root_script) + verify_user_choices(vm, password, root_script, preference) + report['checks'].append('recovery-boot-restore') # Clear the temporary test access before stopping the disposable disk. vm.audit(applications=False) - root_script(vm, 'rm -f /home/qualification/.ssh/authorized_keys /home/qualification/.bash_history\nsystemctl disable sshd.service\nsync\nsystemctl poweroff --no-block\n', password) - vm.ssh_ready = False - vm.process.wait(timeout=60) + poweroff_guest(vm, password, root_script, timeout=60, cleanup_script=( + 'rm -f /home/qualification/.ssh/authorized_keys /home/qualification/.bash_history\n' + 'systemctl disable sshd.service\n')) + if digest(vm.unused_disk) != untouched_sha: + raise RuntimeError('The unused disposable guard disk changed after installed boot') report['status'] = 'passed' except BaseException as error: report['error'] = str(error) or type(error).__name__ raise finally: if vm.owned: + failed_before_cleanup = sys.exc_info()[0] is not None try: vm.cleanup(args.keep_artifacts) - except BaseException: + except BaseException as cleanup_error: report['status'] = 'failed' - raise + report['cleanup_error'] = str(cleanup_error) or type(cleanup_error).__name__ + if not failed_before_cleanup: + raise finally: atomic_json(args.output / 'qualification.json', report) print(f"Qualification passed: {args.output / 'qualification.json'}") diff --git a/image/real_browser_qualification.cjs b/image/real_browser_qualification.cjs new file mode 100644 index 00000000..a33df0b6 --- /dev/null +++ b/image/real_browser_qualification.cjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +// Drives the guest's real Cockpit/Anaconda page through an SSH tunnel. +// Fixture credentials arrive on stdin and are never printed or saved. +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const {chromium} = require("playwright-core"); +let secret = ""; + +async function waitForInitialSetup(page, timeoutMs = 120000, + retryDelays = [1000, 2000, 4000, 8000, 16000, 32000, 32000]) { + const deadline = Date.now() + timeoutMs; + let retries = 0; + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("Installer setup did not become ready before its deadline"); + await page.waitForFunction(() => { + const setup = document.querySelector("#setup"); + if (setup && !setup.hidden) return true; + const retry = document.querySelector("#startup-retry"); + const error = document.querySelector("#error"); + return setup?.hidden && retry && !retry.hidden && error && !error.hidden && + error.textContent.trim() === "Another installer operation is still running."; + }, null, {timeout: remaining}); + if (await page.locator("#setup").isVisible()) return; + if (retries >= retryDelays.length) throw new Error("Installer setup remained busy after bounded initialization retries"); + await page.waitForTimeout(Math.min(retryDelays[retries], Math.max(0, deadline - Date.now()))); + if (await page.locator("#setup").isVisible()) return; + const error = await page.locator("#error").textContent(); + if (error?.trim() !== "Another installer operation is still running." || + !await page.locator("#startup-retry").isVisible()) continue; + const clickTimeout = deadline - Date.now(); + if (clickTimeout <= 0) throw new Error("Installer setup did not become ready before its deadline"); + await page.locator("#retry").click({timeout: clickTimeout}); + retries++; + } +} + +async function main() { + const input = JSON.parse(fs.readFileSync(0, "utf8")); + secret = input.password; + const url = new URL(input.url); + assert.equal(url.hostname, "127.0.0.1"); + assert.equal(url.pathname, "/cockpit/@localhost/cybexos-installer/index.html"); + assert.match(input.target_disk, /^[A-Za-z0-9_-]+$/); + assert.match(input.target_serial, /^CYBEXOS-QUALIFY$/); + assert.match(input.unused_disk, /^[A-Za-z0-9_-]+$/); + assert.notEqual(input.target_disk, input.unused_disk); + const browser = await chromium.launch({executablePath: input.browser, headless: true, + args: ["--disable-dev-shm-usage"]}); + let page; + const pageErrors = []; + try { + page = await browser.newPage({viewport: {width: 1280, height: 900}}); + page.on("pageerror", error => { + if (pageErrors.length < 4) pageErrors.push(String(error.stack || error).slice(0, 500)); + }); + // The guest remains offline; the browser may only talk to its tunnel. + await page.route("**/*", route => { + const request = new URL(route.request().url()); + return request.hostname === "127.0.0.1" && request.port === url.port + ? route.continue() : route.abort(); + }); + await page.goto(input.url, {waitUntil: "domcontentloaded", timeout: 30000}); + await waitForInitialSetup(page); + await page.waitForFunction(() => !document.querySelector("#password").disabled); + await page.selectOption("#keyboard", input.keyboard); + await page.waitForFunction(expected => document.querySelector("#keyboard").value === expected && + !document.querySelector("#password").disabled, input.keyboard); + await page.fill("#username", "qualification"); + await page.fill("#password", input.password); + await page.fill("#confirm", input.password); + await page.locator("#account-form details > summary").click(); + await page.selectOption("#locale", input.locale); + await page.selectOption("#timezone", input.timezone); + const sudo = page.locator("#passwordless-wheel"); + if (input.require_policy_controls) { + assert.equal(await sudo.count(), 1, "Sudo policy choice is missing"); + assert.equal(await sudo.isChecked(), false, "Sudo must require a password by default"); + } + if (await sudo.count()) assert.equal(await sudo.isChecked(), false); + await page.locator("#account-form button[type=submit]").click(); + await page.locator("#location").waitFor({state: "visible"}); + const rescan = page.locator("#rescan-disks"); + if (input.require_policy_controls) assert.equal(await rescan.count(), 1, "Disk rescan is missing"); + if (await rescan.count()) { + await rescan.click(); + await page.waitForFunction(() => !document.querySelector("#rescan-disks").disabled); + assert.equal(await page.inputValue("#disk"), ""); + } + const options = await page.locator("#disk option").evaluateAll(items => items.map(item => item.value)); + assert(options.includes(input.target_disk), "Expected target disk is absent from selector"); + assert(options.includes(input.unused_disk), "Unused guard disk is absent from selector"); + await page.selectOption("#disk", input.target_disk); + const details = page.locator("#disk-details"); + if (input.require_policy_controls) { + assert.match(await details.textContent(), /CYBEXOS-QUALIFY/); + assert.match(await details.textContent(), /Existing partitions:/); + } + await page.locator("#disk-form details > summary").click(); + const encrypted = page.locator("#encrypted"); + assert.equal(await encrypted.isChecked(), true); + if (!input.encrypted) await encrypted.uncheck(); + await page.locator("#disk-form button[type=submit]").click(); + await page.locator("#review").waitFor({state: "visible", timeout: 180000}); + const summary = await page.locator("#summary").textContent(); + const bootKeyboard = await page.locator("#summary dt").evaluateAll(items => + items.find(item => item.textContent.trim() === "Boot keyboard")?.nextElementSibling?.textContent.trim() || ""); + assert.match(summary, /qualification/); + if (input.require_policy_controls) { + assert.match(bootKeyboard, /^[a-z0-9_-]+$/, "Planned boot keyboard is missing"); + assert.match(summary, /CYBEXOS-QUALIFY/); + assert.match(summary, /ask for your account password/); + } + assert.match(summary, input.encrypted ? /Encrypted Btrfs/ : /unencrypted/); + assert.equal(await page.locator("#install").isDisabled(), true); + await page.check("#erase"); + await page.click("#install"); + await page.locator("#progress").waitFor({state: "visible"}); + await page.getByRole("heading", {name: "Your workspace is ready."}).waitFor({timeout: input.install_timeout_ms}); + assert.equal(await page.inputValue("#password"), ""); + assert.equal(await page.inputValue("#confirm"), ""); + process.stdout.write(JSON.stringify({check: "graphical-installer", selected_disk: input.target_disk, + encrypted: input.encrypted, keyboard: input.keyboard, + boot_keyboard: bootKeyboard || input.keyboard, locale: input.locale}) + "\n"); + } catch (error) { + let visibleText = "(unavailable)"; + if (page) { + try { + visibleText = await page.locator("body").innerText({timeout: 3000}); + } catch { /* Keep the original browser failure. */ } + } + const details = [`Visible installer text: ${visibleText.slice(0, 1800)}`]; + if (pageErrors.length) details.push(`Page errors: ${pageErrors.join(" | ")}`); + error.stack = `${String(error.stack || error)}\n${details.join("\n")}`; + throw error; + } finally { + await browser.close(); + } +} + +if (require.main === module) main().catch(error => { + const detail = String(error.stack || error); + const redacted = secret ? detail.replaceAll(secret, "[redacted]") : detail; + process.stderr.write(`Graphical installer qualification failed: ${redacted}\n`); + process.exitCode = 1; +}); + +module.exports = {waitForInitialSetup}; diff --git a/image/reconstruct-iso b/image/reconstruct-iso new file mode 100755 index 00000000..7a285ca0 --- /dev/null +++ b/image/reconstruct-iso @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Verify release parts and atomically reconstruct an ISO without replacing files.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile + + +def digest(value): + return isinstance(value, str) and re.fullmatch(r'[0-9a-f]{64}', value) + + +def reconstruct(manifest, output=None): + manifest = Path(manifest) + if manifest.stat().st_size > 1024 * 1024: + raise ValueError('ISO parts manifest exceeds the size limit') + record = json.loads(manifest.read_text()) + name = record.get('file', '') + if (record.get('format') != 1 or not isinstance(name, str) + or not re.fullmatch(r'[A-Za-z0-9._-]+\.iso', name) + or not digest(record.get('sha256')) + or not isinstance(record.get('bytes'), int) or record['bytes'] <= 0 + or not isinstance(record.get('parts'), list) or not record['parts']): + raise ValueError('Invalid ISO reconstruction manifest') + destination = Path(output) if output is not None else manifest.parent / name + if destination.exists() or destination.is_symlink(): + raise ValueError('ISO output already exists; existing files are preserved') + descriptor, temporary = tempfile.mkstemp(prefix='.cybexos-iso-', dir=destination.parent) + try: + total, complete = 0, hashlib.sha256() + with os.fdopen(descriptor, 'wb') as result: + for index, part in enumerate(record['parts']): + if (part.get('file') != f'{name}.part-{index:03d}' + or not digest(part.get('sha256')) + or not isinstance(part.get('bytes'), int) + or not 0 < part['bytes'] < 2 * 1024 ** 3): + raise ValueError('Invalid or reordered ISO part') + source = manifest.parent / part['file'] + if source.is_symlink() or not source.is_file(): + raise ValueError('ISO parts must be regular files') + size, current = 0, hashlib.sha256() + with source.open('rb') as stream: + while block := stream.read(8 * 1024 * 1024): + size += len(block) + if size > part['bytes']: + raise ValueError('ISO part exceeds its recorded size') + current.update(block) + complete.update(block) + result.write(block) + if size != part['bytes'] or current.hexdigest() != part['sha256']: + raise ValueError('ISO part checksum or size mismatch: ' + source.name) + total += size + if total != record['bytes'] or complete.hexdigest() != record['sha256']: + raise ValueError('Reconstructed ISO checksum or size mismatch') + result.flush() + os.fsync(result.fileno()) + os.fchmod(result.fileno(), 0o644) + # Hard-link publication refuses even a concurrent output creation. + os.link(temporary, destination) + finally: + Path(temporary).unlink(missing_ok=True) + return destination + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('manifest', type=Path) + parser.add_argument('--output', type=Path) + args = parser.parse_args() + try: + print(reconstruct(args.manifest, args.output)) + except (OSError, ValueError, KeyError, TypeError) as error: + parser.exit(1, f'reconstruct-iso: {error}\n') + + +if __name__ == '__main__': + main() diff --git a/image/release-gate b/image/release-gate new file mode 100755 index 00000000..23422571 --- /dev/null +++ b/image/release-gate @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Build, publish to local iVentoy, and qualify an ISO release on a trusted PXE runner.""" +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess + +from build_support import atomic_json, digest +from browser_qualification import browser_dependencies +from vm_testing import require_test_iso + +ROOT = Path(__file__).resolve().parents[1] + + +def interrupted(_signum, _frame): + raise KeyboardInterrupt + + +def run_child(command): + """Let task-owned build/VM helpers clean up on interruption before removal.""" + with subprocess.Popen(command, start_new_session=True) as process: + try: + code = process.wait() + except BaseException: + if process.poll() is None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=30) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + raise + if code: + raise subprocess.CalledProcessError(code, command) + + +def main(): + for signum in (signal.SIGTERM, signal.SIGHUP): + signal.signal(signum, interrupted) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--baseline-iso', type=Path, required=True) + parser.add_argument('--tag', required=True) + parser.add_argument('--memory', type=int, default=24576) + parser.add_argument('--execute', action='store_true') + args = parser.parse_args() + if not args.execute: + parser.error('--execute explicitly authorizes a build and tests on new disposable VM disks') + if not re.fullmatch(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', args.tag): + parser.error('Use a versioned release tag') + if (ROOT / 'VERSION').read_text().strip() != args.tag[1:]: + parser.error('The release tag must match VERSION in the reviewed checkout') + baseline = require_test_iso(args.baseline_iso) + # This runner must be the actual PXE host, not a workstation with a same-named directory. + if not Path('/data/pxe/README.md').is_file(): + parser.error('Run on the documented PXE host with /data/pxe/README.md') + subprocess.run(['systemctl', 'is-active', '--quiet', 'iventoy.service'], check=True) + browser_dependencies() + output = args.output.resolve() + if output.exists() or output.is_relative_to(Path('/data/pxe/iso')): + parser.error('Output must be a new directory outside the served ISO tree') + output.mkdir(parents=True, mode=0o700) + report = {'status': 'running', 'tag': args.tag, 'baseline_iso': str(baseline)} + build = output / 'build' + try: + run_child([str(ROOT / 'image/build'), '--output', str(build), '--memory', str(args.memory), + '--update-channel', str(ROOT / 'image/channels/stable.json')]) + artifacts = build / 'artifacts' + isos = list(artifacts.glob('*.iso')) + rpms = list(artifacts.glob('cybexos-desktop-*.rpm')) + if len(isos) != 1 or len(rpms) != 1: + raise RuntimeError('Build must deliver exactly one ISO and desktop RPM') + if digest(baseline) == digest(isos[0]): + raise RuntimeError('Upgrade baseline must be a different prior ISO, not the candidate image') + run_child([str(ROOT / 'image/publish-pxe'), str(artifacts), '--execute']) + iso = require_test_iso(Path('/data/pxe/iso') / isos[0].name) + report['testing_iso'] = str(iso) + report['testing_iso_bytes'] = iso.stat().st_size + for scenario in ('encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl'): + run_child([str(ROOT / 'image/qualify'), str(iso), '--output', str(output / scenario), + '--execute-vm', '--erase-disposable-disk', '--scenario', scenario]) + run_child([str(ROOT / 'image/qualify'), str(baseline), '--output', str(output / 'upgrade'), + '--execute-vm', '--erase-disposable-disk', '--scenario', 'encrypted-us', + '--candidate-rpm', str(rpms[0]), '--recovery-check', '--legacy-installer']) + report['status'] = 'passed' + report['artifacts'] = str(artifacts) + print(json.dumps(report)) + except BaseException as error: + report.update(status='failed', error=str(error) or type(error).__name__) + # QEMU helpers always stop their guests. Preserve only compact evidence + # and a completed published testing ISO needed to diagnose a failed boot. + if build.exists(): + shutil.rmtree(build) + raise + finally: + atomic_json(output / 'release-gate.json', report) + + +if __name__ == '__main__': + main() diff --git a/image/release-runner b/image/release-runner new file mode 100755 index 00000000..6893c0e2 --- /dev/null +++ b/image/release-runner @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Validate and explicitly start one ephemeral runner for a reviewed release job.""" +from release_runner import main + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/image/release_repository.py b/image/release_repository.py index befa8be4..ad25426e 100644 --- a/image/release_repository.py +++ b/image/release_repository.py @@ -78,12 +78,17 @@ def verify_embedded_channel(path, baseurl, key_id, armor): raise ValueError("RPM update channel does not match this release. Build it with the same --update-channel first.") -def create_repository(packages, output, key_file, key_id, baseurl, gnupghome=None): +def create_repository(packages, output, key_file, key_id, baseurl, gnupghome=None, + packages_baseurl=None): output = Path(output).absolute() if output.exists() or output.is_symlink(): raise ValueError("Repository output must be a new directory; existing releases are preserved") expected = fingerprint(key_id) url = repository_url(baseurl) + if packages_baseurl: + packages_baseurl = repository_url(packages_baseurl) + if '$' in packages_baseurl: + raise ValueError('RPM download URLs must identify an immutable release') armor = public_key(key_file, expected) packages = [Path(path).resolve(strict=True) for path in packages] if not packages or len({path.name for path in packages}) != len(packages): @@ -122,9 +127,18 @@ def create_repository(packages, output, key_file, key_id, baseurl, gnupghome=Non verify_embedded_channel(destination, url, expected, armor) run([*command, str(destination)], env=signing_environment) verify_signed_rpm(destination, key, work) - records.append({**identity, "file": str(destination.relative_to(stage)), - "sha256": sha256(destination), "unsigned_input_sha256": sha256(original)}) - run(["createrepo_c", "--checksum", "sha256", str(stage)]) + record = {**identity, "file": str(destination.relative_to(stage)), + "sha256": sha256(destination), "unsigned_input_sha256": sha256(original)} + if packages_baseurl: + record['url'] = packages_baseurl + '/' + destination.name + records.append(record) + if packages_baseurl: + # GitHub Pages serves only metadata; large RPMs are release assets. + # Scan Packages itself so relative locations are bare asset names. + run(["createrepo_c", "--checksum", "sha256", "--general-compress-type", "gz", "--outputdir", str(stage), + "--baseurl", packages_baseurl + '/', str(target_packages)]) + else: + run(["createrepo_c", "--checksum", "sha256", "--general-compress-type", "gz", str(stage)]) metadata = stage / "repodata/repomd.xml" run([*signing, "--armor", "--detach-sign", "--output", str(metadata) + ".asc", str(metadata)]) manifest = stage / "release.json" @@ -166,10 +180,11 @@ def main(argv=None): parser.add_argument("--key", required=True, help="Complete fingerprint of an existing signing key") parser.add_argument("--baseurl", required=True, help="HTTPS URL where this repository will be hosted") parser.add_argument("--gnupghome", type=Path) + parser.add_argument("--packages-baseurl", help="HTTPS release-asset directory; keep large RPMs off the metadata host") args = parser.parse_args(argv) try: destination = create_repository(args.packages, args.output, args.public_key, args.key, - args.baseurl, args.gnupghome) + args.baseurl, args.gnupghome, args.packages_baseurl) except (ValueError, OSError, subprocess.CalledProcessError) as error: parser.exit(1, f"release-repository: {error}\n") print(f"Signed repository ready for explicit hosting: {destination}") diff --git a/image/release_runner.py b/image/release_runner.py new file mode 100644 index 00000000..dbfe154d --- /dev/null +++ b/image/release_runner.py @@ -0,0 +1,354 @@ +"""Start one verified, ephemeral PXE runner for one already queued release job. + +The operator's gh credentials remain outside the runner environment. This is +not a sandbox: only reviewed workflow code may execute as the PXE operator. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import signal +import shutil +import subprocess +import tarfile +import tempfile +import sys +from urllib.parse import urlsplit +from urllib.request import urlopen +import uuid + +REPOSITORY = 'DigitalPals/CybexOS' +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = {'.github/workflows/release.yml', '.github/workflows/desktop-release.yml'} +ARCHIVE_LIMIT = 1024 ** 3 + + +def api(endpoint, *, method='GET', body=None, paginate=False): + command = ['gh', 'api', '--hostname', 'github.com', '--method', method, '-H', 'Accept: application/vnd.github+json', + '-H', 'X-GitHub-Api-Version: 2022-11-28', endpoint] + if paginate: + command += ['--paginate', '--slurp'] + if body is not None: + command += ['--input', '-'] + result = subprocess.run(command, input=json.dumps(body) if body is not None else None, + capture_output=True, text=True, timeout=60, check=False) + if result.returncode: + if method == 'DELETE' and '(HTTP 404)' in result.stderr: + return None # Ephemeral registration already removed itself. + # API payloads can contain registration credentials; never echo them. + raise RuntimeError(f'GitHub API {method} {endpoint} failed; check gh authentication and repository administration access') + return json.loads(result.stdout) if result.stdout.strip() else None + + +def pages(endpoint, field): + return [item for page in api(endpoint, paginate=True) for item in page[field]] + + +def validate_run(run, head): + if (run.get('repository', {}).get('full_name') != REPOSITORY + or run.get('head_repository', {}).get('full_name') != REPOSITORY + or run.get('event') not in ('push', 'workflow_dispatch') + or run.get('path') not in WORKFLOWS + or run.get('head_sha') != head + or run.get('pull_requests') + or run.get('status') not in ('queued', 'in_progress', 'waiting', 'requested')): + raise ValueError('Run must be an unfinished, non-PR release workflow from this exact reviewed checkout') + branch = run.get('head_branch', '') + if branch != 'main' and not re.fullmatch(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', branch): + raise ValueError('Release runners accept only main or a versioned release tag') + return run + + +def validate_job(jobs, run_id, head): + label = f'cybexos-iso-{run_id}' + matching = [job for job in jobs if label in job.get('labels', []) + and job.get('status') in ('queued', 'in_progress', 'waiting', 'pending')] + if len(matching) != 1: + raise ValueError('Exactly one queued qualification job with this run-specific label is required') + job = matching[0] + if (job.get('status') != 'queued' or job.get('run_id') != run_id + or job.get('head_sha') != head or job.get('labels') != [label]): + raise ValueError('The qualification job must be queued and request only its exact run-specific label') + return job + + +def checkout_head(checkout=ROOT): + def git(*args): + return subprocess.run(['git', '-C', str(checkout), *args], check=True, + capture_output=True, text=True, timeout=15).stdout.strip() + if git('status', '--porcelain', '--untracked-files=all'): + raise ValueError('Use a clean, reviewed checkout matching the queued workflow head') + remote = git('remote', 'get-url', 'origin') + if remote not in (f'https://github.com/{REPOSITORY}', f'https://github.com/{REPOSITORY}.git', + f'git@github.com:{REPOSITORY}.git', f'ssh://git@github.com/{REPOSITORY}.git'): + raise ValueError('Checkout origin must be the official CybexOS repository') + head = git('rev-parse', 'HEAD') + if not re.fullmatch(r'[0-9a-f]{40,64}', head): + raise ValueError('Checkout commit identity is invalid') + return head + + +def reviewed_job(run_id, head, own_runner=None): + run = validate_run(api(f'repos/{REPOSITORY}/actions/runs/{run_id}'), head) + job = validate_job(pages(f'repos/{REPOSITORY}/actions/runs/{run_id}/jobs?filter=latest&per_page=100', 'jobs'), run_id, head) + label = f'cybexos-iso-{run_id}' + # A unique-only label avoids the generic self-hosted pool. Refuse a second + # runner for this run, or another pending job explicitly targeting it. + runners = pages(f'repos/{REPOSITORY}/actions/runners?per_page=100', 'runners') + if any(runner.get('id') != own_runner and label in [item.get('name') for item in runner.get('labels', [])] + for runner in runners): + raise ValueError('A runner already exists for this workflow run') + for state in ('queued', 'in_progress', 'waiting', 'pending', 'requested'): + for other in pages(f'repos/{REPOSITORY}/actions/runs?status={state}&per_page=100', 'workflow_runs'): + if other['id'] == run_id: + continue + others = pages(f'repos/{REPOSITORY}/actions/runs/{other["id"]}/jobs?filter=latest&per_page=100', 'jobs') + if any(label in item.get('labels', []) and item.get('status') != 'completed' for item in others): + raise ValueError('Another unfinished workflow requests the same release-runner label') + return run, job + + +def runner_package(releases): + found = [item for item in releases if item.get('os') == 'linux' and item.get('architecture') == 'x64'] + if len(found) != 1: + raise ValueError('GitHub must provide exactly one current Linux x64 runner package') + package = found[0] + checksum = package.get('sha256_checksum', '') + url = urlsplit(package.get('download_url', '')) + if (not re.fullmatch(r'[0-9a-fA-F]{64}', checksum) or url.scheme != 'https' + or url.netloc != 'github.com' or url.query or url.fragment + or not re.fullmatch(r'/actions/runner/releases/download/v[0-9.]+/actions-runner-linux-x64-[0-9.]+\.tar\.gz', url.path)): + raise ValueError('Runner download must have the GitHub API SHA-256 pin and official release URL') + return package['download_url'], checksum.lower() + + +def download(url, checksum, destination): + digest, size = hashlib.sha256(), 0 + with urlopen(url, timeout=60) as response, destination.open('xb') as stream: + if urlsplit(response.url).scheme != 'https': + raise ValueError('Runner download redirected to an insecure URL') + while block := response.read(8 * 1024 * 1024): + size += len(block) + if size > ARCHIVE_LIMIT: + raise ValueError('Runner archive exceeds the size limit') + digest.update(block) + stream.write(block) + if digest.hexdigest() != checksum: + raise ValueError('Runner download SHA-256 does not match the GitHub API pin') + + +def extract(archive, destination): + # Python 3.12+ data filtering rejects escaping paths, device files and + # escaping symlinks. Only checksum-verified official runner bytes reach it. + with tarfile.open(archive, 'r:gz') as bundle: + bundle.extractall(destination, filter='data') + for name in ('config.sh', 'run.sh'): + path = destination / name + if path.is_symlink() or not path.is_file() or not os.access(path, os.X_OK): + raise ValueError('Official runner archive is missing its executable entry points') + + +def child_environment(work): + home, temporary = work / 'home', work / 'tmp' + home.mkdir(mode=0o700) + temporary.mkdir(mode=0o700) + return {'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'), + 'HOME': str(home), 'TMPDIR': str(temporary), 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8', + 'PYTHONDONTWRITEBYTECODE': '1'} + + +def stop(process): + if process.poll() is not None: + return + for signum, deadline in ((signal.SIGINT, 30), (signal.SIGTERM, 15), (signal.SIGKILL, 10)): + try: + os.killpg(process.pid, signum) + except ProcessLookupError: + break + try: + process.wait(timeout=deadline) + return + except subprocess.TimeoutExpired: + continue + process.wait(timeout=10) + + +def run_child(command, work, environment, timeout, *, capture=False): + with subprocess.Popen(command, cwd=work, env=environment, start_new_session=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None) as process: + try: + process.communicate(timeout=timeout) + except BaseException: + stop(process) + raise + if process.returncode: + raise RuntimeError('Ephemeral runner command failed; local credentials and logs will be removed') + + +class ActiveRunnerError(RuntimeError): + """A transient unit could not be proven stopped; retain its task files.""" + + +def manager_environment(): + runtime = f'/run/user/{os.getuid()}' + return {'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin'), + 'XDG_RUNTIME_DIR': runtime, 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=' + runtime + '/bus', + 'LANG': 'C.UTF-8'} + + +def check_user_manager(): + result = subprocess.run(['systemctl', '--user', 'show', '--property=Version', '--value'], + env=manager_environment(), capture_output=True, timeout=15, check=False) + if result.returncode: + raise ValueError('A reachable operator systemd user manager is required for bounded runner/VM cleanup') + + +def run_scoped_runner(work, environment, timeout, name): + """A transient cgroup contains build VMs even when helpers call setsid().""" + unit = name + '.service' + manager = manager_environment() + command = ['systemd-run', '--user', '--quiet', '--wait', '--pipe', '--collect', + '--service-type=exec', '--unit=' + unit, '--working-directory=' + str(work), + '--property=RuntimeMaxSec=' + str(timeout) + 's', '--property=KillSignal=SIGINT', + '--property=TimeoutStopSec=120s', '--property=KillMode=mixed', + '--property=SendSIGKILL=yes', '/usr/bin/env', '-i', + *(key + '=' + value for key, value in sorted(environment.items())), str(work / 'run.sh')] + try: + # Give systemd time to complete graceful cancellation at RuntimeMaxSec. + run_child(command, work, manager, timeout + 135) + finally: + try: + subprocess.run(['systemctl', '--user', 'stop', unit], env=manager, + capture_output=True, timeout=135, check=False) + result = subprocess.run(['systemctl', '--user', 'show', unit, '--property=ActiveState', '--value'], + env=manager, capture_output=True, text=True, timeout=15, check=False) + if not ((result.returncode == 0 and result.stdout.strip() in ('inactive', 'failed')) + or (result.returncode == 4 and not result.stdout.strip())): + raise ActiveRunnerError(f'Could not confirm {unit} stopped; inspect that exact user unit before removing {work}') + except (OSError, subprocess.SubprocessError) as error: + raise ActiveRunnerError(f'Could not stop {unit}; inspect that exact user unit before removing {work}') from error + + +def owned_runner(name): + found = [runner for runner in pages(f'repos/{REPOSITORY}/actions/runners?per_page=100', 'runners') + if runner.get('name') == name] + if len(found) > 1: + raise RuntimeError('Ambiguous ephemeral runner registration; refuse to delete another runner') + return found[0]['id'] if found else None + + +def cleanup_registration(name): + # Ephemeral runners normally deregister themselves. Exact unique names also + # recover a partially completed config.sh registration without deleting peers. + identifier = owned_runner(name) + if identifier is not None: + api(f'repos/{REPOSITORY}/actions/runners/{identifier}', method='DELETE') + + +def execute(run_id, head, work_root, timeout): + check_user_manager() + reviewed_job(run_id, head) + url, checksum = runner_package(api(f'repos/{REPOSITORY}/actions/runners/downloads')) + name = f'cybexos-iso-{run_id}-{uuid.uuid4().hex[:12]}' + attempted = False + work = Path(tempfile.mkdtemp(prefix=f'cybexos-runner-{run_id}-', dir=work_root)) + retain = False + try: + download(url, checksum, work / 'runner.tar.gz') + extract(work / 'runner.tar.gz', work) + work.chmod(0o700) + (work / 'runner.tar.gz').unlink() + environment = child_environment(work) + # Recheck after a potentially slow download, immediately before enrolling. + reviewed_job(run_id, head) + registration = api(f'repos/{REPOSITORY}/actions/runners/registration-token', method='POST') + token = registration.get('token') if isinstance(registration, dict) else None + if not isinstance(token, str) or not token: + raise ValueError('GitHub did not return a registration token') + try: + attempted = True + # Runner CommandSettings accepts ACTIONS_RUNNER_INPUT_* and removes + # it from the environment after reading. Never put tokens in argv. + run_child([str(work / 'config.sh'), '--unattended', '--url', f'https://github.com/{REPOSITORY}', + '--name', name, '--ephemeral', '--disableupdate', '--no-default-labels', + '--labels', f'cybexos-iso-{run_id}', '--work', '_work'], + work, {**environment, 'ACTIONS_RUNNER_INPUT_TOKEN': token}, 120, capture=True) + token = None + registration = None + identifier = owned_runner(name) + if identifier is None: + raise RuntimeError('Runner registration was not visible in the repository') + _run, expected_job = reviewed_job(run_id, head, own_runner=identifier) + print(f'Starting ephemeral runner {name} for reviewed run {run_id}; automatic stop after {timeout} seconds.', flush=True) + try: + run_scoped_runner(work, environment, timeout, name) + except ActiveRunnerError: + retain = True + raise + completed = api(f'repos/{REPOSITORY}/actions/jobs/{expected_job["id"]}') + if (completed.get('run_id') != run_id or completed.get('runner_id') != identifier + or completed.get('head_sha') != head or completed.get('status') != 'completed'): + raise RuntimeError('Runner did not complete the reviewed job; inspect the workflow before retrying') + if completed.get('conclusion') != 'success': + raise RuntimeError('The reviewed qualification job failed; inspect its retained qualification reports') + print('Ephemeral runner stopped; removing its registration and task directory.', flush=True) + finally: + if attempted: + try: + cleanup_registration(name) + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + raise RuntimeError(f'Registration cleanup failed for {name}; inspect repository Actions runners and the reported transient unit before removing that exact registration') from error + + finally: + if retain: + print(f'Retained task staging {work}: runner unit stop could not be confirmed.', file=sys.stderr) + else: + shutil.rmtree(work) + + +def interrupted(_signal, _frame): + raise KeyboardInterrupt + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--run-id', type=int, required=True, help='Already queued reviewed release workflow run') + parser.add_argument('--execute', action='store_true', help='Register and run one ephemeral runner; otherwise validate only') + parser.add_argument('--work-root', type=Path, default=Path('/data/cybexos-runners')) + parser.add_argument('--timeout', type=int, default=7 * 60 * 60, help='Maximum runner lifetime in seconds (default: 7 hours)') + args = parser.parse_args() + if args.run_id <= 0 or not 60 <= args.timeout <= 8 * 60 * 60: + parser.error('Use a positive run ID and a timeout between 60 seconds and 8 hours') + try: + head = checkout_head() + run, job = reviewed_job(args.run_id, head) + print(f'Reviewed {run["path"]} at {head}; queued job {job["id"]}; label cybexos-iso-{args.run_id}.') + if not args.execute: + print('Validation only. --execute registers an ephemeral runner and starts the reviewed job.') + return 0 + if platform.system() != 'Linux' or platform.machine() != 'x86_64' or os.geteuid() == 0: + raise ValueError('Run as the unprivileged PXE operator on Linux x86_64') + if not Path('/data/pxe/README.md').is_file(): + raise ValueError('Execute on the documented PXE host with /data/pxe/README.md') + work_root = args.work_root.resolve() + if work_root == ROOT or work_root.is_relative_to(ROOT) or work_root.is_relative_to(Path('/data/pxe/iso')): + raise ValueError('Runner staging must be outside the checkout and served ISO tree') + work_root.mkdir(parents=True, exist_ok=True) + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(signum, interrupted) + execute(args.run_id, head, work_root, args.timeout) + return 0 + except KeyboardInterrupt: + print('Ephemeral runner interrupted; cleanup completed or was reported above.', file=sys.stderr) + return 130 + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, tarfile.TarError) as error: + parser.exit(1, f'release-runner: {error}\n') + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/image/rootfs/usr/bin/cybex b/image/rootfs/usr/bin/cybex index 8a4bb0d8..5277b84a 100755 --- a/image/rootfs/usr/bin/cybex +++ b/image/rootfs/usr/bin/cybex @@ -6,19 +6,13 @@ case $command_name in welcome) exec /usr/bin/cybexos-welcome "$@" ;; configure) exec /usr/libexec/cybexos-config "$@" ;; update) exec /usr/share/cybexos/bin/cybexos-update-run run "$@" ;; + update-channel) exec /usr/libexec/cybexos-update-channel "$@" ;; prepare-apps) exec /usr/libexec/cybexos-user-init --background "$@" ;; repair) exec sudo /usr/libexec/cybexos-configure-installed --user "${SUDO_USER:-$USER}" "$@" ;; agent) exec /usr/share/cybexos/bin/cybexos-agent "$@" ;; plugin|dev) exec /usr/share/cybexos/bin/cybexos-runtime "$command_name" "$@" ;; version|--version) exec rpm -q cybexos-desktop ;; - verify|doctor) - systemctl --user is-active quickshell.service - hyprctl configerrors - if [[ -f /var/lib/cybexos/hardware-unsupported ]]; then - echo 'Camera setup needs kernel compatibility review; see /var/lib/xps-hardware/ipu7/abi-failed.log.' >&2 - exit 1 - fi - ;; + verify|doctor) exec /usr/libexec/cybexos-doctor "$@" ;; uninstall) # The desktop is the operating system of an ISO installation; there is no # separate workstation layer to remove. @@ -35,13 +29,14 @@ Commands: welcome Open the CybexOS welcome window configure Change installation choices and apply them (--check prints them) update Check for and apply CybexOS and system updates + update-channel Inspect or enroll the desktop RPM update channel prepare-apps Prepare the offline applications for this account repair Reapply the installed-system policy to this account agent Launch or choose the default AI coding agent plugin Install, update, clone, remove, or configure desktop plugins dev Select, inspect, or disable a live development checkout version Print the installed CybexOS package version - verify Check the running desktop session + verify Check installed-system health (--json for machine-readable output) doctor Alias for verify EOF ;; diff --git a/image/rootfs/usr/bin/cybexos-welcome b/image/rootfs/usr/bin/cybexos-welcome index a2719820..c9bd49e5 100755 --- a/image/rootfs/usr/bin/cybexos-welcome +++ b/image/rootfs/usr/bin/cybexos-welcome @@ -5,7 +5,7 @@ import os from pathlib import Path import sys -from PySide6.QtCore import QObject, Property, QProcess, QTimer, QUrl, Signal, Slot +from PySide6.QtCore import QObject, Property, QProcess, QProcessEnvironment, QTimer, QUrl, Signal, Slot from PySide6.QtGui import QGuiApplication from PySide6.QtNetwork import QLocalServer, QLocalSocket from PySide6.QtQml import QQmlApplicationEngine @@ -26,12 +26,14 @@ class Welcome(QObject): activate = Signal() wallpapersChanged = Signal() wallpaperStateChanged = Signal() + setupChanged = Signal() # Shell IPC calls the window's buttons make, by the name QML passes. SHELL_PAGES = { "appearance": ["settings", "open", "appearance"], # Settings → Wallpaper, on its Online (Wallhaven) view. "wallpaper": ["wallpaper", "browse"], + "network": ["settings", "open", "network"], } def __init__(self): @@ -70,6 +72,61 @@ class Welcome(QObject): self.retry_timer.setSingleShot(True) self.retry_timer.setInterval(SHELL_RETRY_MS) self.retry_timer.timeout.connect(self.request_popular) + self._hardware_state = 'pending' + self._hardware_note = '' + self._seed_state = 'pending' + self._network_state = 'checking' + self._reconcile_state = 'pending' + self.setup_timer = QTimer(self) + self.setup_timer.setInterval(4000) + self.setup_timer.timeout.connect(self.refreshSetup) + self.network_process = QProcess(self) + network_environment = QProcessEnvironment.systemEnvironment() + network_environment.insert("LC_ALL", "C") + self.network_process.setProcessEnvironment(network_environment) + self.network_process.finished.connect(self.networkFinished) + self.network_process.errorOccurred.connect(self.networkFailed) + self.reconcile_process = QProcess(self) + self.reconcile_process.finished.connect(self.reconcileFinished) + self.reconcile_process.errorOccurred.connect(self.reconcileFailed) + self.setup_timeout = QTimer(self) + self.setup_timeout.setSingleShot(True) + self.setup_timeout.setInterval(8000) + self.setup_timeout.timeout.connect(self.setupTimedOut) + + def startSetup(self): + # Start only after singleton/autostart checks and successful QML load. + if not self.live: + self.setup_timer.start() + self.refreshSetup() + + def stopSetup(self): + self.setup_timer.stop() + self.setup_timeout.stop() + for process in (self.network_process, self.reconcile_process): + # Destruction can emit errors; disconnect before stopping children. + process.finished.disconnect() + process.errorOccurred.disconnect() + if process.state() != QProcess.NotRunning: + process.terminate() + if not process.waitForFinished(200): + process.kill() + process.waitForFinished(200) + + def setupTimedOut(self): + for process in (self.network_process, self.reconcile_process): + if process.state() != QProcess.NotRunning: + process.kill() + + def networkFailed(self, _error): + if self._network_state != 'offline': + self._network_state = 'offline' + self.setupChanged.emit() + + def reconcileFailed(self, _error): + if self._reconcile_state != 'error': + self._reconcile_state = 'error' + self.setupChanged.emit() @Property(bool, constant=True) def isLive(self): @@ -83,6 +140,92 @@ class Welcome(QObject): def status(self): return self.message + @Property(str, notify=setupChanged) + def hardwareState(self): + return self._hardware_state + + @Property(str, notify=setupChanged) + def hardwareNote(self): + return self._hardware_note + + @Property(str, notify=setupChanged) + def seedState(self): + return self._seed_state + + @Property(str, notify=setupChanged) + def networkState(self): + return self._network_state + + @Property(str, notify=setupChanged) + def reconcileState(self): + return self._reconcile_state + + @staticmethod + def local_json(path): + try: + value = json.loads(path.read_text()) + return value if isinstance(value, dict) else {} + except (OSError, ValueError): + return {} + + @Slot() + def refreshSetup(self): + if self.live: + return + hardware = self.local_json(Path('/var/lib/cybexos/hardware-status.json')) + policy = self.local_json(Path('/etc/cybexos/hardware.json')) + state_dir = Path(os.environ.get('XDG_STATE_HOME', str(Path.home() / '.local/state'))) / 'cybexos' + seed = self.local_json(state_dir / 'seed-progress.json') + values = { + '_hardware_state': 'skipped' if not policy.get('xps_2026') and not hardware else hardware.get('state', 'pending'), + '_hardware_note': str(hardware.get('reason', '')), + '_seed_state': 'ready' if (state_dir / 'offline-apps-seeded').is_file() else seed.get('state', 'pending'), + } + if Path('/var/lib/cybexos/hardware-unsupported').is_file(): + values['_hardware_state'] = 'failed' + if any(getattr(self, key) != value for key, value in values.items()): + for key, value in values.items(): + setattr(self, key, value) + self.setupChanged.emit() + started = False + if self.network_process.state() == QProcess.NotRunning: + started = True + self.network_process.start('/usr/bin/nmcli', ['-t', '-f', 'STATE', 'general']) + if self.reconcile_process.state() == QProcess.NotRunning: + started = True + self.reconcile_process.start('/usr/libexec/cybexos-reconcile', ['--status']) + if started and not self.setup_timeout.isActive(): + self.setup_timeout.start() + + def reconcileFinished(self, code, _status): + output = bytes(self.reconcile_process.readAllStandardOutput()).decode('utf-8', 'replace') + try: + state = json.loads(output).get('state', 'error') if code == 0 else 'error' + except (ValueError, AttributeError): + state = 'error' + if self._reconcile_state != state: + self._reconcile_state = state + self.setupChanged.emit() + + def networkFinished(self, code, _status): + output = bytes(self.network_process.readAllStandardOutput()).decode('utf-8', 'replace').strip() + state = 'connected' if code == 0 and output.splitlines()[:1] == ['connected'] else 'offline' + if self._network_state != state: + self._network_state = state + self.setupChanged.emit() + + @Slot() + def retryApps(self): + if self.live: + return + QProcess.startDetached('/usr/bin/cybex', ['prepare-apps']) + + @Slot() + def retryHardware(self): + if self.live: + return + QProcess.startDetached('/usr/bin/kitty', ['-e', '/usr/bin/cybex', 'repair', '--hardware']) + @Slot() def install(self): if not self.live or self.busy: @@ -361,6 +504,8 @@ def main(): engine.load(QUrl.fromLocalFile(str(QML))) if not engine.rootObjects(): return 1 + app.aboutToQuit.connect(backend.stopSetup) + backend.startSetup() return app.exec() diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.service b/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.service new file mode 100644 index 00000000..03ba5c66 --- /dev/null +++ b/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.service @@ -0,0 +1,12 @@ +[Unit] +Description=Reconcile installed CybexOS policy after a desktop upgrade +After=local-fs.target systemd-user-sessions.service +ConditionPathExists=!/run/cybexos-live +ConditionPathExists=/usr/share/cybexos/reconcile-version + +[Service] +Type=oneshot +ExecStart=/usr/libexec/cybexos-reconcile +TimeoutStartSec=45min +Nice=10 +UMask=0022 diff --git a/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.timer b/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.timer new file mode 100644 index 00000000..0cdf5039 --- /dev/null +++ b/image/rootfs/usr/lib/systemd/system/cybexos-reconcile.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Apply pending CybexOS desktop policy and initialize new accounts +ConditionPathExists=!/run/cybexos-live + +[Timer] +OnBootSec=2min +OnActiveSec=2min +OnUnitInactiveSec=5min +RandomizedDelaySec=30s + +[Install] +WantedBy=timers.target diff --git a/image/rootfs/usr/libexec/cybexos-config b/image/rootfs/usr/libexec/cybexos-config index 021492db..ee45963d 100755 --- a/image/rootfs/usr/libexec/cybexos-config +++ b/image/rootfs/usr/libexec/cybexos-config @@ -131,6 +131,11 @@ def detect(root=Path('/'), entries=None, group=group_name, inventory=INVENTORY, 'primary_group': group(account.pw_gid), 'primary_home': account.pw_dir, **identity(root)} # Unset, templated or non-boolean defaults fall back to the stricter choice. values.update({key: defaults.get(key) is True for key in BOOLEANS}) + # A live ISO cannot inherit the build host's sudo choice. The guided + # installer applies a confirmed opt-in after provisioning; Advanced has + # no such prompt and keeps the password-required default. + if fresh_account: + values['passwordless_wheel'] = False # Anaconda and the OS settings own identity after an ISO installation; a # later checkout run must not reset them to this installation-time record. values['manage_system_identity'] = False diff --git a/image/rootfs/usr/libexec/cybexos-configure-installed b/image/rootfs/usr/libexec/cybexos-configure-installed index c1761613..a28eda4f 100755 --- a/image/rootfs/usr/libexec/cybexos-configure-installed +++ b/image/rootfs/usr/libexec/cybexos-configure-installed @@ -77,7 +77,7 @@ def accounts(entries, selected=None): and (selected is None or entry.pw_name == selected)] -def configure(account, offline=False, hardware=False): +def configure(account, offline=False, hardware=False, reconcile=False): # Do not inherit a user's callback plugins, Ansible configuration or Python # path into a root provisioning process. The payload contains reviewed roles. environment = { @@ -93,6 +93,7 @@ def configure(account, offline=False, hardware=False): 'primary_group': grp.getgrgid(account.pw_gid).gr_name, 'config_repo': str(PROVISION), 'cybexos_offline': offline, 'cybexos_hardware_only': hardware, + 'cybexos_reconcile': reconcile, 'start_optional_hardware_services': not offline, } # Seeding a later non-admin account must not promote it to wheel. @@ -119,10 +120,30 @@ def retire_legacy_fish(account): str(legacy), str(vendor)], check=True) +def boot_id(): + return Path('/proc/sys/kernel/random/boot_id').read_text().strip() + + +def read_hardware_status(path): + try: + value = json.loads(path.read_text()) + return value if isinstance(value, dict) and value.get('state') in ('pending', 'completed', 'failed', 'running') else {} + except (OSError, ValueError): + return {} + + +def write_hardware_status(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix('.tmp') + temporary.write_text(json.dumps(value, sort_keys=True) + '\n') + temporary.replace(path) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--offline', action='store_true') parser.add_argument('--hardware', action='store_true') + parser.add_argument('--reconcile', action='store_true') parser.add_argument('--user') parser.add_argument('--automatic', action='store_true') parser.add_argument('--save-config', action='store_true', @@ -130,63 +151,106 @@ def main(): args = parser.parse_args() if os.geteuid() != 0: parser.error('Run as root in the installed system') - if args.offline and args.hardware: - parser.error('Hardware setup requires the running installed system') + if sum((args.offline, args.hardware, args.reconcile)) > 1: + parser.error('--offline, --hardware, and --reconcile are mutually exclusive') if args.automatic and not args.hardware: parser.error('--automatic is only valid for hardware setup') - if args.save_config and (args.offline or args.hardware or not args.user): + if args.save_config and (args.offline or args.hardware or args.reconcile or not args.user): parser.error('--save-config applies one account in the running installed system') text = sys.stdin.read() if args.save_config else None lock_directory = Path('/run/lock/cybexos') lock_directory.mkdir(mode=0o755, exist_ok=True) - lock = (lock_directory / 'provision.lock').open('a') - fcntl.flock(lock, fcntl.LOCK_EX) - users = accounts(pwd.getpwall(), args.user) - if args.user and not users: - parser.error('Select an existing installed desktop account') - settings = None if args.hardware else load('cybexos_config', CONFIG_TOOL) - if args.save_config: - save_config(settings, text, args.user) - elif settings and settings.ensure(fresh_account=args.offline): - # Installation and repair of an older ISO installation record the - # detected choices before provisioning consumes them. - print('Recorded the installation choices in /etc/cybexos/config.yml') - digest = hashlib.sha256() - for path in sorted(PROVISION.rglob('*')): - if path.is_file(): - digest.update(str(path.relative_to(PROVISION)).encode()) - digest.update(path.read_bytes()) - identity = digest.hexdigest() + ':' + os.uname().release + ':' + ','.join(user.pw_name for user in users) - marker = Path('/var/lib/cybexos/hardware-configured') - unsupported = marker.with_name('hardware-unsupported') - if args.automatic and marker.is_file() and marker.read_text() == identity: - return - if args.automatic and unsupported.is_file() and unsupported.read_text() == identity: - raise SystemExit('Camera setup needs kernel compatibility review; see /var/lib/xps-hardware/ipu7/abi-failed.log. Automatic retry waits for a changed kernel or provisioning payload.') - for account in users: - try: - configure(account, args.offline, args.hardware) - except subprocess.CalledProcessError: - diagnostic = Path('/var/lib/xps-hardware/ipu7/abi-failed.log') - # The hardware role deliberately reuses a cached ABI diagnostic - # for an unchanged kernel; its timestamp need not be from this run. - if (args.hardware and diagnostic.is_file() - and 'IPU7_STOCK_ABI_CHANGED' in diagnostic.read_text() - and os.uname().release in diagnostic.read_text()): - unsupported.parent.mkdir(parents=True, exist_ok=True) - unsupported.write_text(identity) - raise - if not args.hardware: - retire_legacy_fish(account) - # The Anaconda target helper decides login intent after verifying encryption. - if settings and not args.offline: - record_login(settings) - if args.hardware and users: - marker.parent.mkdir(parents=True, exist_ok=True) - pending = marker.with_suffix('.pending') - pending.write_text(identity) - pending.replace(marker) - unsupported.unlink(missing_ok=True) + with (lock_directory / 'provision.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + users = accounts(pwd.getpwall(), args.user) + if args.user and not users: + parser.error('Select an existing installed desktop account') + settings = None if args.hardware or args.reconcile else load('cybexos_config', CONFIG_TOOL) + if args.save_config: + save_config(settings, text, args.user) + elif settings and settings.ensure(fresh_account=args.offline): + # Installation and repair of an older ISO installation record the + # detected choices before provisioning consumes them. + print('Recorded the installation choices in /etc/cybexos/config.yml') + digest = hashlib.sha256() + for path in sorted(PROVISION.rglob('*')): + if path.is_file(): + digest.update(str(path.relative_to(PROVISION)).encode()) + digest.update(path.read_bytes()) + saved_config = Path('/etc/cybexos/config.yml') + if saved_config.is_file(): + digest.update(saved_config.read_bytes()) + identity = digest.hexdigest() + ':' + os.uname().release + ':' + ','.join(user.pw_name for user in users) + marker = Path('/var/lib/cybexos/hardware-configured') + unsupported = marker.with_name('hardware-unsupported') + status_path = marker.with_name('hardware-status.json') + status = read_hardware_status(status_path) if args.hardware else {} + this_boot = boot_id() if args.hardware else '' + same_identity = status.get('identity') == identity + if args.automatic and unsupported.is_file() and unsupported.read_text() == identity: + raise SystemExit('Camera setup needs kernel compatibility review; see /var/lib/xps-hardware/ipu7/abi-failed.log. Automatic retry waits for a changed kernel or provisioning payload.') + if args.automatic and same_identity: + if status['state'] == 'completed': + return + if status['state'] == 'pending' and status.get('boot_id') == this_boot: + return + if status['state'] == 'failed' and status.get('boot_id') == this_boot: + return + if status.get('resume_boot_id') == this_boot: + return + # A legacy marker did not distinguish success from reboot-pending. + # Validate it once and produce a structured outcome before skipping. + if args.hardware and users: + resumed = bool(same_identity and status.get('state') == 'pending' and status.get('boot_id') != this_boot) + write_hardware_status(status_path, {'version': 1, 'state': 'running', 'identity': identity, + 'boot_id': this_boot, 'resume_boot_id': this_boot if resumed else ''}) + marker.with_name('hardware-result.json').unlink(missing_ok=True) + for account in users: + try: + configure(account, args.offline, args.hardware, args.reconcile) + except subprocess.CalledProcessError: + diagnostic = Path('/var/lib/xps-hardware/ipu7/abi-failed.log') + # The hardware role deliberately reuses a cached ABI diagnostic + # for an unchanged kernel; its timestamp need not be from this run. + if (args.hardware and diagnostic.is_file() + and 'IPU7_STOCK_ABI_CHANGED' in diagnostic.read_text() + and os.uname().release in diagnostic.read_text()): + unsupported.parent.mkdir(parents=True, exist_ok=True) + unsupported.write_text(identity) + if args.hardware: + write_hardware_status(status_path, {'version': 1, 'state': 'failed', 'identity': identity, + 'boot_id': this_boot, 'reason': 'hardware role failed; run cybex repair --hardware'}) + raise + if not args.hardware and not args.reconcile: + retire_legacy_fish(account) + # The Anaconda target helper decides login intent after verifying encryption. + if settings and not args.offline: + record_login(settings) + if args.hardware and users: + # The role result is a separate schema, so read it directly. + try: + result = json.loads(marker.with_name('hardware-result.json').read_text()) + except (OSError, ValueError): + result = None + if not isinstance(result, dict) or not isinstance(result.get('reboot_required'), bool): + write_hardware_status(status_path, {'version': 1, 'state': 'failed', 'identity': identity, + 'boot_id': this_boot, 'reason': 'hardware role did not report an outcome'}) + raise SystemExit('Hardware role did not report an outcome; run cybex repair --hardware') + pending_reboot = result['reboot_required'] + preserved = bool(result.get('preserved_previous') and not result.get('camera_ready')) + state = 'pending' if pending_reboot else 'failed' if preserved else 'completed' + reason = ('reboot required for camera validation' if pending_reboot else + 'previous camera stack retained after a failed refresh; run cybex repair --hardware' if preserved else '') + write_hardware_status(status_path, {'version': 1, 'state': state, + 'identity': identity, 'boot_id': this_boot, + 'reason': reason, + 'camera_ready': result.get('camera_ready', False), + 'preserved_previous': result.get('preserved_previous', False)}) + if state == 'completed': + pending = marker.with_suffix('.pending') + pending.write_text(identity) + pending.replace(marker) + unsupported.unlink(missing_ok=True) if __name__ == '__main__': diff --git a/image/rootfs/usr/libexec/cybexos-doctor b/image/rootfs/usr/libexec/cybexos-doctor new file mode 100755 index 00000000..df737e60 --- /dev/null +++ b/image/rootfs/usr/libexec/cybexos-doctor @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Read-only installed-image health summary. Never probes capture hardware.""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess + + +def run(*argv): + try: + result = subprocess.run(argv, text=True, capture_output=True, timeout=8, check=False, + env={**os.environ, "LC_ALL": "C"}) + return result.returncode, result.stdout.strip() + except (OSError, subprocess.TimeoutExpired): + return 127, '' + + +def read_json(path): + try: + value = json.loads(path.read_text()) + return value if isinstance(value, dict) else {} + except (OSError, ValueError): + return {} + + +def collect(root=Path('/'), home=Path.home(), command=run): + def at(path): + return root / path.lstrip('/') + + checks = [] + def add(name, state, detail, action=''): + checks.append({'name': name, 'state': state, 'detail': detail, 'action': action}) + + build = read_json(at('/usr/share/cybexos/build.json')) + rc, package = command('rpm', '-q', 'cybexos-desktop') + if rc or not re.fullmatch(r'[0-9a-f]{40,64}', str(build.get('source_revision', ''))): + add('build', 'fail', 'Desktop package or build provenance is missing.', 'Reinstall the CybexOS desktop RPM.') + else: + add('build', 'ok', f"{package}; source {str(build['source_revision'])[:12]}") + + rc, channel_text = command('/usr/libexec/cybexos-update-channel', 'status', '--json') + try: + channel = json.loads(channel_text) if rc == 0 else {} + except ValueError: + channel = {} + if not isinstance(channel, dict): + channel = {} + if channel.get('status') == 'desktop-channel-ready': + add('desktop_channel', 'ok', 'Desktop update channel is enabled.') + elif channel.get('status') == 'desktop-channel-disabled': + add('desktop_channel', 'warning', 'Desktop update channel is not enrolled.', + 'Run cybex update-channel status for enrollment details.') + else: + add('desktop_channel', 'fail', 'Desktop update channel cannot be verified.', + 'Run cybex update-channel status.') + + rc, active = command('systemctl', '--user', 'is-active', 'quickshell.service') + add('quickshell', 'ok' if rc == 0 and active == 'active' else 'fail', + 'Quickshell is active.' if rc == 0 and active == 'active' else 'Quickshell is not active.', + '' if rc == 0 and active == 'active' else 'Run systemctl --user status quickshell.service.') + rc, errors = command('hyprctl', 'configerrors') + add('hyprland', 'ok' if rc == 0 and not errors else 'fail', + 'Hyprland configuration has no reported errors.' if rc == 0 and not errors else 'Hyprland reported configuration errors.', + '' if rc == 0 and not errors else 'Run hyprctl configerrors.') + + failed = [] + failed_query_ok = True + for args in (('systemctl', '--failed', '--plain', '--no-legend'), + ('systemctl', '--user', '--failed', '--plain', '--no-legend')): + rc, output = command(*args) + if rc == 0: + failed.extend(line.split()[0] for line in output.splitlines() if line.split()) + else: + failed_query_ok = False + add('failed_units', 'fail' if failed else 'ok' if failed_query_ok else 'warning', + 'Failed units: ' + ', '.join(failed[:8]) if failed else + 'No failed system or user units.' if failed_query_ok else 'Could not query all failed units.', + 'Run systemctl --failed and systemctl --user --failed.' if failed else '') + rc, qml_errors = command('journalctl', '--user', '-b', '-u', 'quickshell.service', '-p', 'warning', '--no-pager', '-o', 'cat') + # Report only a count. Journals may contain private window or widget data. + count = len([line for line in qml_errors.splitlines() if 'qml' in line.lower() or 'qrc:' in line.lower()]) if rc == 0 else 0 + add('qml', 'warning' if count or rc else 'ok', + f'{count} Quickshell QML error lines in this boot.' if count else + 'No Quickshell QML errors in this boot.' if rc == 0 else 'Could not read the Quickshell journal.') + + state_dir = home / '.local/state/cybexos' + seed = read_json(state_dir / 'seed-progress.json') + seed_state = seed.get('state', 'pending') + if (state_dir / 'offline-apps-seeded').is_file(): + seed_state = 'ready' + seed_level = {'ready': 'ok', 'copying': 'pending', 'pending': 'pending', 'error': 'fail'}.get(seed_state, 'warning') + add('seeded_apps', seed_level, + {'ready': 'Offline applications are ready.', 'copying': 'Offline applications are being prepared.', + 'pending': 'Offline applications are pending.', 'error': 'Offline application preparation failed.'}.get(seed_state, 'Offline application status is unknown.'), + 'Run cybex prepare-apps to retry.' if seed_level == 'fail' else '') + + rc, reconciliation_text = command('/usr/libexec/cybexos-reconcile', '--status') + try: + reconciliation = json.loads(reconciliation_text) if rc == 0 else {} + except ValueError: + reconciliation = {} + if not isinstance(reconciliation, dict): + reconciliation = {} + reconcile_state = reconciliation.get('state', 'unknown') + add('reconciliation', {'ready': 'ok', 'pending': 'pending', 'running': 'pending', + 'error': 'fail', 'blocked': 'fail'}.get(reconcile_state, 'warning'), + f'Installed policy reconciliation: {reconcile_state}.', + 'Run sudo /usr/libexec/cybexos-reconcile --retry.' if reconcile_state in ('error', 'blocked') else '') + + hardware_policy = read_json(at('/etc/cybexos/hardware.json')) + hardware = read_json(at('/var/lib/cybexos/hardware-status.json')) + hardware_state = hardware.get('state', 'pending') + if not hardware_policy.get('xps_2026') and not hardware: + hardware_state = 'skipped' + if at('/var/lib/cybexos/hardware-unsupported').exists(): + hardware_state = 'failed' + add('hardware', {'completed': 'ok', 'pending': 'pending', 'running': 'pending', + 'failed': 'fail', 'skipped': 'skipped'}.get(hardware_state, 'warning'), + f'Hardware setup: {hardware_state}.', + 'Reboot to validate the camera transaction.' if hardware_state == 'pending' and 'reboot' in hardware.get('reason', '') + else 'Run cybex repair --hardware.' if hardware_state == 'failed' else '') + + rc, network = command('nmcli', '-t', '-f', 'STATE', 'general') + connected = rc == 0 and network.splitlines()[:1] == ['connected'] + add('network', 'ok' if connected else 'warning', + 'Network is connected.' if connected else 'Network is disconnected or still connecting.', + '' if connected else 'Open network settings to connect.') + + rc, recovery = command('systemctl', 'is-enabled', 'cybexos-recovery-refresh.service') + recovery_files = all(at(path).is_file() for path in ('/etc/grub.d/42_cybexos_recovery', + '/usr/lib/systemd/system/cybexos-recovery-refresh.service')) + add('recovery', 'ok' if recovery_files and rc == 0 and recovery == 'enabled' else 'warning', + 'Recovery refresh is installed and enabled.' if recovery_files and rc == 0 and recovery == 'enabled' + else 'Recovery refresh is not fully enabled.') + + if hardware_policy.get('xps_2026'): + rc, thermal = command('systemctl', 'is-active', 'thermald.service') + add('xps_thermal', 'ok' if rc == 0 and thermal == 'active' else 'warning', + 'XPS thermal service is active.' if rc == 0 and thermal == 'active' else 'XPS thermal service is not active.') + rc, speaker = command('systemctl', '--user', 'is-active', 'xps-speaker-tuning.service') + add('xps_audio', 'ok' if rc == 0 and speaker == 'active' else 'warning', + 'XPS speaker tuning is active.' if rc == 0 and speaker == 'active' else 'XPS speaker tuning is not active.') + + overall = 'fail' if any(item['state'] == 'fail' for item in checks) else \ + 'warning' if any(item['state'] in ('warning', 'pending') for item in checks) else 'ok' + return {'version': 1, 'overall': overall, 'checks': checks} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--json', action='store_true') + args = parser.parse_args() + result = collect() + if args.json: + print(json.dumps(result, sort_keys=True)) + else: + for item in result['checks']: + print(f"{item['state'].upper():7} {item['name']}: {item['detail']}") + if item['action']: + print(f" {item['action']}") + raise SystemExit(1 if result['overall'] == 'fail' else 0) + + +if __name__ == '__main__': + main() diff --git a/image/rootfs/usr/libexec/cybexos-reconcile b/image/rootfs/usr/libexec/cybexos-reconcile new file mode 100755 index 00000000..6acfe739 --- /dev/null +++ b/image/rootfs/usr/libexec/cybexos-reconcile @@ -0,0 +1,192 @@ +#!/usr/bin/python3 +"""Deferred, bounded baseline/account convergence for installed RPM upgrades.""" +import argparse +from contextlib import contextmanager +import fcntl +import hashlib +import json +import os +from pathlib import Path +import pwd +import subprocess +import tempfile +import time + +VENDOR = Path('/usr/share/cybexos') +STATE = Path('/var/lib/cybexos/reconcile') +CONFIG = Path('/etc/cybexos/config.yml') +MAX_ATTEMPTS = 3 + + +def write(path, value): + path.parent.mkdir(parents=True, exist_ok=True, mode=0o755) + fd, temporary = tempfile.mkstemp(prefix='.reconcile-', dir=path.parent) + try: + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream, sort_keys=True) + stream.write('\n') + stream.flush() + os.fsync(stream.fileno()) + os.fchmod(stream.fileno(), 0o644) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + + +def read(path, default): + return json.loads(path.read_text()) if path.exists() else default + + +def version(): + value = (VENDOR / 'reconcile-version').read_text().strip() + if len(value) != 64 or any(c not in '0123456789abcdef' for c in value): + raise ValueError('Invalid packaged reconciliation version') + return value + + +def accounts(): + return [entry for entry in pwd.getpwall() if 1000 <= entry.pw_uid < 65534 + and entry.pw_name != 'liveuser' and Path(entry.pw_dir).parent == Path('/home')] + + +def identity(account, release): + config = CONFIG.read_bytes() if CONFIG.is_file() else b'' + return hashlib.sha256(json.dumps([release, hashlib.sha256(config).hexdigest(), + account.pw_uid, account.pw_gid, account.pw_dir]).encode()).hexdigest() + + +def status(): + value = read(STATE / 'status.json', {'state': 'pending', 'accounts': {}}) + desired = version() + value['desiredVersion'] = desired + value['pending'] = value.get('version') != desired or value.get('state') != 'ready' + if value.get('version') != desired: + value['state'] = 'pending' + return value + + +@contextmanager +def rpm_idle(): + """Share RPM's POSIX transaction lock; never race a package payload change. + + Baseline reconciliation deliberately contains no package operations. The + lock also prevents DNF from replacing our helper/payload halfway through. + """ + location = next((p for p in (Path('/usr/lib/sysimage/rpm/.rpm.lock'), + Path('/var/lib/rpm/.rpm.lock')) if p.is_file()), None) + if location is None: + raise BlockingIOError('RPM transaction lock is unavailable; retry after boot') + with location.open('rb') as lock: + fcntl.lockf(lock, fcntl.LOCK_SH | fcntl.LOCK_NB) + yield + + +def apply(account): + env = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': '/root', + 'LANG': 'C.UTF-8', 'PYTHONDONTWRITEBYTECODE': '1'} + subprocess.run(['/usr/libexec/cybexos-configure-installed', '--reconcile', + '--user', account.pw_name], env=env, check=True, timeout=600) + # All home traversal runs with that account's privilege, never as root. + subprocess.run(['/usr/sbin/runuser', '-u', account.pw_name, '--', '/usr/bin/env', '-i', + 'HOME=' + account.pw_dir, 'USER=' + account.pw_name, + 'PATH=/usr/bin:/bin', 'LANG=C.UTF-8', 'PYTHONDONTWRITEBYTECODE=1', + '/usr/libexec/cybexos-user-init', '--essential'], + env=env, check=True, timeout=120) + # New packaged unit definitions take effect at the next service start; + # do not disrupt a running desktop or the user's in-flight app state. + if Path(f'/run/user/{account.pw_uid}/bus').exists(): + subprocess.run(['/usr/bin/systemctl', '--user', '--machine=' + account.pw_name + '@.host', + 'daemon-reload'], env=env, check=True, timeout=30) + + +def reconcile(retry=False): + release = version() + previous = read(STATE / 'status.json', {'accounts': {}}) + state = {'version': release, 'state': 'pending', 'accounts': {}, 'lastAttempt': int(time.time())} + users = accounts() + for account in users: + key = identity(account, release) + old = previous.get('accounts', {}).get(account.pw_name, {}) + entry = dict(old) if old.get('identity') == key else { + 'identity': key, 'uid': account.pw_uid, 'home': account.pw_dir, + 'version': release, 'attempts': 0, 'state': 'pending'} + if retry and entry['state'] != 'ready': + entry.update(attempts=0, state='pending') + state['accounts'][account.pw_name] = entry + # Empty installations still need their first account reconciled later. + if not users: + state.update(state='pending', error='Waiting for an installed desktop account') + write(STATE / 'status.json', state) + return state + if all(entry['state'] == 'ready' for entry in state['accounts'].values()): + state['state'] = 'ready' + (STATE / 'pending.json').unlink(missing_ok=True) + if previous.get('version') == release and previous.get('state') == 'ready' and previous.get('accounts') == state['accounts']: + return previous + write(STATE / 'status.json', state) + (STATE / 'pending.json').unlink(missing_ok=True) + return state + write(STATE / 'status.json', state) + try: + with rpm_idle(): + for account in users: + entry = state['accounts'][account.pw_name] + if entry['state'] == 'ready' or entry['attempts'] >= MAX_ATTEMPTS: + continue + entry.update(state='running', attempts=entry['attempts'] + 1) + state['state'] = 'running' + write(STATE / 'status.json', state) + try: + apply(account) + except (OSError, subprocess.SubprocessError) as error: + entry.update(state='error', error=str(error)) + else: + entry.update(state='ready', identity=identity(account, release)) + entry.pop('error', None) + write(STATE / 'status.json', state) + except BlockingIOError as error: + state.update(state='pending', error=str(error)) + write(STATE / 'status.json', state) + return state + unfinished = [entry for entry in state['accounts'].values() if entry['state'] != 'ready'] + state['state'] = ('blocked' if all(entry['attempts'] >= MAX_ATTEMPTS for entry in unfinished) + else 'error') if unfinished else 'ready' + if unfinished: + state['error'] = 'Reconciliation failed; inspect journalctl -u cybexos-reconcile and retry with sudo /usr/libexec/cybexos-reconcile --retry' + else: + state.pop('error', None) + (STATE / 'pending.json').unlink(missing_ok=True) + write(STATE / 'status.json', state) + return state + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + choice = parser.add_mutually_exclusive_group() + choice.add_argument('--queue', action='store_true', help='Record pending work only; safe in RPM posttrans') + choice.add_argument('--status', action='store_true', help='Print read-only JSON status') + choice.add_argument('--retry', action='store_true', help='Reset failed attempt budgets and retry now') + args = parser.parse_args() + if args.status: + print(json.dumps(status(), sort_keys=True)) + return + if os.geteuid() != 0: + parser.error('Run as root') + if args.queue: + write(STATE / 'pending.json', {'version': version()}) + return + if Path('/run/cybexos-live').exists(): + return + STATE.mkdir(parents=True, exist_ok=True, mode=0o755) + with (STATE / 'lock').open('a') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return + result = reconcile(args.retry) + if result['state'] in ('error', 'blocked'): + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/image/rootfs/usr/libexec/cybexos-update-channel b/image/rootfs/usr/libexec/cybexos-update-channel new file mode 100755 index 00000000..62a5fbc1 --- /dev/null +++ b/image/rootfs/usr/libexec/cybexos-update-channel @@ -0,0 +1,215 @@ +#!/usr/bin/python3 +"""Inspect or explicitly enroll an ISO installation in a signed desktop channel.""" +import argparse +import configparser +from datetime import datetime, timezone +import fcntl +import json +import os +from pathlib import Path +import platform +import subprocess +import sys +import tempfile +from urllib.parse import urlsplit +from urllib.request import urlopen + +sys.path.insert(0, '/usr/share/cybexos/lib') +from release_metadata import fingerprint, public_key, repository_url # noqa: E402 + + +REPO = 'etc/yum.repos.d/cybexos-desktop.repo' +KEY = 'etc/pki/rpm-gpg/CYBEXOS-desktop.asc' +CONFIG = 'etc/cybexos/update-channel.json' + + +def read_json(path): + value = json.loads(path.read_text()) + if not isinstance(value, dict): + raise ValueError('Channel configuration must be a JSON object') + return value + + +def status(root=Path('/')): + record = {'available': False, 'availableVersion': '', 'currentVersion': '', + 'managed': True, 'enabled': False, 'configured': False, + 'channel': 'rpm', 'status': 'desktop-channel-disabled', + 'note': 'CybexOS desktop updates are not configured. Fedora and application updates remain available.'} + if root == Path('/'): + result = subprocess.run(['rpm', '-q', 'cybexos-desktop'], capture_output=True, text=True, timeout=10) + if result.returncode == 0: + record['currentVersion'] = result.stdout.strip() + try: + parser = configparser.ConfigParser(interpolation=None) + with (root / REPO).open() as stream: + parser.read_file(stream) + repo = parser['cybexos-desktop'] + if not repo.getboolean('enabled', fallback=True): + return record + record['enabled'] = True + if not all(repo.getboolean(key, fallback=False) for key in ('gpgcheck', 'repo_gpgcheck')): + raise ValueError('Both package and repository signature verification must be enabled') + configured = root / CONFIG + settings = read_json(configured if configured.exists() else root / 'usr/share/cybexos/update-channel.json') + expected = fingerprint(settings.get('fingerprint', '')) + baseurl = repository_url(repo.get('baseurl', '')) + if baseurl != repository_url(settings.get('baseurl', '')): + raise ValueError('Repository URL differs from the enrolled channel') + key_url = urlsplit(repo.get('gpgkey', '')) + if key_url.scheme != 'file' or key_url.netloc or key_url.query or key_url.fragment: + raise ValueError('The enrolled public key must be a local file') + if key_url.path not in ('/' + KEY, '/usr/share/cybexos/update-key.asc'): + raise ValueError('The repository uses an unrecognized public key path') + public_key(root / key_url.path.lstrip('/'), expected) + record.update(configured=True, status='desktop-channel-ready', baseurl=baseurl, + fingerprint=expected, note='CybexOS desktop updates are delivered with system packages.') + except FileNotFoundError: + if record['enabled']: + record.update(status='desktop-channel-invalid', note='The enabled desktop channel is missing its configuration or public key.') + except (OSError, ValueError, KeyError, configparser.Error, subprocess.SubprocessError): + record.update(status='desktop-channel-invalid', note='The desktop update channel is invalid. Run cybex update-channel enroll with its reviewed configuration.') + return record + + +def resolved_url(url, root): + release = {} + for line in (root / 'etc/os-release').read_text().splitlines(): + key, sep, value = line.partition('=') + if sep: + release[key] = value.strip('"\'') + version = release.get('VERSION_ID', '') + if release.get('ID') != 'fedora' or not version.isdecimal() or platform.machine() != 'x86_64': + raise ValueError('Desktop channels require a supported Fedora x86_64 installation') + return url.replace('$releasever', version).replace('$basearch', 'x86_64') + + +def verify_repository(url, armor): + """Verify bounded metadata in an isolated keyring before changing trust.""" + with tempfile.TemporaryDirectory(prefix='cybexos-channel-') as directory: + work = Path(directory) + key = work / 'public.asc' + key.write_bytes(armor) + for name in ('repomd.xml', 'repomd.xml.asc'): + with urlopen(url + '/repodata/' + name, timeout=30) as response: + if urlsplit(response.url).scheme != 'https': + raise ValueError('Repository metadata redirected to an insecure URL') + content = response.read(4 * 1024 * 1024 + 1) + if len(content) > 4 * 1024 * 1024: + raise ValueError('Repository metadata exceeds the size limit') + (work / name).write_bytes(content) + gpg = ['gpg', '--no-options', '--homedir', str(work), '--batch', '--no-autostart'] + subprocess.run([*gpg, '--import', str(key)], check=True, capture_output=True, timeout=15) + subprocess.run([*gpg, '--verify', str(work / 'repomd.xml.asc'), str(work / 'repomd.xml')], + check=True, capture_output=True, timeout=15) + + +def atomic_write(path, content): + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_symlink() or (path.exists() and not path.is_file()): + raise ValueError(f'Refusing to replace a non-regular channel file: {path}') + fd, temporary = tempfile.mkstemp(prefix='.cybexos-channel-', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + os.fchmod(stream.fileno(), 0o644) + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + parent = os.open(path.parent, os.O_DIRECTORY) + try: + os.fsync(parent) + finally: + os.close(parent) + finally: + Path(temporary).unlink(missing_ok=True) + + +def enroll(config, expected, root=Path('/'), check=False): + expected = fingerprint(expected) + settings = read_json(config) + if set(settings) != {'baseurl', 'fingerprint', 'key_file'} or fingerprint(settings['fingerprint']) != expected: + raise ValueError('Configuration does not match the explicitly trusted fingerprint') + url = repository_url(settings['baseurl']) + armor = public_key(config.parent / settings['key_file'], expected) + # A broken or temporarily disabled repo does not discard previously pinned + # trust. Read the enrollment record independently of its readiness status. + for pinned in (root / CONFIG, root / 'usr/share/cybexos/update-channel.json'): + if pinned.is_file(): + recorded = read_json(pinned).get('fingerprint') + if recorded and fingerprint(recorded) != expected: + raise ValueError('Signing-key rotation requires a separate reviewed migration') + if recorded: + break + verify_repository(resolved_url(url, root), armor) + if check: + return {'checked': True, 'baseurl': url, 'fingerprint': expected} + repo = ('# Enrolled with cybex update-channel; local policy survives RPM updates.\n' + '[cybexos-desktop]\nname=CybexOS desktop updates\nenabled=1\n' + 'gpgcheck=1\nrepo_gpgcheck=1\nskip_if_unavailable=0\nmetadata_expire=6h\n' + f'baseurl={url}\ngpgkey=file:///{KEY}\n').encode() + values = {KEY: armor, CONFIG: (json.dumps({'enabled': True, 'baseurl': url, 'fingerprint': expected}, indent=2) + '\n').encode(), REPO: repo} + before = {} + for name in values: + path = root / name + if path.is_symlink() or (path.exists() and not path.is_file()): + raise ValueError('Refusing to replace a non-regular channel file') + before[name] = path.read_bytes() if path.exists() else None + if all(before[name] == value for name, value in values.items()): + return status(root) + stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ') + backup = root / 'var/lib/cybexos/backups' / ('update-channel-' + stamp) + backup.mkdir(parents=True, mode=0o700) + for name, content in before.items(): + if content is not None: + atomic_write(backup / name, content) + written = [] + try: + for name, content in values.items(): + # atomic_write can fail after rename (for example directory fsync). + # Include the in-flight destination in rollback before publishing. + written.append(name) + atomic_write(root / name, content) + except BaseException: + for name in reversed(written): + if before[name] is None: + (root / name).unlink(missing_ok=True) + else: + atomic_write(root / name, before[name]) + raise + return {**status(root), 'backup': str(backup)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + actions = parser.add_subparsers(dest='action', required=True) + state = actions.add_parser('status') + state.add_argument('--json', action='store_true') + enable = actions.add_parser('enroll') + enable.add_argument('configuration', type=Path) + enable.add_argument('--fingerprint', required=True, help='Independently reviewed complete signing fingerprint') + enable.add_argument('--check', action='store_true', help='Verify public key and signed repository without changing configuration') + args = parser.parse_args() + try: + if args.action == 'status': + value = status() + print(json.dumps(value) if args.json else value['note']) + return 0 + if not args.check and os.geteuid() != 0: + parser.error('Enrollment requires sudo; --check is available without root') + if args.check: + value = enroll(args.configuration.resolve(strict=True), args.fingerprint, check=True) + else: + lockdir = Path('/run/lock/cybexos') + lockdir.mkdir(mode=0o755, parents=True, exist_ok=True) + descriptor = os.open(lockdir / 'update-channel.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + value = enroll(args.configuration.resolve(strict=True), args.fingerprint) + print(json.dumps(value, indent=2)) + return 0 + except (OSError, ValueError, subprocess.SubprocessError) as error: + parser.exit(1, f'update-channel: {error}\n') + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/image/rootfs/usr/libexec/cybexos-user-init b/image/rootfs/usr/libexec/cybexos-user-init index 7a8015a0..3ecd5143 100755 --- a/image/rootfs/usr/libexec/cybexos-user-init +++ b/image/rootfs/usr/libexec/cybexos-user-init @@ -3,6 +3,7 @@ import argparse import fcntl import json +import importlib.util import os from pathlib import Path import shutil @@ -82,6 +83,46 @@ def write_status(path, status): Path(temporary).unlink(missing_ok=True) +def reconcile_defaults(home, vendor, state): + """Update owned fragments independently of the one-time application seed. + + Personal edits, symlinks and deletions survive; originals are backed up + before replacement. A failed publication leaves a recoverable ledger. + """ + manifest = vendor / "managed-defaults.json" + if not manifest.exists(): + return + spec = importlib.util.spec_from_file_location("cybexos_managed_files", vendor / "lib/managed_files.py") + managed = importlib.util.module_from_spec(spec) + spec.loader.exec_module(managed) + release = (vendor / "reconcile-version").read_text().strip() + status_path = state / "defaults-progress.json" + status = json.loads(status_path.read_text()) if status_path.exists() else {} + if status.get("version") == release and status.get("state") == "ready": + return + status = {"version": release, "state": "running", "preserved": []} + write_status(status_path, status) + try: + for relative in json.loads(manifest.read_text()): + path = Path(relative) + if path.is_absolute() or ".." in path.parts: + raise ValueError("Invalid managed default path") + source, target = vendor / "managed-seed" / path, home / path + if not directory(target.parent): + status["preserved"].append(relative) + continue + result = managed.manage(target, source.read_bytes(), state / "defaults/ownership.json", + mode=source.stat().st_mode & 0o777) + if result["preserved"]: + status["preserved"].append(relative) + status["state"] = "ready" + except BaseException: + status["state"] = "error" + write_status(status_path, status) + raise + write_status(status_path, status) + + def notify(message, urgency="normal"): if shutil.which("notify-send"): try: @@ -103,6 +144,7 @@ def initialize(home, vendor=Path("/usr/share/cybexos"), mode="all", notification descriptor = os.open(state / "seed.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) with os.fdopen(descriptor, "w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) + reconcile_defaults(home, vendor, state) essential = vendor / "essential-seed" if essential.is_dir(): seed_applications(essential, home) diff --git a/image/rootfs/usr/share/cybexos/welcome/Main.qml b/image/rootfs/usr/share/cybexos/welcome/Main.qml index fe4e7ca3..aa5bc78f 100644 --- a/image/rootfs/usr/share/cybexos/welcome/Main.qml +++ b/image/rootfs/usr/share/cybexos/welcome/Main.qml @@ -31,7 +31,7 @@ ApplicationWindow { width: Math.max(minimumWidth, Math.round(Screen.width * 0.5)) height: Math.max(minimumHeight, Math.round(Screen.height * 0.48)) minimumWidth: 760 - minimumHeight: 580 + minimumHeight: 660 visible: true title: "Welcome to CybexOS" color: "#0e0e10" @@ -290,7 +290,93 @@ ApplicationWindow { lineHeight: 1.35 } - Item { Layout.preferredHeight: 34 } + Item { Layout.preferredHeight: welcome.isLive ? 34 : 20 } + + Rectangle { + visible: !welcome.isLive + Layout.fillWidth: true + Layout.preferredHeight: setupRows.implicitHeight + 24 + radius: 12 + color: window.surface + ColumnLayout { + id: setupRows + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 7 + RowLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: welcome.networkState === "connected" ? "Network connected" + : welcome.networkState === "checking" ? "Checking network…" : "Network connection needed" + color: welcome.networkState === "connected" ? window.inkMid : window.accentText + font.pixelSize: 13 + } + Button { + visible: welcome.networkState === "offline" + text: "Open network settings" + flat: true + onClicked: welcome.openSettings("network") + } + } + RowLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: welcome.hardwareState === "completed" ? "Hardware setup complete" + : welcome.hardwareState === "skipped" ? "Hardware setup: no special device support needed" + : welcome.hardwareState === "pending" && welcome.hardwareNote.indexOf("reboot") >= 0 + ? "Camera update installed — reboot to finish validation" + : welcome.hardwareState === "running" ? "Hardware setup in progress" + : welcome.hardwareState === "failed" ? "Hardware setup needs another attempt" + : "Hardware setup pending" + color: welcome.hardwareState === "failed" || welcome.hardwareState === "pending" + ? window.accentText : window.inkMid + font.pixelSize: 13 + wrapMode: Text.WordWrap + } + Button { + visible: welcome.hardwareState === "failed" + text: "Retry hardware" + flat: true + onClicked: welcome.retryHardware() + } + } + RowLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: welcome.seedState === "ready" ? "Offline applications ready" + : welcome.seedState === "copying" ? "Preparing offline applications…" + : welcome.seedState === "error" ? "Offline application preparation interrupted" + : "Offline applications waiting to prepare" + color: welcome.seedState === "error" ? window.accentText : window.inkMid + font.pixelSize: 13 + } + Button { + visible: welcome.seedState === "error" + text: "Retry apps" + flat: true + onClicked: welcome.retryApps() + } + } + Text { + Layout.fillWidth: true + visible: welcome.reconcileState !== "ready" + text: welcome.reconcileState === "error" || welcome.reconcileState === "blocked" + ? "Desktop policy update needs attention. Run cybex doctor for details." + : "Applying desktop policy updates…" + color: welcome.reconcileState === "error" || welcome.reconcileState === "blocked" + ? window.accentText : window.inkMid + font.pixelSize: 13 + wrapMode: Text.WordWrap + } + } + } + + Item { Layout.preferredHeight: welcome.isLive ? 0 : 14 } // ---- installed: make it yours --------------------------------- ColumnLayout { diff --git a/image/test_build_tools.py b/image/test_build_tools.py index 0aefb58e..e2dfc4ba 100644 --- a/image/test_build_tools.py +++ b/image/test_build_tools.py @@ -1,12 +1,17 @@ """Source-only fixtures: no ISO filesystem, QEMU process or PXE server is used.""" import hashlib +import importlib.machinery +import importlib.util import json import os +import socket from pathlib import Path import tempfile +import tarfile from types import SimpleNamespace import unittest -from unittest.mock import patch +import urllib.error +from unittest.mock import Mock, patch from build_support import (builder_cloud_config, checksum_entries, deliver_artifacts, select_firmware, wait_for_builder_initialization) @@ -14,6 +19,35 @@ from pxe_publish import DEFAULT_CONTRACT, IVentoy, publish, staging_parent, validate_status +class SourceArchiveTests(unittest.TestCase): + def test_disposable_builder_receives_skill_license_and_provisioning_helper(self): + loader = importlib.machinery.SourceFileLoader('archive_build', str(Path(__file__).with_name('build'))) + spec = importlib.util.spec_from_loader(loader.name, loader) + builder = importlib.util.module_from_spec(spec) + loader.exec_module(builder) + from desktop_payload import prepare_session + from provision_payload import prepare_provision + import yaml + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + archive = root / 'source.tar.gz' + builder.source_archive(archive) + extracted = root / 'source' + with tarfile.open(archive) as stream: + stream.extractall(extracted, filter='data') + payload = root / 'payload' + inventory = yaml.safe_load((extracted / 'inventory/group_vars/all.yml').read_text()) + # Exercise the actual packager against only what crosses the VM + # boundary; testing against the full checkout hid missing inputs. + prepare_session(extracted, payload, inventory) + prepare_provision(extracted, payload) + self.assertTrue((extracted / 'LICENSE').is_file()) + self.assertTrue((payload / 'usr/share/cybexos/agent-skills/cybexos/SKILL.md').is_file()) + helper = payload / 'usr/share/cybexos/provision/scripts/manage-agent-skills' + self.assertTrue(helper.is_file()) + self.assertTrue(helper.stat().st_mode & 0o111) + + class BuilderInitializationTests(unittest.TestCase): def test_hostname_is_set_only_during_final_stage(self): config = builder_cloud_config('ssh-ed25519 synthetic-public-fixture') @@ -138,6 +172,74 @@ def test_missing_firmware_fails_with_package_guidance(self): class PublisherTests(unittest.TestCase): + def fixture_client(self, responses, timeout=180): + clock = [0] + methods = [] + responses = iter(responses) + def request(method): + methods.append(method) + clock[0] += 1 + response = next(responses) + if isinstance(response, Exception): + raise response + return response + def sleep(seconds): + clock[0] += seconds + client = IVentoy('http://127.0.0.1:26000/iventoy/json', DEFAULT_CONTRACT, + request=request, timeout=timeout, sleep=sleep, clock=lambda: clock[0]) + return client, methods, clock + + def test_accepted_refresh_retries_only_status_timeouts(self): + client, methods, _ = self.fixture_client([ + {'status': 'running'}, {'result': 'success'}, TimeoutError('timed out'), + urllib.error.URLError(TimeoutError('timed out')), {'status': 'refreshing'}, + {'status': 'running'}, [{'name': 'fixture.iso'}], + ]) + client.refresh('fixture.iso') + self.assertEqual(methods.count('refresh_img_list'), 1) + self.assertEqual(methods.count('query_status'), 5) + self.assertEqual(methods[-1], 'get_img_tree') + + def test_polling_uses_one_deadline_for_the_whole_refresh(self): + client, methods, clock = self.fixture_client([ + {'status': 'refreshing'}, {'status': 'running'}, {'result': 'success'}, + TimeoutError('timed out')], timeout=8) + with self.assertRaisesRegex(RuntimeError, 'deadline'): + client.refresh('fixture.iso') + self.assertEqual(clock[0], 8) + self.assertEqual(methods, ['query_status', 'query_status', 'refresh_img_list', 'query_status']) + + def test_ambiguous_refresh_timeout_is_never_repeated(self): + client, methods, _ = self.fixture_client([{'status': 'running'}, TimeoutError('timed out')]) + with self.assertRaises(TimeoutError): + client.refresh('fixture.iso') + self.assertEqual(methods, ['query_status', 'refresh_img_list']) + + def test_status_json_and_other_transport_errors_are_not_retried(self): + for error in (json.JSONDecodeError('bad JSON', '!', 0), + urllib.error.URLError(ConnectionRefusedError('refused'))): + with self.subTest(error=type(error).__name__): + client, methods, _ = self.fixture_client([ + {'status': 'running'}, {'result': 'success'}, error]) + with self.assertRaises(type(error)): + client.refresh('fixture.iso') + self.assertEqual(methods, ['query_status', 'refresh_img_list', 'query_status']) + + def test_timeout_recovery_still_requires_running_pxe_and_expected_filename(self): + for remaining, message in (([{'status': 'stopped'}], 'not running'), + ([{'status': 'running'}, [{'name': 'other.iso'}]], 'published filename')): + client, _, _ = self.fixture_client([ + {'status': 'running'}, {'result': 'success'}, TimeoutError('timed out'), *remaining]) + with self.assertRaisesRegex(RuntimeError, message): + client.refresh('fixture.iso') + + def test_http_request_timeout_cannot_exceed_remaining_deadline(self): + client = IVentoy('http://127.0.0.1:26000/iventoy/json', DEFAULT_CONTRACT, clock=lambda: 5) + with patch('pxe_publish.urllib.request.urlopen', side_effect=TimeoutError) as request: + with self.assertRaises(TimeoutError): + client._query('query_status', 8) + self.assertEqual(request.call_args.kwargs['timeout'], 3) + def test_refresh_polls_known_iventoy_contract_and_verifies_tree(self): responses = iter([{'status': 'running'}, {'result': 'success'}, {'status': 'refreshing'}, {'status': 'running'}, [{'name': 'fixture.iso'}]]) methods = [] @@ -208,6 +310,91 @@ def test_staging_inside_served_tree_is_rejected(self): class QualificationSourceTests(unittest.TestCase): + def test_long_artifact_paths_use_bindable_private_qmp_runtime_across_boots(self): + from vm_testing import TestVM, poweroff_guest + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / ('a' * 80) / ('b' * 80) + root.mkdir(parents=True) + vm = TestVM(root, firmware='bios') + vm.owned = True + sockets = [] + def launch(arguments, **kwargs): + option = arguments[arguments.index('-qmp') + 1] + path = option.removeprefix('unix:').split(',')[0] + connection = socket.socket(socket.AF_UNIX) + connection.bind(path) # Real kernel pathname-length check. + sockets.append(connection) + process = Mock(pid=12345) + process.poll.return_value = None + def finish(*args, **kwargs): + connection.close() + process.poll.return_value = 0 + return 0 + process.wait.side_effect = finish + process.terminate.side_effect = finish + return process + try: + with patch('vm_testing.subprocess.Popen', side_effect=launch), \ + patch.dict(os.environ, {'TMPDIR': str(root)}): + paths = [] + for poweroff in (True, False): + vm.start() + runtime = vm.runtime + paths.append(runtime) + self.assertEqual(runtime.stat().st_mode & 0o777, 0o700) + self.assertLess(len(os.fsencode(vm.qmp_path)), 108) + self.assertEqual(json.loads((root / 'vm.json').read_text())['qmp'], str(vm.qmp_path)) + if poweroff: + poweroff_guest(vm, 'fixture-password', Mock()) + else: + vm.cleanup(keep_artifacts=True) + self.assertFalse(runtime.exists()) + self.assertIsNone(vm.runtime) + self.assertNotEqual(*paths) + self.assertTrue((root / 'qemu.log').exists(), 'Requested diagnostic outputs stay retained') + finally: + vm.stop() + for connection in sockets: + connection.close() + + def test_failed_vm_launch_cleans_private_qmp_runtime(self): + from vm_testing import TestVM + with tempfile.TemporaryDirectory() as temporary: + vm = TestVM(temporary, firmware='bios') + runtimes = [] + def fail(*args, **kwargs): + runtimes.append(vm.runtime) + raise OSError('fixture launch failure') + with patch('vm_testing.subprocess.Popen', side_effect=fail): + with self.assertRaisesRegex(OSError, 'fixture launch failure'): + vm.start() + self.assertFalse(runtimes[0].exists()) + self.assertIsNone(vm.runtime) + self.assertIsNone(vm.console) + + def test_state_publication_failure_stops_guest_before_removing_runtime(self): + from vm_testing import TestVM + with tempfile.TemporaryDirectory() as temporary: + vm = TestVM(temporary, firmware='bios') + process = Mock(pid=12345) + process.poll.return_value = None + runtimes = [] + def terminate(): + self.assertTrue(vm.runtime.is_dir()) + process.poll.return_value = 0 + process.terminate.side_effect = terminate + process.wait.return_value = 0 + def fail(*args): + runtimes.append(vm.runtime) + raise OSError('fixture state publication failure') + with patch('vm_testing.subprocess.Popen', return_value=process), \ + patch('vm_testing.atomic_json', side_effect=fail): + with self.assertRaisesRegex(OSError, 'fixture state publication failure'): + vm.start() + process.terminate.assert_called_once() + self.assertFalse(runtimes[0].exists()) + self.assertIsNone(vm.runtime) + def test_installation_requires_both_explicit_execution_flags(self): import qualification for flags in ([], ['--execute-vm'], ['--erase-disposable-disk']): diff --git a/image/test_console_bootstrap.py b/image/test_console_bootstrap.py new file mode 100644 index 00000000..e023b32a --- /dev/null +++ b/image/test_console_bootstrap.py @@ -0,0 +1,103 @@ +"""Mocked installed-console transport: no VM or host session is touched.""" +import tempfile +import unittest +from unittest.mock import patch + +from vm_testing import TestVM, console_output, sudo_password_prompt + + +class ConsoleBootstrapTests(unittest.TestCase): + def exercise(self, *, passworded=False, german=False, failure=None, + sudo_prompt='[sudo] password for qualification:'): + password = 'private-fixture-secret' + state = {'phase': 'login', 'time': 0, 'typed': []} + def clock(): + state['time'] += 0.1 + return state['time'] + def sleep(seconds): + state['time'] += seconds + def typing(text): + state['typed'].append(text) + if text == 'qualification\n': + state['phase'] = 'password' + elif text == password + '\n': + state['phase'] = 'shell' if state['phase'] == 'password' else 'authorized' + elif text.startswith('echo CONSOLEWORKS'): + state['phase'] = 'probe' + elif text == 'clear\n': + state['phase'] = 'shell' + elif text.startswith('sudo echo'): + state['phase'] = 'sudo' if passworded else 'authorized' + elif text.startswith('HISTFILE=') and 'CONSOLE%sREADY' in text: + state['phase'] = 'bash' + def screen(): + phase = state['phase'] + return { + 'login': 'fedora login:', 'password': 'Password:', + 'shell': 'a custom Fish prompt without user or host', + 'probe': ('custom prompt echo CONSOLEWORKS y' if failure == 'shell' else + 'custom prompt echo CONSOLEWORKS y\nCONSOLE WORKS ' + ('z' if german else 'y')), + 'sudo': sudo_prompt, + 'authorized': 'CONSOLE AUTH', + 'bash': ('echo ' + password if failure == 'bash' else + "bash-5.3$ printf 'CONSOLE%sREADY' BASH\nCONSOLEBASHREADY"), + }[phase] + with tempfile.TemporaryDirectory() as directory: + vm = TestVM(directory) + vm.key.with_suffix('.pub').write_text('ssh-ed25519 public-fixture') + with patch.object(vm, 'keypress'), patch.object(vm, 'alive'), \ + patch.object(vm, 'type', side_effect=typing), \ + patch.object(vm, 'screen_text', side_effect=screen), \ + patch.object(vm, 'wait_ssh') as ssh, \ + patch('vm_testing.time.monotonic', side_effect=clock), \ + patch('vm_testing.time.sleep', side_effect=sleep): + if failure: + with self.assertRaises(RuntimeError) as error: + vm.bootstrap_installed_ssh(password, timeout=50) + self.assertNotIn(password, str(error.exception)) + ssh.assert_not_called() + else: + vm.bootstrap_installed_ssh(password, timeout=50) + ssh.assert_called_once() + return state['typed'] + + def test_passwordless_fish_prompt_and_german_keymap(self): + typed = self.exercise(german=True) + self.assertEqual(typed.count('private-fixture-secret\n'), 1) + self.assertIn('sudo loadkezs us\n', typed) + self.assertIn('exec env HISTFILE=/dev/null bash --noprofile --norc\n', typed) + self.assertTrue(any('authorized_keys' in command for command in typed)) + + def test_passworded_sudo_authenticates_once(self): + typed = self.exercise(passworded=True) + self.assertEqual(typed.count('private-fixture-secret\n'), 2) + self.assertIn('sudo loadkeys us\n', typed) + + def test_dutch_sudo_ocr_misread_authenticates_once(self): + typed = self.exercise(passworded=True, + sudo_prompt='[sudo] uachtwoord voor qualification:') + self.assertEqual(typed.count('private-fixture-secret\n'), 2) + self.assertIn('sudo loadkeys us\n', typed) + + def test_echoed_command_alone_never_proves_shell(self): + typed = self.exercise(failure='shell') + self.assertEqual(typed.count('echo CONSOLEWORKS y\n'), 3) + self.assertFalse(any(command.startswith('sudo') for command in typed)) + + def test_failed_bash_confirmation_never_sends_setup(self): + typed = self.exercise(failure='bash') + self.assertFalse(any('authorized_keys' in command for command in typed)) + + def test_output_only_and_explicit_sudo_prompts(self): + self.assertFalse(console_output('prompt sudo echo CONSOLEAUTH', 'CONSOLEAUTH')) + self.assertTrue(console_output('CONSOLE AUTH', 'CONSOLEAUTH')) + for prompt in ('[sudo] password for qualification:', 'Passwort für qualification:', + 'wachtwoord voor qualification:', '[sudo] uachtwoord voor qualification:'): + self.assertTrue(sudo_password_prompt(prompt)) + for prompt in ('Password:', 'password for john:', 'qualification login:', + 'uachtwoord voor qualification:', '[sudo] uachtwoord voor john:'): + self.assertFalse(sudo_password_prompt(prompt)) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_doctor.py b/image/test_doctor.py new file mode 100644 index 00000000..5d02cb8d --- /dev/null +++ b/image/test_doctor.py @@ -0,0 +1,108 @@ +"""Fixture checks for the installed doctor; no live services or capture devices.""" +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +SCRIPT = Path(__file__).parent / 'rootfs/usr/libexec/cybexos-doctor' +loader = importlib.machinery.SourceFileLoader('cybexos_doctor', str(SCRIPT)) +spec = importlib.util.spec_from_loader(loader.name, loader) +doctor = importlib.util.module_from_spec(spec) +loader.exec_module(doctor) + + +class DoctorTests(unittest.TestCase): + def fixture(self, root, home): + def write(path, value): + target = root / path.lstrip('/') + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(value)) + write('/usr/share/cybexos/build.json', {'source_revision': 'a' * 40}) + write('/etc/cybexos/hardware.json', {'xps_2026': True}) + write('/var/lib/cybexos/hardware-status.json', {'state': 'pending', 'reason': 'reboot required for camera validation'}) + for path in ('/etc/grub.d/42_cybexos_recovery', + '/usr/lib/systemd/system/cybexos-recovery-refresh.service'): + target = root / path.lstrip('/') + target.parent.mkdir(parents=True, exist_ok=True) + target.touch() + state = home / '.local/state/cybexos' + state.mkdir(parents=True) + (state / 'seed-progress.json').write_text('{"state":"copying"}') + + def command(self, *argv): + if argv[0] == 'rpm': + return 0, 'cybexos-desktop-1.0' + if argv[0].endswith('cybexos-update-channel'): + return 0, '{"status":"desktop-channel-disabled"}' + if argv[0].endswith('cybexos-reconcile'): + return 0, '{"state":"ready"}' + if argv[:3] == ('systemctl', '--user', 'is-active'): + return 0, 'active' + if argv[:2] == ('systemctl', 'is-active'): + return 0, 'active' + if argv[:2] == ('systemctl', 'is-enabled'): + return 0, 'enabled' + if argv[0] == 'nmcli': + return 0, 'connected' + return 0, '' + + def test_pending_reboot_and_seed_are_explicit_warnings(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / 'root' + home = Path(temporary) / 'home' + self.fixture(root, home) + result = doctor.collect(root, home, self.command) + checks = {entry['name']: entry for entry in result['checks']} + self.assertEqual(result['overall'], 'warning') + self.assertEqual(checks['hardware']['state'], 'pending') + self.assertIn('Reboot', checks['hardware']['action']) + self.assertEqual(checks['seeded_apps']['state'], 'pending') + self.assertEqual(checks['desktop_channel']['state'], 'warning') + + def test_malformed_helper_json_is_reported_and_retry_uses_installed_path(self): + with tempfile.TemporaryDirectory() as temporary: + root, home = Path(temporary) / 'root', Path(temporary) / 'home' + self.fixture(root, home) + def command(*argv): + if argv[0].endswith('cybexos-update-channel'): + return 0, '[]' + if argv[0].endswith('cybexos-reconcile'): + return 0, '{"state":"blocked"}' + return self.command(*argv) + checks = {entry['name']: entry for entry in doctor.collect(root, home, command)['checks']} + self.assertEqual(checks['desktop_channel']['state'], 'fail') + self.assertIn('/usr/libexec/cybexos-reconcile --retry', checks['reconciliation']['action']) + def malformed(*argv): + if argv[0].endswith('cybexos-reconcile'): + return 0, '[]' + return self.command(*argv) + checks = {entry['name']: entry for entry in doctor.collect(root, home, malformed)['checks']} + self.assertEqual(checks['reconciliation']['state'], 'warning') + + def test_missing_package_failure_and_qml_error_are_reported_without_journal_text(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / 'root' + home = Path(temporary) / 'home' + self.fixture(root, home) + secret = 'private-widget-value' + def command(*argv): + if argv[0] == 'rpm': + return 1, '' + if argv[0] == 'journalctl': + return 0, 'QML error ' + secret + if argv[:2] == ('systemctl', '--failed'): + return 0, 'bad.service loaded failed failed' + return self.command(*argv) + result = doctor.collect(root, home, command) + checks = {entry['name']: entry for entry in result['checks']} + self.assertEqual(result['overall'], 'fail') + self.assertEqual(checks['build']['state'], 'fail') + self.assertEqual(checks['failed_units']['state'], 'fail') + self.assertEqual(checks['qml']['state'], 'warning') + self.assertNotIn(secret, json.dumps(result)) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_github_release.py b/image/test_github_release.py new file mode 100644 index 00000000..3b111183 --- /dev/null +++ b/image/test_github_release.py @@ -0,0 +1,246 @@ +"""Offline release staging gates, metadata integrity and ISO reconstruction.""" +import gzip +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import github_release as release + +loader = importlib.machinery.SourceFileLoader('reconstruct_iso', str(Path(__file__).with_name('reconstruct-iso'))) +spec = importlib.util.spec_from_loader(loader.name, loader) +reconstruct = importlib.util.module_from_spec(spec) +loader.exec_module(reconstruct) +FINGERPRINT = 'A' * 40 +REPOSITORY = 'DigitalPals/CybexOS' +TAG = 'v1.2.3' +BASE = 'https://digitalpals.github.io/CybexOS/44/x86_64' + + +def checksums(root): + paths = sorted(path for path in root.rglob('*') if path.is_file() and path.name != 'SHA256SUMS') + (root / 'SHA256SUMS').write_text(''.join(f'{release.sha256(path)} {path.relative_to(root)}\n' for path in paths)) + + +class GitHubRelease(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.signed, self.artifacts = self.root / 'signed', self.root / 'artifacts' + self.output = self.root / 'result' + (self.signed / 'Packages').mkdir(parents=True) + (self.signed / 'repodata').mkdir() + self.artifacts.mkdir() + self.rpm = self.signed / 'Packages/cybexos-desktop.rpm' + self.rpm.write_bytes(b'fixture signed RPM bytes') + self.iso = self.artifacts / 'CybexOS-fixture.iso' + self.iso.write_bytes(b'fixture ISO image bytes') + checksums(self.artifacts) + self.package = {'name': 'cybexos-desktop', 'epoch': '1', 'version': '1.2.3', + 'release': '1.fc44', 'arch': 'x86_64', + 'file': 'Packages/' + self.rpm.name, + 'sha256': release.sha256(self.rpm), 'unsigned_input_sha256': 'b' * 64, + 'url': release.github_url(REPOSITORY, TAG) + '/' + self.rpm.name} + self.manifest = {'format': 1, 'fingerprint': FINGERPRINT, 'baseurl': BASE, + 'packages': [self.package]} + (self.signed / 'release.json.asc').write_text('fixture detached signature') + (self.signed / 'CYBEXOS-desktop.asc').write_text('fixture public key') + (self.signed / 'update-channel.json').write_text(json.dumps({ + 'baseurl': BASE, 'fingerprint': FINGERPRINT, 'key_file': 'CYBEXOS-desktop.asc'})) + self.metadata() + self.reports = [] + for scenario in ('encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl'): + self.report(scenario, release.sha256(self.iso), ['graphical-installer']) + self.report('upgrade', 'c' * 64, ['installed-rpm-upgrade', 'recovery-boot-restore'], 'b' * 64) + + def metadata(self, package_url=None): + package = self.package + primary = ('' + 'cybexos-desktopx86_64' + '' + f'{package["sha256"]}' + f'') + metadata = self.signed / 'repodata/primary.xml.gz' + metadata.write_bytes(gzip.compress(primary.encode())) + repomd = ('' + f'{release.sha256(metadata)}' + '') + (self.signed / 'repodata/repomd.xml').write_text(repomd) + (self.signed / 'repodata/repomd.xml.asc').write_text('fixture detached signature') + self.save_manifest() + + def save_manifest(self): + (self.signed / 'release.json').write_text(json.dumps(self.manifest)) + checksums(self.signed) + + def report(self, scenario, iso, checks, rpm=None): + path = self.root / (scenario + '.json') + path.write_text(json.dumps({'scenario': scenario, 'iso_sha256': iso, + 'status': 'passed', 'checks': checks, + 'candidate_rpm_sha256': rpm})) + self.reports.append(path) + return path + + def prepare(self): + # These fixtures are deliberately not signed releases or VM evidence. + # Other tests cover the fail-closed signature subprocess boundary. + with patch.object(release, 'verify_release') as signatures, patch.object(release, 'verify_signed_rpm') as rpm: + result = release.prepare(self.signed, self.artifacts, self.output, + REPOSITORY, TAG, FINGERPRINT, self.reports) + self.assertEqual(signatures.call_count, 2) + rpm.assert_called_once() + return result + + def test_stages_qualified_release_and_metadata_without_rpm_on_pages(self): + self.assertEqual(self.prepare(), self.output) + self.assertTrue((self.output / 'assets' / self.rpm.name).is_file()) + self.assertFalse(list((self.output / 'pages').rglob('*.rpm'))) + self.assertTrue((self.output / 'assets/reconstruct-iso.py').is_file()) + self.assertEqual(len(json.loads((self.output / 'assets/qualification-reports.json').read_text())), 5) + release.verify_checksums(self.output / 'assets', 'desktop-SHA256SUMS') + release.verify_checksums(self.output / 'pages/44/x86_64') + + def test_checksum_inventory_cannot_hide_unlisted_files_or_symlinks(self): + extra = self.signed / 'repodata/unlisted' + extra.write_text('unlisted') + with self.assertRaisesRegex(ValueError, 'inventory'): + self.prepare() + extra.unlink() + extra.symlink_to(self.iso) + with self.assertRaisesRegex(ValueError, 'regular'): + self.prepare() + self.assertFalse(self.output.exists()) + + def test_checksum_path_escape_is_rejected(self): + (self.signed / 'SHA256SUMS').write_text(f'{release.sha256(self.iso)} ../artifacts/{self.iso.name}\n') + with self.assertRaisesRegex(ValueError, 'Unsafe'): + self.prepare() + + def test_invalid_signature_never_publishes(self): + with patch.object(release, 'verify_release', side_effect=subprocess.CalledProcessError(1, ['gpg'])): + with self.assertRaises(subprocess.CalledProcessError): + release.prepare(self.signed, self.artifacts, self.output, REPOSITORY, TAG, FINGERPRINT, self.reports) + self.assertFalse(self.output.exists()) + self.assertFalse(list(self.root.glob('.cybexos-github-*'))) + + def test_asset_limit_is_enforced_before_signatures_or_copying(self): + with patch.object(release, 'ASSET_LIMIT', self.rpm.stat().st_size): + with self.assertRaisesRegex(ValueError, '2 GiB'): + self.prepare() + self.assertFalse(self.output.exists()) + + def test_release_tag_and_pages_channel_must_match(self): + self.package['version'] = '1.2.4' + self.save_manifest() + with self.assertRaisesRegex(ValueError, 'release tag'): + self.prepare() + self.package['version'] = '1.2.3' + self.manifest['baseurl'] = 'https://unrelated.example/repo' + self.save_manifest() + with self.assertRaisesRegex(ValueError, 'GitHub Pages'): + self.prepare() + + def test_authenticated_metadata_must_reference_the_exact_uploaded_rpm(self): + self.metadata(package_url='https://unrelated.example/package.rpm') + with self.assertRaisesRegex(ValueError, 'package URL'): + self.prepare() + self.assertFalse(self.output.exists()) + + def test_repodata_hash_is_verified_independently_of_checksum_inventory(self): + (self.signed / 'repodata/primary.xml.gz').write_bytes(b'corrupt metadata') + checksums(self.signed) + with self.assertRaisesRegex(ValueError, 'metadata checksum'): + self.prepare() + + def test_missing_fresh_scenario_and_wrong_iso_are_blocked(self): + self.reports.pop(0) + with self.assertRaisesRegex(ValueError, 'four fresh'): + self.prepare() + self.report('encrypted-us', 'd' * 64, ['graphical-installer']) + with self.assertRaisesRegex(ValueError, 'four fresh'): + self.prepare() + + def test_upgrade_requires_different_iso_exact_candidate_and_recovery(self): + upgrade = self.reports[-1] + base = json.loads(upgrade.read_text()) + for changes in ({'iso_sha256': release.sha256(self.iso)}, + {'candidate_rpm_sha256': 'd' * 64}, + {'checks': ['installed-rpm-upgrade']}): + upgrade.write_text(json.dumps({**base, **changes})) + with self.assertRaisesRegex(ValueError, 'prior ISO'): + self.prepare() + upgrade.write_text(json.dumps({**base, 'status': 'failed'})) + with self.assertRaisesRegex(ValueError, 'must have passed'): + self.prepare() + + def test_signed_verification_uses_the_expected_key_and_both_signatures(self): + work = self.root / 'verify' + work.mkdir() + with patch.object(release, 'public_key') as key, patch.object(release.subprocess, 'run') as run: + release.verify_release(self.signed, FINGERPRINT, work) + key.assert_called_once_with(self.signed / 'CYBEXOS-desktop.asc', FINGERPRINT) + self.assertEqual(len(run.call_args_list), 3) + signatures = [call.args[0] for call in run.call_args_list[1:]] + self.assertTrue(all('--verify' in command for command in signatures)) + self.assertIn(str(self.signed / 'release.json.asc'), signatures[0]) + self.assertIn(str(self.signed / 'repodata/repomd.xml.asc'), signatures[1]) + + +class IsoReconstruction(unittest.TestCase): + def test_chunks_reconstruct_exact_bytes_and_preserve_existing_output(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, assets = root / 'CybexOS.iso', root / 'assets' + assets.mkdir() + source.write_bytes(bytes(range(256)) * 17) + record = release.split_iso(source, assets, chunk_size=127) + self.assertTrue(all(part['bytes'] <= 127 for part in record['parts'])) + manifest = assets / 'CybexOS.iso.parts.json' + result = reconstruct.reconstruct(manifest) + self.assertEqual(result.read_bytes(), source.read_bytes()) + self.assertEqual(release.sha256(result), record['sha256']) + with self.assertRaisesRegex(ValueError, 'already exists'): + reconstruct.reconstruct(manifest) + self.assertFalse(list(assets.glob('.cybexos-iso-*'))) + + def test_corruption_missing_parts_and_path_traversal_leave_no_partial_iso(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source, assets = root / 'CybexOS.iso', root / 'assets' + assets.mkdir() + source.write_bytes(b'123456789') + release.split_iso(source, assets, chunk_size=3) + manifest = assets / 'CybexOS.iso.parts.json' + record = json.loads(manifest.read_text()) + part = assets / record['parts'][0]['file'] + part.write_bytes(b'bad') + with self.assertRaisesRegex(ValueError, 'checksum'): + reconstruct.reconstruct(manifest) + part.unlink() + with self.assertRaisesRegex(ValueError, 'regular'): + reconstruct.reconstruct(manifest) + record['parts'][0]['file'] = '../outside' + manifest.write_text(json.dumps(record)) + with self.assertRaisesRegex(ValueError, 'Invalid'): + reconstruct.reconstruct(manifest) + self.assertFalse((assets / 'CybexOS.iso').exists()) + self.assertFalse(list(assets.glob('.cybexos-iso-*'))) + + def test_invalid_chunk_sizes_fail_before_creating_parts(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / 'CybexOS.iso' + source.write_bytes(b'image') + for size in (0, -1, release.ASSET_LIMIT): + with self.assertRaisesRegex(ValueError, 'part size'): + release.split_iso(source, root, size) + self.assertEqual(list(root.iterdir()), [source]) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_installed_policy.py b/image/test_installed_policy.py index 182a6856..17cedbf3 100644 --- a/image/test_installed_policy.py +++ b/image/test_installed_policy.py @@ -39,13 +39,19 @@ def test_automatic_hardware_checks_retry_after_a_kernel_change(self): provision = root / 'provision' provision.mkdir() (provision / 'policy').write_text('fixture') + (root / 'run/lock').mkdir(parents=True) def paths(value): return root / value.lstrip('/') if value.startswith(('/var/', '/run/')) else Path(value) - (root / 'run/lock').mkdir(parents=True) kernel = types.SimpleNamespace(release='kernel-one') + boot = ['boot-one'] + def applied(*_args): + result = root / 'var/lib/cybexos/hardware-result.json' + result.parent.mkdir(parents=True, exist_ok=True) + result.write_text('{"reboot_required": false, "camera_ready": true}') with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ patch.object(configure.os, 'geteuid', return_value=0), patch.object(configure.os, 'uname', return_value=kernel), \ - patch.object(configure.pwd, 'getpwall', return_value=[account]), patch.object(configure, 'configure') as apply, \ + patch.object(configure, 'boot_id', side_effect=lambda: boot[0]), \ + patch.object(configure.pwd, 'getpwall', return_value=[account]), patch.object(configure, 'configure', side_effect=applied) as apply, \ patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): configure.main() configure.main() @@ -60,6 +66,102 @@ def paths(value): self.assertEqual(apply.call_count, 2) self.assertFalse(marker.with_name('hardware-unsupported').exists()) + def test_pending_camera_resumes_once_after_same_kernel_reboot(self): + import pwd + import types + account = pwd.struct_passwd(('john', 'x', 1000, 1000, '', '/home/john', '/usr/bin/fish')) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + provision = root / 'provision' + provision.mkdir() + (provision / 'policy').write_text('fixture') + (root / 'run/lock').mkdir(parents=True) + def paths(value): + return root / value.lstrip('/') if value.startswith(('/var/', '/run/')) else Path(value) + boot = ['boot-one'] + outcomes = [True, False] + def applied(*_args): + result = root / 'var/lib/cybexos/hardware-result.json' + result.parent.mkdir(parents=True, exist_ok=True) + result.write_text(json.dumps({'reboot_required': outcomes.pop(0), 'camera_ready': not outcomes})) + with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ + patch.object(configure.os, 'geteuid', return_value=0), \ + patch.object(configure.os, 'uname', return_value=types.SimpleNamespace(release='same-kernel')), \ + patch.object(configure, 'boot_id', side_effect=lambda: boot[0]), \ + patch.object(configure.pwd, 'getpwall', return_value=[account]), \ + patch.object(configure, 'configure', side_effect=applied) as apply, \ + patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): + configure.main() + status = json.loads((root / 'var/lib/cybexos/hardware-status.json').read_text()) + self.assertEqual(status['state'], 'pending') + self.assertFalse((root / 'var/lib/cybexos/hardware-configured').exists()) + configure.main() + self.assertEqual(apply.call_count, 1) + boot[0] = 'boot-two' + configure.main() + self.assertEqual(apply.call_count, 2) + configure.main() + self.assertEqual(apply.call_count, 2) + self.assertEqual(json.loads((root / 'var/lib/cybexos/hardware-status.json').read_text())['state'], 'completed') + + def test_missing_hardware_result_is_failure_not_completion(self): + import pwd + import types + account = pwd.struct_passwd(('john', 'x', 1000, 1000, '', '/home/john', '/usr/bin/fish')) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + provision = root / 'provision' + provision.mkdir() + (root / 'run/lock').mkdir(parents=True) + def paths(value): + return root / value.lstrip('/') if value.startswith(('/var/', '/run/')) else Path(value) + with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ + patch.object(configure.os, 'geteuid', return_value=0), \ + patch.object(configure.os, 'uname', return_value=types.SimpleNamespace(release='kernel')), \ + patch.object(configure, 'boot_id', return_value='boot'), \ + patch.object(configure.pwd, 'getpwall', return_value=[account]), \ + patch.object(configure, 'configure'), \ + patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): + with self.assertRaisesRegex(SystemExit, 'did not report an outcome'): + configure.main() + self.assertEqual(json.loads((root / 'var/lib/cybexos/hardware-status.json').read_text())['state'], 'failed') + self.assertFalse((root / 'var/lib/cybexos/hardware-configured').exists()) + + def test_legacy_completion_marker_is_validated_once_and_config_changes_invalidate_it(self): + import pwd + import types + account = pwd.struct_passwd(('john', 'x', 1000, 1000, '', '/home/john', '/usr/bin/fish')) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + provision = root / 'provision' + provision.mkdir() + (root / 'run/lock').mkdir(parents=True) + saved = root / 'etc/cybexos/config.yml' + saved.parent.mkdir(parents=True) + saved.write_text('xps_2026_camera_enabled: true\n') + def paths(value): + return root / value.lstrip('/') if value.startswith(('/var/', '/run/', '/etc/')) else Path(value) + def applied(*_args): + result = root / 'var/lib/cybexos/hardware-result.json' + result.parent.mkdir(parents=True, exist_ok=True) + result.write_text('{"reboot_required":false,"camera_ready":true}') + with patch.object(configure, 'PROVISION', provision), patch.object(configure, 'Path', side_effect=paths), \ + patch.object(configure.os, 'geteuid', return_value=0), \ + patch.object(configure.os, 'uname', return_value=types.SimpleNamespace(release='same-kernel')), \ + patch.object(configure, 'boot_id', return_value='same-boot'), \ + patch.object(configure.pwd, 'getpwall', return_value=[account]), \ + patch.object(configure, 'configure', side_effect=applied) as apply, \ + patch('sys.argv', ['configure-installed', '--hardware', '--automatic']): + configure.main() + (root / 'var/lib/cybexos/hardware-status.json').unlink() + configure.main() + self.assertEqual(apply.call_count, 2, 'Legacy marker alone cannot prove completion') + configure.main() + self.assertEqual(apply.call_count, 2) + saved.write_text('xps_2026_camera_enabled: false\n') + configure.main() + self.assertEqual(apply.call_count, 3, 'Saved hardware choices invalidate prior completion') + def test_camera_backup_and_restore_preserve_a_dangling_vendor_symlink(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -92,7 +194,10 @@ def test_session_target_can_start_services_ordered_after_graphical_session(self) 'hermes-menubar-bridge', 'cybexos-welcome', 'cybexos-app-seed'): (units / (name + '.service')).write_text( '[Unit]\nAfter=graphical-session.target\n[Service]\nExecStart=/usr/bin/true\n') - environment = {**os.environ, 'SYSTEMD_UNIT_PATH': str(units) + ':/usr/lib/systemd/user'} + runtime = units / 'runtime' + runtime.mkdir(mode=0o700) + environment = {**os.environ, 'SYSTEMD_UNIT_PATH': str(units) + ':/usr/lib/systemd/user', + 'XDG_RUNTIME_DIR': str(runtime)} def verify(): return subprocess.run(['systemd-analyze', '--user', 'verify', str(units / target.name)], env=environment, text=True, capture_output=True) @@ -166,6 +271,7 @@ def flatten(name): image = {task['name']: task for task in flatten('image.yml')} workstation = {task['name']: task for task in flatten('main.yml')} for name in ('Enable user lingering', 'Enable user lingering in the offline installation target', + 'Create the offline user lingering directory', 'Configure active local wheel Polkit authorization', 'Revoke passwordless local Polkit authorization when disabled', 'Enable Docker socket activation when requested', 'Start Docker on demand rather than at boot', @@ -176,6 +282,9 @@ def flatten(name): online = image['Enable user lingering'] self.assertIn('loginctl enable-linger', online['ansible.builtin.command']) self.assertIn('not cybexos_offline', online['when']) + directory = image['Create the offline user lingering directory']['ansible.builtin.file'] + self.assertEqual((directory['path'], directory['state'], directory['mode']), + ('/var/lib/systemd/linger', 'directory', '0755')) offline = image['Enable user lingering in the offline installation target']['ansible.builtin.copy'] self.assertEqual((offline['dest'], offline['owner'], offline['mode']), ('/var/lib/systemd/linger/{{ primary_user }}', 'root', '0644')) @@ -202,6 +311,35 @@ def flatten(name): self.assertFalse(any('select' in task.get('ansible.builtin.command', {}).get('argv', []) for task in image.values() if isinstance(task.get('ansible.builtin.command'), dict))) + def test_offline_linger_marker_is_created_when_parent_directory_is_absent(self): + tasks = yaml.safe_load((ROOT / 'roles/base/tasks/accounts.yml').read_text()) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + linger = root / 'var/lib/systemd/linger' + selected = [dict(task) for task in tasks if task['name'] in ( + 'Create the offline user lingering directory', + 'Enable user lingering in the offline installation target')] + self.assertEqual(len(selected), 2) + self.assertEqual(selected[0]['name'], 'Create the offline user lingering directory') + selected[0]['ansible.builtin.file'] = { + **selected[0]['ansible.builtin.file'], 'path': str(linger)} + selected[1]['ansible.builtin.copy'] = { + **selected[1]['ansible.builtin.copy'], 'dest': str(linger / '{{ primary_user }}')} + for task in selected: + module = task.get('ansible.builtin.file') or task['ansible.builtin.copy'] + module.pop('owner', None) + module.pop('group', None) + playbook = root / 'offline-linger.yml' + playbook.write_text(yaml.safe_dump([{'hosts': 'localhost', 'connection': 'local', 'become': False, + 'gather_facts': False, 'vars': { + 'primary_user': 'fixture', 'cybexos_offline': True}, + 'tasks': selected}])) + for _ in range(2): + result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], + text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue((linger / 'fixture').is_file()) + def test_repair_payload_uses_shared_sources_and_hardware_detection(self): with tempfile.TemporaryDirectory() as temporary: payload = Path(temporary) diff --git a/image/test_installer.py b/image/test_installer.py index 394301f4..42c61848 100644 --- a/image/test_installer.py +++ b/image/test_installer.py @@ -63,6 +63,7 @@ def __init__(self): self.choices = copy.deepcopy(CHOICES) self.started = False self.failed = False + self.rescanned = False def inventory(self): return self.choices @@ -70,6 +71,9 @@ def inventory(self): def reset(self): pass + def rescan(self): + self.rescanned = True + def keyboard(self, layout): return {"keyboard": layout, "boot_keyboard": layout} @@ -146,6 +150,20 @@ def test_locale_keyboard_and_unencrypted_choices_are_preserved(self): ) self.assertEqual(plan["account"]["locale"], "nl_NL.UTF-8") self.assertFalse(plan["account"]["encrypted"]) + self.assertFalse(plan["account"]["passwordless_wheel"]) + + def test_sudo_requires_explicit_boolean_opt_in(self): + self.assertTrue(self.plan(passwordless_wheel=True)["account"]["passwordless_wheel"]) + for invalid in ("true", 1, None): + with self.subTest(invalid=invalid), self.assertRaises(backend.Invalid): + self.plan(passwordless_wheel=invalid) + + def test_rescan_invalidates_confirmation_and_returns_new_inventory(self): + old = self.plan() + self.assertEqual(self.installer.rescan()["disks"][0]["name"], "vda") + self.assertTrue(self.adapter.rescanned) + with self.assertRaises(backend.Invalid): + self.commit(old) def test_keyboard_change_invalidates_old_confirmation(self): plan = self.plan() @@ -216,6 +234,29 @@ def test_changed_disk_and_stale_confirmation_are_rejected(self): with self.assertRaises(backend.Invalid): self.commit(plan) self.assertFalse(self.adapter.started) + self.adapter.choices["disks"][0]["serial"] = "FIXTURE-ONLY" + new = self.plan() + self.adapter.choices["disks"][0]["partitions"] = [ + {"path": "/dev/vda1", "size": 1024, "filesystem": "ext4"} + ] + with self.assertRaises(backend.Invalid): + self.commit(new) + + def test_status_and_diagnostics_redact_policy_and_worker_text(self): + plan = self.plan(passwordless_wheel=True) + self.commit(plan) + state = self.state.read() + state.update(step=1, total=2, message="private /home/alice fixture phrase never real") + self.state.write(state) + status = backend.installation_status(self.state, lambda *args, **kwargs: types.SimpleNamespace(stdout="active\n")) + self.assertEqual(status["phase"], "installing") + self.assertNotIn("account", status) + self.assertNotIn("private", json.dumps(status)) + report = backend.diagnostics(self.state, lambda *args, **kwargs: types.SimpleNamespace( + stdout="ActiveState=active\nSubState=running\nResult=success\nEnvironment=SECRET=leak\n")) + self.assertEqual(report["worker"], {"ActiveState": "active", "SubState": "running", "Result": "success"}) + self.assertNotIn("alice", json.dumps(report)) + self.assertNotIn("SECRET", json.dumps(report)) def test_confirmation_and_replay_protection(self): plan = self.plan() @@ -431,6 +472,14 @@ def test_unencrypted_path_never_sets_luks_password(self): self.assertFalse(any(call[0] == "passphrase" for call in self.calls)) def test_inventory_excludes_protected_media_and_reads_payload_size(self): + lsblk_calls = [] + def lsblk(command, **kwargs): + lsblk_calls.append(command) + return types.SimpleNamespace(stdout=json.dumps({"blockdevices": [{ + "path": "/dev/vda", "type": "disk", "children": [ + {"path": "/dev/vda1", "size": 2 * 1024**3, "type": "part", "fstype": "vfat"} + ]}]})) + patch.object(backend.subprocess, "run", side_effect=lsblk).start() self.modules[("Storage", "/DiskSelection")]._methods["GetUsableDisks"] = lambda: [ "vda", "live", @@ -448,7 +497,8 @@ def test_inventory_excludes_protected_media_and_reads_payload_size(self): localization._methods["GetKeyboardLayouts"] = lambda: [ {"layout-id": "us", "description": "English"} ] - localization._methods["GetCommonLocales"] = lambda: ["en_US.UTF-8"] + localization._methods["GetLanguages"] = lambda: ["en"] + localization._methods["GetLocales"] = lambda language: ["en_US.UTF-8"] timezone = self.modules[("Timezone", "")] timezone._values.update(Timezone="America/New_York", GeolocationResult={"territory": "", "timezone": ""}) timezone._methods["GetAllValidTimezones"] = lambda: { @@ -459,6 +509,10 @@ def test_inventory_excludes_protected_media_and_reads_payload_size(self): ) inventory = self.adapter.inventory() self.assertEqual([item["name"] for item in inventory["disks"]], ["vda"]) + self.assertEqual(inventory["disks"][0]["partitions"], [ + {"path": "/dev/vda1", "size": 2 * 1024**3, "filesystem": "vfat"} + ]) + self.assertEqual(lsblk_calls[0][-2:], ["--", "/dev/vda"]) self.assertEqual(inventory["required_bytes"], 70 * 1024**3) self.assertEqual( inventory["timezones"], ["America/New_York", "Europe/Amsterdam", "Europe/Berlin", "UTC"] @@ -474,6 +528,40 @@ def test_inventory_excludes_protected_media_and_reads_payload_size(self): inventory = self.adapter.inventory() self.assertEqual((inventory["timezone"], inventory["detected_timezone"]), ("UTC", "")) + def test_inventory_lists_all_supported_regional_locales_not_only_common_choices(self): + self.modules[("Storage", "/DiskSelection")]._methods["GetUsableDisks"] = lambda: [] + localization = self.modules[("Localization", "")] + localization.Language = "fr_CA.UTF-8" + available = {"en": ["en_US.UTF-8", "en_GB.UTF-8"], + "nl": ["nl_NL.UTF-8", "nl_BE.UTF-8", "nl_NL.UTF-8"]} + localization._methods.update( + GetKeyboardLayouts=lambda: [], + GetCommonLocales=lambda: ["en_US.UTF-8"], + GetLanguages=lambda: list(available), + GetLocales=lambda language: available[language], + ) + timezone = self.modules[("Timezone", "")] + timezone._values.update(Timezone="UTC", GeolocationResult={}) + timezone._methods["GetAllValidTimezones"] = lambda: {} + self.modules[("Payloads", "")] = StrictProxy(methods={"CalculateRequiredSpace": lambda: 1}) + inventory = self.adapter.inventory() + self.assertEqual(inventory["locales"], [ + "en_GB.UTF-8", "en_US.UTF-8", "nl_BE.UTF-8", "nl_NL.UTF-8", "fr_CA.UTF-8" + ]) + self.assertEqual(inventory["locale"], "fr_CA.UTF-8") + self.assertEqual(backend.validate_account( + {**ACCOUNT, "locale": "nl_NL.UTF-8"}, + {**CHOICES, "locales": inventory["locales"]} + )["locale"], "nl_NL.UTF-8") + + def test_rescan_uses_anaconda_scan_task(self): + storage = self.modules[("Storage", "")] + storage._methods["ScanDevicesWithTask"] = lambda: "/scan" + calls = [] + self.adapter.task = lambda module, path, **kwargs: calls.append((module, path, kwargs)) + self.adapter.rescan() + self.assertEqual(calls, [("Storage", "/scan", {"timeout": 300})]) + def test_geolocation_runs_only_without_an_earlier_result(self): timezone = self.modules[("Timezone", "")] timezone._values["GeolocationResult"] = {"territory": "", "timezone": ""} @@ -562,6 +650,10 @@ def setUp(self): "root:x:0:0::/root:/bin/bash\nalice:x:1000:1000::/home/alice:/bin/bash\n" ) (self.root / "etc/shadow").write_text("root:!:1::::::\nalice:fixture-hash:1::::::\n") + (self.root / "etc/cybexos").mkdir() + (self.root / "etc/cybexos/config.yml").write_text( + "primary_user: 'alice'\ndesktop_autologin: false\npasswordless_wheel: false\n" + ) def test_autologin_requires_both_request_and_verified_encryption(self): policy = {"account": {"username": "alice", "encrypted": True}} @@ -576,6 +668,7 @@ def test_autologin_requires_both_request_and_verified_encryption(self): record = json.loads((self.root / "etc/cybexos/installation.json").read_text()) self.assertEqual(record["keyring"], "encrypted-boot-passphrase-or-prompt") self.assertNotIn("password", record) + self.assertFalse(record["passwordless_wheel"]) policy["account"]["encrypted"] = False target.finalize(self.root, policy, False) self.assertFalse(json.loads(login.read_text())["autologin"]) @@ -596,6 +689,37 @@ def test_target_discards_live_decision_and_requires_boolean_encryption(self): with self.subTest(value=invalid), self.assertRaises(RuntimeError): target.finalize(self.root, {"account": {"username": "alice", "encrypted": invalid}}, True) + def test_sudo_policy_requires_explicit_choice_and_records_it(self): + path = self.root / "etc/sudoers.d/10-wheel-nopasswd" + path.parent.mkdir() + path.write_text("inherited image policy\n") + target.finalize(self.root, {"account": {"username": "alice", "encrypted": False, + "passwordless_wheel": False}}, False) + self.assertFalse(path.exists()) + target.finalize(self.root, {"account": {"username": "alice", "encrypted": False, + "passwordless_wheel": True}}, False) + self.assertEqual(path.read_text(), "%wheel ALL=(ALL:ALL) NOPASSWD: ALL\n") + self.assertEqual(path.stat().st_mode & 0o777, 0o440) + self.assertIn("passwordless_wheel: true\n", (self.root / "etc/cybexos/config.yml").read_text()) + self.assertTrue(json.loads((self.root / "etc/cybexos/installation.json").read_text())["passwordless_wheel"]) + for invalid in ("true", 1, None): + with self.subTest(invalid=invalid), self.assertRaises(RuntimeError): + target.finalize(self.root, {"account": {"username": "alice", "encrypted": False, + "passwordless_wheel": invalid}}, False) + + def test_missing_or_ambiguous_saved_policy_fails_closed(self): + config = self.root / "etc/cybexos/config.yml" + config.unlink() + with self.assertRaises(FileNotFoundError): + target.finalize(self.root, {"account": {"username": "alice", "encrypted": False, + "passwordless_wheel": True}}, False) + self.assertFalse((self.root / "etc/sudoers.d/10-wheel-nopasswd").exists()) + config.write_text("primary_user: 'alice'\ndesktop_autologin: false\npasswordless_wheel: false\npasswordless_wheel: true\n") + with self.assertRaises(RuntimeError): + target.finalize(self.root, {"account": {"username": "alice", "encrypted": False, + "passwordless_wheel": True}}, False) + self.assertFalse((self.root / "etc/sudoers.d/10-wheel-nopasswd").exists()) + def test_missing_administrator_does_not_authorize_login(self): with self.assertRaises(RuntimeError): target.finalize(self.root, {"account": {"username": "missing", "encrypted": True}}, True) @@ -732,6 +856,7 @@ def test_launcher_and_post_install_contract(self): self.assertIn("%post --nochroot --erroronfail", hook) self.assertIn("rm -f /etc/anaconda/conf.d/20-cybexos.conf", hook) self.assertIn("rm -f /run/cybexos-live-session /etc/sddm.conf", hook) + self.assertIn("rm -f /etc/sudoers.d/10-wheel-nopasswd", hook) self.assertIn('"autologin":false,"live":false', hook) self.assertIn("systemctl enable sddm.service", hook) self.assertIn( diff --git a/image/test_login_qualification.py b/image/test_login_qualification.py index 7baab417..87a85749 100644 --- a/image/test_login_qualification.py +++ b/image/test_login_qualification.py @@ -11,7 +11,7 @@ import login_qualification import qualification -from vm_testing import QUALIFICATION_DISK_SERIAL, TestVM, is_disk_prompt +from vm_testing import QUALIFICATION_DISK_SERIAL, TestVM, is_disk_prompt, poweroff_guest class DiskPromptTests(unittest.TestCase): @@ -94,6 +94,126 @@ def test_ssh_bootstrap_never_types_shell_commands_into_grub(self): typing.assert_not_called() +class GuestPoweroffTests(unittest.TestCase): + def exercise_shutdown(self, *, disconnected=True, exit_code=0, preparation='', + marker_present=True, command_status=255, sync_status=0): + vm = Mock(ssh_ready=True) + vm.process.wait.return_value = exit_code + if isinstance(exit_code, Exception): + vm.process.wait.side_effect = exit_code + calls = [] + def execute(_vm, script, _password): + calls.append(script) + # Run the preparation and marker using the production bash -e + # contract; command stubs never touch host services or files. + stubs = f'sync() {{ return {sync_status}; }}\nsystemctl() {{ return 0; }}\n' + result = subprocess.run(['bash', '-e', '-s'], input=stubs + script, + text=True, capture_output=True, check=True) + if disconnected and 'systemctl poweroff' in script: + raise subprocess.CalledProcessError(command_status, ['ssh', 'fixture'], + output=result.stdout if marker_present else '') + return result + try: + poweroff_guest(vm, 'fixture-password', execute, timeout=60, cleanup_script=preparation) + except Exception as error: + return vm, calls, error + return vm, calls, None + + def test_acknowledged_disconnect_requires_clean_qemu_exit(self): + for disconnected in (False, True): + with self.subTest(disconnected=disconnected): + vm, calls, error = self.exercise_shutdown(disconnected=disconnected) + self.assertIsNone(error) + self.assertEqual(calls[0], 'sync\n') + vm.process.wait.assert_called_once_with(timeout=60) + self.assertFalse(vm.ssh_ready) + self.assertIsNone(vm.console) + vm.process.terminate.assert_not_called() + + def test_disconnect_cannot_hide_guest_shutdown_timeout_or_crash(self): + for exit_code in (subprocess.TimeoutExpired('qemu-fixture', 60), 1, -15): + with self.subTest(exit_code=exit_code): + vm, _calls, error = self.exercise_shutdown(exit_code=exit_code) + self.assertIsInstance(error, RuntimeError) + self.assertIn('QEMU', str(error)) + self.assertFalse(vm.ssh_ready) + vm.process.terminate.assert_not_called() + vm.process.kill.assert_not_called() + + def test_sync_authentication_cleanup_and_missing_ack_fail_without_wait(self): + for options in ({'sync_status': 1}, {'command_status': 1}, + {'preparation': 'false\n'}, {'marker_present': False}): + with self.subTest(options=options): + vm, _calls, error = self.exercise_shutdown(**options) + self.assertIsInstance(error, subprocess.CalledProcessError) + vm.process.wait.assert_not_called() + + def test_cleanup_finishes_in_same_request_before_shutdown_marker(self): + vm, calls, error = self.exercise_shutdown(preparation="printf 'cleanup-complete\\n'\n") + self.assertIsNone(error) + self.assertEqual(len(calls), 2) + self.assertIn('cleanup-complete', calls[1]) + vm.process.wait.assert_called_once_with(timeout=60) + + +class SshTimeoutDiagnosticsTests(unittest.TestCase): + def test_timeout_reports_redacted_ssh_and_ocr_and_removes_frame(self): + for ocr_fails in (False, True): + with self.subTest(ocr_fails=ocr_fails), tempfile.TemporaryDirectory() as directory: + vm = TestVM(directory) + vm.ssh = ['ssh', 'fixture'] + vm.key.with_suffix('.pub').write_text('ssh-ed25519 fixture') + qmp = Mock() + def capture_frame(command, arguments): + self.assertEqual(command, 'screendump') + Path(arguments['filename']).write_bytes(b'transient private-fixture frame') + qmp.call.side_effect = capture_frame + ocr = (subprocess.TimeoutExpired('tesseract private-fixture', 5) if ocr_fails + else SimpleNamespace(stdout='Emergency console private-fixture')) + with patch.object(vm, 'alive'), patch.object(vm, 'type') as typing, \ + patch.object(vm, 'keypress') as keys, \ + patch('vm_testing.time.monotonic', side_effect=[0, 1, 301]), \ + patch('vm_testing.time.sleep'), \ + patch('vm_testing.subprocess.run', return_value=SimpleNamespace( + returncode=255, stderr='Connection refused private-fixture')), \ + patch('vm_testing.Qmp', return_value=qmp), \ + patch('vm_testing.shutil.which', return_value='/fixture/tesseract'), \ + patch('vm_testing.run', side_effect=ocr if ocr_fails else None, + return_value=ocr) as recognize: + with self.assertRaisesRegex(RuntimeError, 'readiness deadline') as caught: + vm.wait_ssh(setup=False, redactions=('private-fixture',)) + message = str(caught.exception) + self.assertIn('SSH exit 255: Connection refused [redacted]', message) + self.assertNotIn('private-fixture', message) + self.assertIn('Unavailable (TimeoutExpired:' if ocr_fails else 'Emergency console', message) + self.assertEqual(recognize.call_args.kwargs['timeout'], 5) + self.assertFalse((vm.work / 'prompt.png').exists()) + qmp.stream.close.assert_called_once() + qmp.socket.close.assert_called_once() + typing.assert_not_called() + keys.assert_not_called() + + def test_screen_capture_connection_failure_removes_frame(self): + with tempfile.TemporaryDirectory() as directory: + vm = TestVM(directory) + frame = vm.work / 'prompt.png' + frame.write_bytes(b'transient frame') + with patch('vm_testing.shutil.which', return_value='/fixture/tesseract'), \ + patch('vm_testing.Qmp', side_effect=OSError('QMP unavailable')): + with self.assertRaisesRegex(OSError, 'QMP unavailable'): + vm.screen_text(timeout=5) + self.assertFalse(frame.exists()) + + def test_cold_reboot_passes_password_redaction_without_retrying_login(self): + vm = Mock() + vm.process.wait.return_value = 0 + with patch.object(login_qualification, 'wait_login_state'): + login_qualification.reboot_installed(vm, 'private-fixture', Mock()) + vm.unlock_disk.assert_called_once_with('private-fixture') + vm.wait_ssh.assert_called_once_with(setup=False, redactions=('private-fixture',)) + vm.type.assert_not_called() + + class KeyringProbeTests(unittest.TestCase): def test_synthetic_secret_never_becomes_an_ssh_argument(self): vm = SimpleNamespace(ssh=['ssh', 'disposable-fixture']) diff --git a/image/test_qualification.py b/image/test_qualification.py new file mode 100644 index 00000000..e65cad66 --- /dev/null +++ b/image/test_qualification.py @@ -0,0 +1,305 @@ +"""Source-only safety checks for the real-browser VM qualification path.""" +from pathlib import Path +import ast +import importlib.machinery +import importlib.util +import json +import os +import subprocess +import tempfile +import types +import unittest +from unittest.mock import Mock, patch + +import browser_qualification as browser +import qualification +import upgrade_qualification as upgrade +from vm_testing import QUALIFICATION_DISK_SERIAL, QUALIFICATION_UNUSED_SERIAL, TestVM + + +class BrowserTransportTests(unittest.TestCase): + def test_missing_browser_dependencies_fail_before_vm_or_output_creation(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / 'qualification' + iso = Path('/data/pxe/iso/fixture.iso') + arguments = ['qualify', str(iso), '--output', str(output), + '--execute-vm', '--erase-disposable-disk'] + with patch('sys.argv', arguments), patch.object(qualification.signal, 'signal'), \ + patch.object(qualification, 'require_test_iso', return_value=iso), \ + patch.object(qualification, 'browser_dependencies', + side_effect=RuntimeError('Missing playwright-core')) as dependencies, \ + patch.object(qualification, 'TestVM') as vm: + with self.assertRaisesRegex(RuntimeError, 'Missing playwright-core'): + qualification.main() + dependencies.assert_called_once_with() + vm.assert_not_called() + self.assertFalse(output.exists()) + + def test_only_exact_guest_loopback_installer_url_is_accepted(self): + self.assertEqual(browser.validate_guest_url( + 'http://127.0.0.1:8080/cockpit/@localhost/cybexos-installer/index.html'), 8080) + self.assertEqual(browser.validate_guest_url( + 'http://localhost/cockpit/@localhost/cybexos-installer/index.html'), 80) + # Anaconda passes its original URL to the wrapper, which redirects the + # actual browser to the CybexOS page. Both use the same loopback port. + self.assertEqual(browser.validate_guest_url( + 'http://127.0.0.1/cockpit/@localhost/anaconda-webui/index.html'), 80) + for value in ('http://example.com/cockpit/@localhost/cybexos-installer/index.html', + 'http://127.0.0.1@evil.invalid/cockpit/@localhost/cybexos-installer/index.html', + 'https://127.0.0.1/cockpit/@localhost/cybexos-installer/index.html', + 'http://127.0.0.1/cockpit/@localhost/cybexos-installer/index.html?x=1'): + with self.subTest(value=value), self.assertRaises(ValueError): + browser.validate_guest_url(value) + + def test_guest_disk_names_must_match_both_fixed_serials(self): + vm = types.SimpleNamespace(ssh=['ssh', 'guest']) + valid = f'vda {QUALIFICATION_DISK_SERIAL}\nvdb {QUALIFICATION_UNUSED_SERIAL}\n' + for listing, expected in ((valid, ('vda', 'vdb')), + (valid.replace(QUALIFICATION_UNUSED_SERIAL, 'OTHER'), None), + (valid + f'vdc {QUALIFICATION_DISK_SERIAL}\n', None)): + with patch.object(qualification, 'run', return_value=types.SimpleNamespace(stdout=listing)): + if expected is None: + with self.assertRaises(RuntimeError): + qualification.qualification_disks(vm) + else: + self.assertEqual(qualification.qualification_disks(vm), expected) + + def test_qualification_attaches_guard_only_when_requested(self): + with tempfile.TemporaryDirectory() as directory: + ordinary = TestVM(Path(directory) / 'ordinary') + guarded = TestVM(Path(directory) / 'guarded', guard_disk=True) + self.assertFalse(ordinary.guard_disk) + self.assertTrue(guarded.guard_disk) + self.assertNotEqual(guarded.disk, guarded.unused_disk) + + +class UpgradeTests(unittest.TestCase): + def test_recovery_requires_exact_booted_point_from_snapshot_index(self): + source = Path(__file__).resolve().parents[1] / 'roles/base/files/cybexos-system-snapshot' + loader = importlib.machinery.SourceFileLoader('qualification_snapshot_fixture', str(source)) + spec = importlib.util.spec_from_loader(loader.name, loader) + snapshot = importlib.util.module_from_spec(spec) + loader.exec_module(snapshot) + point = '20260901T120000Z-1' + for booted in (point, False, True, '', '20260901T120000Z-2'): + with self.subTest(booted=booted): + # Build the response with the real producer: recoveryBoot is + # the booted ID, whereas bootMenu and bootable are booleans. + layout = snapshot.Layout('recovery', recovery=booted) + with patch.object(snapshot, 'describe_points', + return_value=([{'id': point, 'bootable': True}], ['menu'])): + index, _ = snapshot.build_index(layout, None, {}, with_menu=True) + calls = [] + def root_script(vm, script, password, **kwargs): + calls.append(script) + return types.SimpleNamespace(stdout=json.dumps(index)) + if booted == point: + upgrade.verify_recovery_boot(None, point, 'fixture-password', root_script) + self.assertEqual(calls[-1], f'{upgrade.SNAPSHOT} restore {point}\n') + else: + with self.assertRaisesRegex(RuntimeError, 'requested recovery point'): + upgrade.verify_recovery_boot(None, point, 'fixture-password', root_script) + self.assertEqual(len(calls), 1, 'Restore must not run after a mismatched boot') + + def test_preference_fixture_changes_effective_default_and_rejects_invalid_position(self): + for initial, expected in (({}, 'bottom'), ({'position': 'top'}, 'bottom'), + ({'position': 'bottom'}, 'top'), ({'position': 'invalid'}, None)): + with self.subTest(initial=initial), tempfile.TemporaryDirectory() as directory: + home = Path(directory) + kitty = home / '.config/kitty/cybexos.conf' + settings = home / '.config/cybexos/shell.json' + kitty.parent.mkdir(parents=True) + settings.parent.mkdir(parents=True) + kitty.write_text('font_size 12\n') + settings.write_text(json.dumps(initial)) + def root_script(vm, script, password): + guest = script.split("python3 - <<'PY'\n", 1)[1].split('\nPY\n', 1)[0] + guest = guest.replace("Path('/home/qualification')", f'Path({directory!r})') + return subprocess.run(['python3', '-'], input=guest, text=True, + capture_output=True, check=True, timeout=10) + if expected is None: + with self.assertRaises(subprocess.CalledProcessError): + upgrade.prepare_user_choices(None, 'fixture-password', root_script) + else: + self.assertEqual(upgrade.prepare_user_choices(None, 'fixture-password', root_script), expected) + self.assertEqual(json.loads(settings.read_text())['position'], expected) + self.assertIn(upgrade.MANAGED_MARKER, kitty.read_text()) + + def test_user_preference_fixture_is_valid_guest_python(self): + captured = [] + def root_script(vm, script, password): + captured.append(script) + return types.SimpleNamespace(stdout='bottom\n') + self.assertEqual(upgrade.prepare_user_choices(None, 'fixture-password', root_script), 'bottom') + guest = captured[0].split("python3 - <<'PY'\n", 1)[1].split('\nPY\n', 1)[0] + compile(guest, '', 'exec') + + def test_upgrade_rejects_same_or_older_rpm_before_dnf(self): + vm = types.SimpleNamespace() + for comparison in (0, -1): + with patch.object(upgrade, 'copy_candidate', return_value='a' * 64), \ + patch.object(upgrade, 'inspect_version', return_value={'comparison': comparison}), \ + patch.object(upgrade, 'run') as command: + with self.assertRaises(RuntimeError): + upgrade.upgrade(vm, '/fixture.rpm', 'fixture-password', lambda *args, **kwargs: None) + command.assert_not_called() + + def test_candidate_symlink_is_refused_before_guest_transfer(self): + with tempfile.TemporaryDirectory() as directory: + real = Path(directory) / 'candidate.rpm' + real.write_bytes(b'fixture') + link = Path(directory) / 'link.rpm' + link.symlink_to(real) + with self.assertRaises(ValueError): + upgrade.copy_candidate(types.SimpleNamespace(), link) + + +class InstalledAuditTests(unittest.TestCase): + def test_session_readiness_precedes_virtual_terminal_lookup(self): + for autologin in (False, True): + with self.subTest(autologin=autologin): + vm = Mock(ssh=['ssh', 'fixture']) + states = [] + def ready(_vm, *, desktop): + states.append(desktop) + def lookup(*args, **kwargs): + self.assertEqual(states, [autologin], + 'VT discovery ran before session readiness') + return types.SimpleNamespace(stdout='1\n') + with patch('login_qualification.wait_login_state', side_effect=ready), \ + patch.object(qualification, 'run', side_effect=lookup), \ + patch.object(qualification.time, 'sleep'): + qualification.focus_installed_desktop(vm, 'fixture-password', autologin) + vm.keypress.assert_called_once_with('ctrl+alt+f1') + vm.wait_desktop.assert_called_once_with() + self.assertEqual(states, [autologin, True]) + if autologin: + vm.type.assert_not_called() + else: + vm.type.assert_called_once_with('fixture-password\n') + + def test_disk_unlock_and_desktop_login_are_independent_for_recovery(self): + for encrypted, options, autologin in ((True, {}, True), (False, {}, False), + (True, {'autologin': False}, False)): + with self.subTest(encrypted=encrypted, options=options): + vm = Mock() + with patch.object(qualification, 'focus_installed_desktop') as focus: + qualification.boot_installed(vm, 'fixture-password', encrypted, **options) + vm.start.assert_called_once_with(user='qualification') + if encrypted: + vm.unlock_disk.assert_called_once_with('fixture-password') + else: + vm.unlock_disk.assert_not_called() + vm.wait_ssh.assert_called_once_with(timeout=12, setup=False, + redactions=('fixture-password',)) + focus.assert_called_once_with(vm, 'fixture-password', autologin) + + def test_generated_timezone_check_accepts_file_aliases_but_rejects_other_zone(self): + script = qualification.installed_audit(False, False, 'us', 'us', 'en_US.UTF-8', 'UTC') + guest = script.split("python3 - <<'CHECK'\n", 1)[1].split('\nCHECK\n', 1)[0] + assertion = next(node for node in ast.parse(guest).body + if isinstance(node, ast.Assert) and isinstance(node.msg, ast.Constant) + and node.msg.value == 'Installed timezone differs') + check = compile(ast.Module(body=[assertion], type_ignores=[]), '', 'exec') + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + zones = root / 'usr/share/zoneinfo' + (zones / 'Etc').mkdir(parents=True) + utc = zones / 'UTC' + utc.write_bytes(b'fixture UTC zone') + (zones / 'Etc/UTC').hardlink_to(utc) + (zones / 'UTC-symlink').symlink_to('UTC') + (zones / 'other-zone').write_bytes(b'fixture different zone') + localtime = root / 'etc/localtime' + localtime.parent.mkdir() + for target, succeeds in (('UTC', True), ('Etc/UTC', True), + ('UTC-symlink', True), ('other-zone', False)): + with self.subTest(target=target): + localtime.unlink(missing_ok=True) + localtime.symlink_to('../usr/share/zoneinfo/' + target) + environment = {'Path': lambda value: root / value.lstrip('/'), + 'expected_timezone': 'UTC'} + if succeeds: + exec(check, environment) + else: + with self.assertRaisesRegex(AssertionError, 'Installed timezone differs'): + exec(check, environment) + + def test_python_traceback_survives_bounded_shell_failure_output(self): + trap = next(line for line in qualification.INSTALLED_AUDIT.splitlines() + if line.startswith('trap ')) + script = trap + "\npython3 - <<'PY'\n#" + 'long fixture comment ' * 300 + script += "\nraise AssertionError('specific audit assertion')\nPY\n" + result = subprocess.run(['bash', '-e', '-s'], input=script, text=True, + capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertIn('AssertionError: specific audit assertion', result.stderr[-1000:]) + self.assertIn('Installed audit shell check failed at line', result.stderr) + self.assertNotIn('long fixture comment', result.stderr) + + def test_shell_assertions_fail_on_forbidden_state_and_probe_errors(self): + # Exercise the generated audit using the same bash -e mode as root_script. + # Guest commands are stubs so no host accounts, packages or disks are read. + stubs = r''' +getent() { return "${FIXTURE_GETENT:-2}"; } +getenforce() { echo Enforcing; } +findmnt() { + if [[ "$*" == *FSTYPE* ]]; then echo btrfs; else + echo '/dev/fixture[/root]'; return "${FIXTURE_FINDMNT:-0}" + fi +} +lsblk() { printf '%s\n' "${FIXTURE_TYPES:-part}"; return "${FIXTURE_LSBLK:-0}"; } +systemctl() { if [[ "$1" == show ]]; then echo inherit; fi; } +rpm() { return "${FIXTURE_RPM:-1}"; } +python3() { cat >/dev/null; echo fixture-audit-complete; } +test() { + if [[ "$1" == '!' && "$2" == '-e' ]]; then return 0; fi + builtin test "$@" +} +''' + cases = [ + ('plain', False, {}, True), + ('encrypted', True, {'FIXTURE_TYPES': 'crypt\npart'}, True), + ('live account', False, {'FIXTURE_GETENT': '0'}, False), + ('account query error', False, {'FIXTURE_GETENT': '3'}, False), + ('gdm installed', False, {'FIXTURE_RPM': '0'}, False), + ('package query error', False, {'FIXTURE_RPM': '2'}, False), + ('unexpected encryption', False, {'FIXTURE_TYPES': 'crypt\npart'}, False), + ('missing encryption', True, {}, False), + ('block query error', False, {'FIXTURE_LSBLK': '1'}, False), + ('mount query error', False, {'FIXTURE_FINDMNT': '1'}, False), + ] + for name, encrypted, environment, succeeds in cases: + with self.subTest(name=name): + script = qualification.installed_audit(encrypted, False, 'us', 'us', + 'en_US.UTF-8', 'UTC') + result = subprocess.run(['bash', '-e', '-s'], input=stubs + script, + env={**os.environ, **environment}, text=True, + capture_output=True, timeout=10) + self.assertEqual(result.returncode == 0, succeeds, result.stderr) + self.assertEqual('fixture-audit-complete' in result.stdout, succeeds) + + def test_selected_install_settings_are_checked_in_target_and_desktop(self): + script = qualification.installed_audit(True, True, 'nl', 'nl', 'nl_NL.UTF-8', 'Europe/Amsterdam') + self.assertIn('export EXPECTED_KEYBOARD=nl', script) + self.assertIn('export EXPECTED_BOOT_KEYMAP=nl', script) + self.assertIn('export EXPECTED_LOCALE=nl_NL.UTF-8', script) + self.assertIn('export EXPECTED_TIMEZONE=Europe/Amsterdam', script) + guest = script.split("python3 - <<'CHECK'\n", 1)[1].split('\nCHECK\n', 1)[0] + compile(guest, '', 'exec') + compile(qualification.DESKTOP_KEYBOARD_AUDIT, '', 'exec') + + def test_failed_installed_audit_reports_check_without_exposing_password(self): + failure = subprocess.CalledProcessError(1, ['ssh', 'guest'], + output='earlier output fixture-secret', + stderr='Installed audit shell check failed at line 7: test condition') + with patch.object(qualification, 'root_script', side_effect=failure): + with self.assertRaisesRegex(RuntimeError, 'line 7: test condition') as caught: + qualification.verify_installed_audit(None, True, False, 'us', 'us', + 'en_US.UTF-8', 'UTC', 'fixture-secret') + self.assertNotIn('fixture-secret', str(caught.exception)) + self.assertIn('[redacted]', str(caught.exception)) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_reconfigure.py b/image/test_reconfigure.py index c4be50a9..865a02ab 100644 --- a/image/test_reconfigure.py +++ b/image/test_reconfigure.py @@ -109,7 +109,8 @@ def test_installation_records_the_installed_system_in_the_install_schema(self): saved['machine_keyboard_variant']), ('studio', 'Europe/Amsterdam', 'nl_NL.UTF-8', 'nl_NL.UTF-8', 'us', 'dvorak')) self.assertEqual(saved['features'], inventory['features']) - for key in ('passwordless_wheel', 'passwordless_local_polkit', 'docker_sudoless', + self.assertIs(saved['passwordless_wheel'], False) + for key in ('passwordless_local_polkit', 'docker_sudoless', 'xps_2026_camera_enabled', 'allow_insecure_sccache_transport'): self.assertIs(saved[key], inventory[key], key) self.assertIs(saved['manage_system_identity'], False) @@ -206,13 +207,15 @@ def test_verified_autologin_decision_updates_only_the_saved_autologin_choice(sel self.assertEqual(sorted(path.name for path in self.path.parent.iterdir()), ['config.yml', 'installation.json', 'login.json']) - def test_configuration_for_another_account_or_none_is_left_alone(self): + def test_configuration_for_another_account_or_none_fails_closed(self): text = self.path.read_text().replace("primary_user: 'alice'", "primary_user: 'bob'") self.path.write_text(text) - target.record_autologin(self.root, 'alice', True) + with self.assertRaises(RuntimeError): + target.finalize(self.root, {'account': {'username': 'alice', 'encrypted': True}}, True) self.assertEqual(self.path.read_text(), text) self.path.unlink() - target.finalize(self.root, {'account': {'username': 'alice', 'encrypted': True}}, True) + with self.assertRaises(FileNotFoundError): + target.finalize(self.root, {'account': {'username': 'alice', 'encrypted': True}}, True) self.assertFalse(self.path.exists()) @@ -282,7 +285,7 @@ def test_configuration_is_recorded_before_provisioning_consumes_it(self): settings, calls = self.run_main(['--offline']) self.assertEqual([call[0] for call in calls], ['ensure', 'configure']) self.assertEqual(calls[0][1], {'fresh_account': True}) - self.assertEqual(calls[1][2:], (True, False)) + self.assertEqual(calls[1][2:], (True, False, False)) settings, calls = self.run_main(['--user', 'alice']) self.assertEqual([call[0] for call in calls], ['ensure', 'configure', 'login']) self.assertEqual(calls[0][1], {'fresh_account': False}) diff --git a/image/test_release_gate.py b/image/test_release_gate.py new file mode 100644 index 00000000..b194f578 --- /dev/null +++ b/image/test_release_gate.py @@ -0,0 +1,121 @@ +"""The release orchestrator fails closed before publishing or starting VM tests.""" +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import MagicMock, patch + +import browser_qualification + +loader = importlib.machinery.SourceFileLoader('release_gate', str(Path(__file__).with_name('release-gate'))) +spec = importlib.util.spec_from_loader(loader.name, loader) +gate = importlib.util.module_from_spec(spec) +loader.exec_module(gate) + + +class ReleaseGate(unittest.TestCase): + def fixture(self, root, same_iso=False): + checkout = root / 'source' + checkout.mkdir() + (checkout / 'VERSION').write_text('1.2.3\n') + pxe = root / 'pxe' + (pxe / 'iso').mkdir(parents=True) + (pxe / 'README.md').write_text('fixture PXE host') + baseline = pxe / 'iso/baseline.iso' + baseline.write_bytes(b'candidate' if same_iso else b'prior ISO') + output = root / 'output' + commands = [] + def paths(value): + value = str(value) + return pxe / value[len('/data/pxe/'):] if value.startswith('/data/pxe/') else Path(value) + def run(command): + commands.append(command) + if command[0].endswith('/image/build'): + artifacts = output / 'build/artifacts' + artifacts.mkdir(parents=True) + (artifacts / 'candidate.iso').write_bytes(b'candidate') + (artifacts / 'cybexos-desktop-1.2.3.rpm').write_bytes(b'RPM') + if command[0].endswith('/image/publish-pxe'): + (pxe / 'iso/candidate.iso').write_bytes(b'candidate') + return checkout, baseline, output, paths, run, commands + + def invoke(self, root, same_iso=False): + checkout, baseline, output, paths, run, commands = self.fixture(root, same_iso) + with patch.object(gate, 'ROOT', checkout), patch.object(gate, 'Path', side_effect=paths), \ + patch.object(gate, 'require_test_iso', side_effect=lambda path: path), \ + patch.object(gate.subprocess, 'run'), patch.object(gate, 'browser_dependencies'), \ + patch.object(gate, 'run_child', side_effect=run), patch.object(gate.signal, 'signal'), \ + patch('sys.argv', ['release-gate', '--output', str(output), '--baseline-iso', str(baseline), + '--tag', 'v1.2.3', '--execute']): + if same_iso: + with self.assertRaisesRegex(RuntimeError, 'different prior ISO'): + gate.main() + else: + gate.main() + return output, commands + + def test_same_iso_is_rejected_before_pxe_publication_and_vm_creation(self): + with tempfile.TemporaryDirectory() as temporary: + output, commands = self.invoke(Path(temporary), same_iso=True) + self.assertEqual(len(commands), 1) + self.assertFalse((output / 'build').exists()) + self.assertEqual(json.loads((output / 'release-gate.json').read_text())['status'], 'failed') + + def test_required_matrix_and_prior_rpm_upgrade_recovery_are_invoked(self): + with tempfile.TemporaryDirectory() as temporary: + output, commands = self.invoke(Path(temporary)) + qualifications = [command for command in commands if command[0].endswith('/image/qualify')] + self.assertEqual(len(qualifications), 5) + self.assertEqual([command[command.index('--scenario') + 1] for command in qualifications[:4]], + ['encrypted-us', 'plain-us', 'encrypted-nl', 'plain-nl']) + upgrade = qualifications[-1] + self.assertTrue(upgrade[1].endswith('/baseline.iso')) + self.assertIn('--candidate-rpm', upgrade) + self.assertIn('--recovery-check', upgrade) + self.assertIn('--legacy-installer', upgrade) + self.assertEqual(json.loads((output / 'release-gate.json').read_text())['status'], 'passed') + + def test_interruption_terminates_owned_process_group_before_returning(self): + process = MagicMock() + process.pid = 43210 + process.poll.return_value = None + process.wait.side_effect = [KeyboardInterrupt(), 0] + process.__enter__.return_value = process + with patch.object(gate.subprocess, 'Popen', return_value=process) as spawn, \ + patch.object(gate.os, 'killpg') as stop: + with self.assertRaises(KeyboardInterrupt): + gate.run_child(['fixture-child']) + spawn.assert_called_once_with(['fixture-child'], start_new_session=True) + stop.assert_called_once_with(process.pid, gate.signal.SIGTERM) + self.assertEqual(process.wait.call_args_list[-1].kwargs, {'timeout': 30}) + + def test_unresponsive_owned_group_is_killed_and_reaped(self): + process = MagicMock() + process.pid = 43210 + process.poll.return_value = None + process.wait.side_effect = [KeyboardInterrupt(), subprocess.TimeoutExpired('fixture', 30), 0] + process.__enter__.return_value = process + with patch.object(gate.subprocess, 'Popen', return_value=process), patch.object(gate.os, 'killpg') as stop: + with self.assertRaises(KeyboardInterrupt): + gate.run_child(['fixture-child']) + self.assertEqual([call.args[1] for call in stop.call_args_list], [gate.signal.SIGTERM, gate.signal.SIGKILL]) + self.assertEqual(process.wait.call_count, 3) + + def test_browser_preflight_checks_supported_node_and_driver_before_build(self): + with patch.dict(browser_qualification.os.environ, {'CYBEXOS_BROWSER': '/fixture/browser'}), \ + patch.object(browser_qualification.shutil, 'which', return_value=None), \ + patch.object(browser_qualification.os, 'access', return_value=True), \ + patch.object(browser_qualification.subprocess, 'run', return_value=SimpleNamespace(returncode=1)) as run: + with self.assertRaisesRegex(RuntimeError, 'Node.js >=20'): + browser_qualification.browser_dependencies() + source = run.call_args.args[0][-1] + self.assertIn('process.versions.node', source) + self.assertIn('require("playwright-core")', source) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_release_runner.py b/image/test_release_runner.py new file mode 100644 index 00000000..756ba6e6 --- /dev/null +++ b/image/test_release_runner.py @@ -0,0 +1,251 @@ +"""No runner registration or listening occurs in these source fixtures.""" +import hashlib +import io +import os +from pathlib import Path +import signal +import subprocess +import tarfile +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +import release_runner as runner + +HEAD = 'a' * 40 +RUN_ID = 1234 +LABEL = f'cybexos-iso-{RUN_ID}' +RUN = {'id': RUN_ID, 'repository': {'full_name': runner.REPOSITORY}, + 'head_repository': {'full_name': runner.REPOSITORY}, + 'event': 'push', 'path': '.github/workflows/release.yml', + 'head_sha': HEAD, 'head_branch': 'v1.2.3', 'pull_requests': [], 'status': 'in_progress'} +JOB = {'id': 5678, 'run_id': RUN_ID, 'head_sha': HEAD, 'labels': [LABEL], 'status': 'queued'} +DOWNLOAD = {'os': 'linux', 'architecture': 'x64', 'sha256_checksum': 'b' * 64, + 'download_url': 'https://github.com/actions/runner/releases/download/v2.333.0/actions-runner-linux-x64-2.333.0.tar.gz'} + + +class ReviewedIdentity(unittest.TestCase): + def test_exact_reviewed_main_and_version_tag_are_allowed(self): + for branch in ('main', 'v1.2.3', 'v1.2.3-rc.1'): + self.assertEqual(runner.validate_run({**RUN, 'head_branch': branch}, HEAD)['head_sha'], HEAD) + self.assertEqual(runner.validate_job([JOB], RUN_ID, HEAD)['id'], JOB['id']) + + def test_wrong_source_workflow_pr_and_finished_runs_are_rejected(self): + cases = ({'head_sha': 'c' * 40}, {'event': 'pull_request'}, + {'head_repository': {'full_name': 'attacker/CybexOS'}}, + {'repository': {'full_name': 'attacker/CybexOS'}}, + {'path': '.github/workflows/tests.yml'}, {'head_branch': 'unreviewed'}, + {'pull_requests': [{'number': 12}]}, {'status': 'completed'}) + for values in cases: + with self.subTest(values=values), self.assertRaises(ValueError): + runner.validate_run({**RUN, **values}, HEAD) + + def test_unique_label_only_and_exact_queued_job_are_required(self): + for jobs in ([], [JOB, JOB], [{**JOB, 'labels': ['self-hosted', LABEL]}], + [{**JOB, 'run_id': 99}], [{**JOB, 'head_sha': 'c' * 40}], + [{**JOB, 'status': 'in_progress'}]): + with self.subTest(jobs=jobs), self.assertRaises(ValueError): + runner.validate_job(jobs, RUN_ID, HEAD) + + def test_an_existing_runner_or_other_workflow_claim_blocks_enrollment(self): + def pages(endpoint, field): + if field == 'jobs': + return [JOB] + if field == 'runners': + return [{'id': 77, 'labels': [{'name': LABEL}]}] + return [] + with patch.object(runner, 'api', return_value=RUN), patch.object(runner, 'pages', side_effect=pages): + with self.assertRaisesRegex(ValueError, 'already exists'): + runner.reviewed_job(RUN_ID, HEAD) + runner.reviewed_job(RUN_ID, HEAD, own_runner=77) + def conflicting(endpoint, field): + if field == 'jobs': + return [JOB] if f'/runs/{RUN_ID}/' in endpoint else [{**JOB, 'run_id': 99}] + return [{'id': 99}] if field == 'workflow_runs' else [] + with patch.object(runner, 'api', return_value=RUN), patch.object(runner, 'pages', side_effect=conflicting): + with self.assertRaisesRegex(ValueError, 'Another unfinished'): + runner.reviewed_job(RUN_ID, HEAD) + + def test_official_download_requires_api_checksum(self): + self.assertEqual(runner.runner_package([DOWNLOAD]), (DOWNLOAD['download_url'], 'b' * 64)) + for values in ({'sha256_checksum': ''}, {'download_url': 'https://attacker.example/runner.tar.gz'}, + {'download_url': DOWNLOAD['download_url'] + '?token=secret'}, {'architecture': 'arm64'}): + with self.assertRaises(ValueError): + runner.runner_package([{**DOWNLOAD, **values}]) + + +class PackageVerification(unittest.TestCase): + def test_checksum_mismatch_never_reaches_extraction(self): + with tempfile.TemporaryDirectory() as temporary: + response = io.BytesIO(b'fixture archive bytes') + response.url = DOWNLOAD['download_url'] + with patch.object(runner, 'urlopen', return_value=response): + with self.assertRaisesRegex(ValueError, 'SHA-256'): + runner.download(response.url, 'b' * 64, Path(temporary) / 'runner.tar.gz') + + def test_verified_download_and_archive_path_filter(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + archive = root / 'runner.tar.gz' + with tarfile.open(archive, 'w:gz') as bundle: + for name in ('config.sh', 'run.sh'): + member = tarfile.TarInfo(name) + content = b'#!/bin/sh\nexit 0\n' + member.mode, member.size = 0o755, len(content) + bundle.addfile(member, io.BytesIO(content)) + output = root / 'unpacked' + output.mkdir() + runner.extract(archive, output) + self.assertTrue(os.access(output / 'run.sh', os.X_OK)) + with tarfile.open(archive, 'w:gz') as bundle: + member = tarfile.TarInfo('../escape') + member.size = 1 + bundle.addfile(member, io.BytesIO(b'x')) + with self.assertRaises(tarfile.FilterError): + runner.extract(archive, output) + self.assertFalse((root / 'escape').exists()) + data = b'official pinned bytes' + response = io.BytesIO(data) + response.url = DOWNLOAD['download_url'] + with patch.object(runner, 'urlopen', return_value=response): + runner.download(response.url, hashlib.sha256(data).hexdigest(), root / 'download') + self.assertEqual((root / 'download').read_bytes(), data) + + +class EphemeralLifecycle(unittest.TestCase): + def execute_fixture(self, root, failure=None, completed=None): + commands = [] + def api(endpoint, **_kwargs): + if endpoint.endswith('/downloads'): + return [DOWNLOAD] + if endpoint.endswith('/registration-token'): + return {'token': 'private-registration-token'} + if '/actions/jobs/' in endpoint: + return completed or {**JOB, 'status': 'completed', 'runner_id': 77, 'conclusion': 'success'} + raise AssertionError(endpoint) + def invoke(command, work, environment, timeout, **kwargs): + commands.append((command, dict(environment), timeout, kwargs)) + (work / '.credentials').write_text('ephemeral credentials') + if failure and (failure == 'registration' or command[0].endswith('/run.sh')): + raise KeyboardInterrupt + with patch.object(runner, 'check_user_manager'), \ + patch.object(runner, 'reviewed_job', return_value=(RUN, JOB)), \ + patch.object(runner, 'api', side_effect=api), \ + patch.object(runner, 'download', side_effect=lambda _url, _sha, path: path.touch()), \ + patch.object(runner, 'extract'), patch.object(runner, 'owned_runner', return_value=77), \ + patch.object(runner, 'run_child', side_effect=invoke), \ + patch.object(runner, 'run_scoped_runner', side_effect=lambda work, env, timeout, _name: invoke([str(work / 'run.sh')], work, env, timeout)), \ + patch.object(runner, 'cleanup_registration') as cleanup, \ + patch.dict(os.environ, {'GH_TOKEN': 'operator-admin-token', 'GITHUB_TOKEN': 'other-token'}): + try: + runner.execute(RUN_ID, HEAD, root, 60) + finally: + cleanup.assert_called_once() + self.assertEqual(list(root.iterdir()), [], 'Task directory and credentials must be removed') + return commands + + def test_success_registers_unique_only_label_and_strips_operator_tokens(self): + with tempfile.TemporaryDirectory() as temporary: + commands = self.execute_fixture(Path(temporary)) + config, run = commands + self.assertIn('--ephemeral', config[0]) + self.assertIn('--no-default-labels', config[0]) + self.assertEqual(config[0][config[0].index('--labels') + 1], LABEL) + self.assertNotIn('private-registration-token', ' '.join(config[0])) + self.assertEqual(config[1]['ACTIONS_RUNNER_INPUT_TOKEN'], 'private-registration-token') + for command in commands: + self.assertNotIn('GH_TOKEN', command[1]) + self.assertNotIn('GITHUB_TOKEN', command[1]) + self.assertNotIn('ACTIONS_RUNNER_INPUT_TOKEN', run[1]) + self.assertEqual(run[2], 60) + + def test_interruption_cleans_partial_registration_and_active_runner(self): + for failure in ('registration', 'running'): + with self.subTest(failure=failure), tempfile.TemporaryDirectory() as temporary: + with self.assertRaises(KeyboardInterrupt): + self.execute_fixture(Path(temporary), failure=failure) + + def test_unexpected_job_assignment_fails_after_stopping_and_cleans_up(self): + with tempfile.TemporaryDirectory() as temporary: + with self.assertRaisesRegex(RuntimeError, 'reviewed job'): + self.execute_fixture(Path(temporary), completed={**JOB, 'status': 'completed', + 'runner_id': 88, 'conclusion': 'success'}) + + def test_cleanup_deletes_only_exact_owned_runner(self): + names = [{'name': 'other-runner', 'id': 1}, {'name': 'owned-unique', 'id': 2}] + with patch.object(runner, 'pages', return_value=names), patch.object(runner, 'api') as api: + runner.cleanup_registration('owned-unique') + api.assert_called_once_with(f'repos/{runner.REPOSITORY}/actions/runners/2', method='DELETE') + with patch.object(runner, 'pages', return_value=[]), patch.object(runner, 'api') as api: + runner.cleanup_registration('already-removed') + api.assert_not_called() + + def test_runtime_uses_a_bounded_cgroup_and_stops_it_on_interruption(self): + result = subprocess.CompletedProcess([], 0, stdout='inactive\n') + with patch.object(runner, 'run_child', side_effect=KeyboardInterrupt) as invoke, \ + patch.object(runner.subprocess, 'run', return_value=result) as systemctl: + with self.assertRaises(KeyboardInterrupt): + runner.run_scoped_runner(Path('/fixture'), {'HOME': '/fixture/home'}, 60, 'owned-unit') + command = invoke.call_args.args[0] + self.assertIn('--property=RuntimeMaxSec=60s', command) + self.assertIn('--property=TimeoutStopSec=120s', command) + self.assertIn('--property=KillMode=mixed', command) + self.assertIn('/usr/bin/env', command) + self.assertIn('-i', command) + self.assertEqual(systemctl.call_args_list[0].args[0], ['systemctl', '--user', 'stop', 'owned-unit.service']) + self.assertEqual(systemctl.call_args_list[-1].args[0][-2:], ['--property=ActiveState', '--value']) + + def test_running_cgroup_is_not_mistaken_for_safe_cleanup(self): + result = subprocess.CompletedProcess([], 0, stdout='deactivating\n') + with patch.object(runner, 'run_child'), patch.object(runner.subprocess, 'run', return_value=result): + with self.assertRaisesRegex(runner.ActiveRunnerError, 'Could not confirm'): + runner.run_scoped_runner(Path('/fixture'), {}, 60, 'owned-unit') + + def test_unconfirmed_stop_retains_private_staging_instead_of_deleting_live_files(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + def api(endpoint, **_kwargs): + return [DOWNLOAD] if endpoint.endswith('/downloads') else {'token': 'fixture-token'} + with patch.object(runner, 'check_user_manager'), \ + patch.object(runner, 'reviewed_job', return_value=(RUN, JOB)), \ + patch.object(runner, 'api', side_effect=api), \ + patch.object(runner, 'download', side_effect=lambda _url, _sha, path: path.touch()), \ + patch.object(runner, 'extract'), patch.object(runner, 'run_child'), \ + patch.object(runner, 'owned_runner', return_value=77), \ + patch.object(runner, 'cleanup_registration') as cleanup, \ + patch.object(runner, 'run_scoped_runner', side_effect=runner.ActiveRunnerError('still stopping')): + with self.assertRaises(runner.ActiveRunnerError): + runner.execute(RUN_ID, HEAD, root, 60) + cleanup.assert_called_once() + directories = list(root.iterdir()) + self.assertEqual(len(directories), 1) + self.assertEqual(directories[0].stat().st_mode & 0o777, 0o700) + self.assertTrue((directories[0] / 'home').is_dir()) + # The outer fixture owns and removes this simulated retained tree. + + def test_validation_only_does_not_register_a_runner(self): + with patch.object(runner, 'checkout_head', return_value=HEAD), \ + patch.object(runner, 'reviewed_job', return_value=(RUN, JOB)), \ + patch.object(runner, 'execute') as execute, \ + patch('sys.argv', ['release-runner', '--run-id', str(RUN_ID)]): + self.assertEqual(runner.main(), 0) + execute.assert_not_called() + + def test_timeout_stops_and_reaps_task_owned_process_group(self): + process = MagicMock() + process.__enter__.return_value = process + process.communicate.side_effect = subprocess.TimeoutExpired('fixture', 60) + with patch.object(runner.subprocess, 'Popen', return_value=process), patch.object(runner, 'stop') as stop: + with self.assertRaises(subprocess.TimeoutExpired): + runner.run_child(['fixture'], Path('/fixture'), {}, 60) + stop.assert_called_once_with(process) + process = MagicMock(pid=9876) + process.poll.return_value = None + process.wait.side_effect = [subprocess.TimeoutExpired('fixture', 30), 0] + with patch.object(runner.os, 'killpg') as kill: + runner.stop(process) + self.assertEqual([call.args for call in kill.call_args_list], [(9876, signal.SIGINT), (9876, signal.SIGTERM)]) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_release_signing.py b/image/test_release_signing.py new file mode 100644 index 00000000..d3b3c1d3 --- /dev/null +++ b/image/test_release_signing.py @@ -0,0 +1,97 @@ +"""Exercise the actual RPM/GnuPG/createrepo toolchain with a disposable key.""" +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from github_release import verify_checksums, verify_metadata, verify_release +from release_metadata import channel_payload, public_key +from release_repository import create_repository + + +@unittest.skipUnless(all(shutil.which(tool) for tool in + ('rpmbuild', 'rpmsign', 'rpmkeys', 'createrepo_c', 'gpg', 'gpgconf')), + 'RPM build/signing and repository tools unavailable') +class RealReleaseSigning(unittest.TestCase): + def test_signing_subkey_and_repository_are_independently_verifiable(self): + def run(args): + result = subprocess.run(args, capture_output=True, text=True, check=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout + + with tempfile.TemporaryDirectory(prefix='cybexos-real-signing-') as temporary: + work = Path(temporary) + home = work / 'keyring' + home.mkdir(mode=0o700) + signing_home = work / 'signing-subkey-only' + signing_home.mkdir(mode=0o700) + try: + gpg = ['gpg', '--homedir', str(home), '--batch', '--pinentry-mode', + 'loopback', '--passphrase', ''] + run([*gpg, '--quick-generate-key', 'CybexOS disposable signing fixture', + 'ed25519', 'cert', '1d']) + listing = run([*gpg, '--with-colons', '--list-keys']) + fingerprint = next(line.split(':')[9] for line in listing.splitlines() + if line.startswith('fpr:')) + run([*gpg, '--quick-add-key', fingerprint, 'ed25519', 'sign', '1d']) + key = work / 'fixture.asc' + key.write_text(run([*gpg, '--armor', '--export', fingerprint])) + armor = public_key(key, fingerprint) + # CI receives only the signing subkey, with a primary-key + # stub. Exercise that exact custody split in a fresh keyring. + subkey = run([*gpg, '--armor', '--export-secret-subkeys', fingerprint]) + subprocess.run(['gpg', '--homedir', str(signing_home), '--batch', '--import'], + input=subkey, text=True, capture_output=True, check=True) + baseurl = 'https://fixtures.invalid/CybexOS/44/x86_64' + payload = work / 'payload' + for relative, content in channel_payload(baseurl, fingerprint, armor).items(): + path = payload / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + top = work / 'rpmbuild' + spec = work / 'fixture.spec' + spec.write_text('''Name: cybexos-desktop +Version: 1.0.0 +Release: 1 +Summary: Disposable cryptographic integration fixture +License: MIT +BuildArch: x86_64 +%global _binary_filedigest_algorithm 8 +%description +Fixture only. Never installed or published. +%install +mkdir -p %{buildroot} +cp -a ''' + str(payload) + '''/. %{buildroot}/ +%files +/etc/yum.repos.d/cybexos-desktop.repo +/usr/share/cybexos/update-channel.json +/usr/share/cybexos/update-key.asc +''') + run(['rpmbuild', '--define', '_topdir ' + str(top), '-bb', str(spec)]) + package = next(top.rglob('*.rpm')) + output = work / 'signed' + create_repository([package], output, key, fingerprint, baseurl, signing_home, + 'https://github.com/DigitalPals/CybexOS/releases/download/v1.0.0') + verify_checksums(output) + verify_release(output, fingerprint, work) + manifest = json.loads((output / 'release.json').read_text()) + verify_metadata(output, manifest['packages']) + self.assertTrue(list((output / 'repodata').glob('*-primary.xml.gz'))) + # Cryptographic verification must reject modified signed data, + # even if the unsigned SHA256SUMS inventory is rebuilt. + with (output / 'release.json').open('a') as stream: + stream.write(' ') + verify_home = work / 'tamper-check' + verify_home.mkdir() + with self.assertRaises(subprocess.CalledProcessError): + verify_release(output, fingerprint, verify_home) + finally: + for keyring in (home, signing_home): + subprocess.run(['gpgconf', '--homedir', str(keyring), '--kill', 'all'], + check=False, capture_output=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_update_channel.py b/image/test_update_channel.py new file mode 100644 index 00000000..93b9ba87 --- /dev/null +++ b/image/test_update_channel.py @@ -0,0 +1,131 @@ +"""Enrollment verifies trust before mutation and retains the previous channel.""" +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +loader = importlib.machinery.SourceFileLoader('update_channel', str(Path(__file__).parent / 'rootfs/usr/libexec/cybexos-update-channel')) +spec = importlib.util.spec_from_loader(loader.name, loader) +channel = importlib.util.module_from_spec(spec) +loader.exec_module(channel) +FINGERPRINT = 'A' * 40 + + +class EnrollmentTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.config = self.root / 'public.json' + self.config.write_text(json.dumps({'baseurl': 'https://digitalpals.github.io/CybexOS/44/x86_64', + 'fingerprint': FINGERPRINT, 'key_file': 'public.asc'})) + (self.root / 'etc').mkdir() + (self.root / 'etc/os-release').write_text('ID=fedora\nVERSION_ID=44\n') + self.public = patch.object(channel, 'public_key', return_value=b'public key') + self.public.start() + self.addCleanup(self.public.stop) + self.verify = patch.object(channel, 'verify_repository') + self.verifier = self.verify.start() + self.addCleanup(self.verify.stop) + + def test_unenrolled_system_never_claims_desktop_updates_are_ready(self): + result = channel.status(self.root) + self.assertEqual(result['status'], 'desktop-channel-disabled') + self.assertFalse(result['configured']) + + def test_fingerprint_mismatch_never_contacts_server_or_changes_files(self): + with self.assertRaisesRegex(ValueError, 'fingerprint'): + channel.enroll(self.config, 'B' * 40, self.root) + self.verifier.assert_not_called() + self.assertFalse((self.root / channel.REPO).exists()) + + def test_unverified_repository_cannot_enable_channel(self): + self.verifier.side_effect = ValueError('Bad signature') + with self.assertRaisesRegex(ValueError, 'Bad signature'): + channel.enroll(self.config, FINGERPRINT, self.root) + self.assertFalse((self.root / channel.REPO).exists()) + + def test_check_verifies_without_writing_and_enrollment_is_idempotent(self): + channel.enroll(self.config, FINGERPRINT, self.root, check=True) + self.verifier.assert_called_once() + self.assertFalse((self.root / channel.REPO).exists()) + result = channel.enroll(self.config, FINGERPRINT, self.root) + self.assertEqual(result['status'], 'desktop-channel-ready') + self.assertIn('gpgcheck=1\nrepo_gpgcheck=1', (self.root / channel.REPO).read_text()) + again = channel.enroll(self.config, FINGERPRINT, self.root) + self.assertNotIn('backup', again) + self.assertEqual(len(list((self.root / 'var/lib/cybexos/backups').iterdir())), 1) + + def test_failure_restores_previous_files(self): + repo = self.root / channel.REPO + repo.parent.mkdir(parents=True) + repo.write_text('[cybexos-desktop]\nenabled=0\n') + real_write = channel.atomic_write + + def fail_repo(path, data): + if path == repo and b'enabled=1' in data: + raise OSError('fixture interrupted write') + real_write(path, data) + + with patch.object(channel, 'atomic_write', side_effect=fail_repo): + with self.assertRaises(OSError): + channel.enroll(self.config, FINGERPRINT, self.root) + self.assertEqual(repo.read_text(), '[cybexos-desktop]\nenabled=0\n') + self.assertFalse((self.root / channel.KEY).exists()) + self.assertFalse((self.root / channel.CONFIG).exists()) + + def test_failure_after_publication_restores_in_flight_destination(self): + repo = self.root / channel.REPO + repo.parent.mkdir(parents=True) + original = b'[cybexos-desktop]\nenabled=0\n' + repo.write_bytes(original) + real_write = channel.atomic_write + + def fail_after_rename(path, data): + real_write(path, data) + if path == repo and b'enabled=1' in data: + raise OSError('fixture directory fsync failure') + + with patch.object(channel, 'atomic_write', side_effect=fail_after_rename): + with self.assertRaises(OSError): + channel.enroll(self.config, FINGERPRINT, self.root) + self.assertEqual(repo.read_bytes(), original) + self.assertFalse((self.root / channel.KEY).exists()) + self.assertFalse((self.root / channel.CONFIG).exists()) + + def test_disabled_or_broken_repo_cannot_bypass_existing_key_pin(self): + record = self.root / channel.CONFIG + record.parent.mkdir(parents=True) + record.write_text(json.dumps({'fingerprint': 'B' * 40})) + repo = self.root / channel.REPO + repo.parent.mkdir(parents=True) + for content in ('[cybexos-desktop]\nenabled=0\n', + '[cybexos-desktop]\nenabled=1\ngpgcheck=0\n'): + repo.write_text(content) + with self.assertRaisesRegex(ValueError, 'rotation'): + channel.enroll(self.config, FINGERPRINT, self.root) + self.assertEqual(repo.read_text(), content) + self.verifier.assert_not_called() + + def test_enabled_but_incomplete_channel_is_visible(self): + repo = self.root / channel.REPO + repo.parent.mkdir(parents=True) + repo.write_text('[cybexos-desktop]\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\n') + self.assertEqual(channel.status(self.root)['status'], 'desktop-channel-invalid') + + def test_enrollment_rejects_symlink_targets_without_modifying_referent(self): + repo = self.root / channel.REPO + repo.parent.mkdir(parents=True) + protected = self.root / 'protected' + protected.write_text('preserved') + repo.symlink_to(protected) + with self.assertRaisesRegex(ValueError, 'non-regular'): + channel.enroll(self.config, FINGERPRINT, self.root) + self.assertEqual(protected.read_text(), 'preserved') + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_upgrade_lifecycle.py b/image/test_upgrade_lifecycle.py new file mode 100644 index 00000000..292709d6 --- /dev/null +++ b/image/test_upgrade_lifecycle.py @@ -0,0 +1,230 @@ +"""Exercise installed RPM upgrade ownership, deferred work and retry contracts.""" +from contextlib import nullcontext +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from desktop_payload import prepare_managed_defaults + +ROOT = Path(__file__).resolve().parents[1] + + +def load(name, relative): + loader = importlib.machinery.SourceFileLoader(name, str(ROOT / relative)) + spec = importlib.util.spec_from_loader(name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RECONCILE = load('upgrade_reconcile', 'image/rootfs/usr/libexec/cybexos-reconcile') +INIT = load('upgrade_init', 'image/rootfs/usr/libexec/cybexos-user-init') +MANAGED = load('upgrade_managed', 'image/library/cybexos_managed_file.py') + + +class Ownership(unittest.TestCase): + def test_adopt_upgrade_backup_preserve_edit_and_deletion(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + file, ledger = root / 'file', root / 'state/ownership.json' + file.write_bytes(b'original') + self.assertTrue(MANAGED.manage(file, b'new', ledger)['preserved']) + self.assertEqual(file.read_bytes(), b'original') + self.assertFalse(MANAGED.manage(file, b'original', ledger)['changed']) + self.assertTrue(MANAGED.manage(file, b'new', ledger)['changed']) + backups = [p for p in (ledger.parent / 'backups').rglob('*') if p.is_file()] + self.assertEqual([p.read_bytes() for p in backups], [b'original']) + file.write_bytes(b'personal') + self.assertTrue(MANAGED.manage(file, b'newer', ledger)['preserved']) + file.unlink() + self.assertTrue(MANAGED.manage(file, b'newer', ledger)['preserved']) + self.assertFalse(file.exists()) + + def test_symlinked_parent_never_writes_external_file(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / 'external').mkdir() + (root / 'link').symlink_to(root / 'external') + result = MANAGED.manage(root / 'link/file', b'default', root / 'ledger') + self.assertTrue(result['preserved']) + self.assertFalse((root / 'external/file').exists()) + + def test_failed_publication_recovers_without_losing_ownership(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + file, ledger = root / 'file', root / 'state/ownership.json' + MANAGED.manage(file, b'old', ledger) + atomic = MANAGED.atomic + def fail(path, content, mode=0o600): + if path == file: + raise OSError('disk full') + atomic(path, content, mode) + with patch.object(MANAGED, 'atomic', side_effect=fail): + with self.assertRaises(OSError): + MANAGED.manage(file, b'new', ledger) + self.assertEqual(file.read_bytes(), b'old') + self.assertTrue(MANAGED.manage(file, b'new', ledger)['changed']) + self.assertEqual(file.read_bytes(), b'new') + + def test_owned_removal_and_custom_override_removal(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + file, ledger = root / 'file', root / 'state/ownership.json' + MANAGED.manage(file, b'old', ledger) + self.assertTrue(MANAGED.manage(file, b'', ledger, absent=True)['changed']) + file.write_bytes(b'custom') + self.assertTrue(MANAGED.manage(file, b'', ledger, absent=True)['preserved']) + self.assertEqual(file.read_bytes(), b'custom') + + +class Lifecycle(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.vendor, self.state = self.root / 'vendor', self.root / 'state' + self.vendor.mkdir() + self.version = self.vendor / 'reconcile-version' + self.version.write_text('a' * 64) + self.config = self.root / 'config.yml' + self.config.write_text('saved choices') + self.alice = SimpleNamespace(pw_name='alice', pw_uid=1000, pw_gid=1000, pw_dir='/home/alice') + self.bob = SimpleNamespace(pw_name='bob', pw_uid=1001, pw_gid=1001, pw_dir='/home/bob') + for key, value in (('VENDOR', self.vendor), ('STATE', self.state), ('CONFIG', self.config)): + patcher = patch.object(RECONCILE, key, value) + patcher.start() + self.addCleanup(patcher.stop) + self.users = patch.object(RECONCILE, 'accounts', return_value=[self.alice]).start() + self.idle = patch.object(RECONCILE, 'rpm_idle', side_effect=lambda: nullcontext()).start() + self.apply = patch.object(RECONCILE, 'apply').start() + self.addCleanup(patch.stopall) + + def test_success_stops_convergence_until_version_config_or_account_changes(self): + self.assertEqual(RECONCILE.reconcile()['state'], 'ready') + for _ in range(4): + self.assertEqual(RECONCILE.reconcile()['state'], 'ready') + self.apply.assert_called_once_with(self.alice) + self.version.write_text('b' * 64) + RECONCILE.reconcile() + self.assertEqual(self.apply.call_count, 2) + self.config.write_text('new saved choices') + RECONCILE.reconcile() + self.assertEqual(self.apply.call_count, 3) + self.users.return_value = [self.alice, self.bob] + RECONCILE.reconcile() + self.assertEqual(self.apply.call_count, 4) + self.apply.assert_called_with(self.bob) + + def test_failure_budget_survives_runs_and_explicit_retry_resets_it(self): + self.apply.side_effect = subprocess.CalledProcessError(1, ['fixture']) + for _ in range(6): + state = RECONCILE.reconcile() + self.assertEqual(state['state'], 'blocked') + self.assertEqual(self.apply.call_count, RECONCILE.MAX_ATTEMPTS) + self.assertIn('fixture', state['accounts']['alice']['error']) + self.apply.side_effect = None + self.assertEqual(RECONCILE.reconcile(retry=True)['state'], 'ready') + self.assertEqual(self.apply.call_count, RECONCILE.MAX_ATTEMPTS + 1) + self.assertNotIn('error', RECONCILE.status()['accounts']['alice']) + + def test_completed_accounts_not_repeated_after_other_account_failure(self): + self.users.return_value = [self.alice, self.bob] + def apply(account): + if account.pw_name == 'bob': + raise OSError('fixture failure') + self.apply.side_effect = apply + for _ in range(4): + RECONCILE.reconcile() + self.assertEqual([call.args[0].pw_name for call in self.apply.call_args_list], + ['alice', 'bob', 'bob', 'bob']) + + def test_transaction_busy_defers_without_spending_attempt_budget(self): + self.idle.side_effect = BlockingIOError('RPM busy') + for _ in range(5): + state = RECONCILE.reconcile() + self.apply.assert_not_called() + self.assertEqual(state['state'], 'pending') + self.assertEqual(state['accounts']['alice']['attempts'], 0) + + def test_new_release_makes_old_ready_status_pending(self): + RECONCILE.reconcile() + self.assertFalse(RECONCILE.status()['pending']) + self.version.write_text('b' * 64) + self.assertTrue(RECONCILE.status()['pending']) + self.assertEqual(RECONCILE.status()['state'], 'pending') + + def test_dormant_account_needs_no_running_user_bus(self): + # Invoke the real worker with all subprocesses mocked. A dormant + # account still gets ownership-safe baseline and per-user defaults. + with patch.object(Path, 'exists', return_value=False), patch.object(RECONCILE.subprocess, 'run') as run: + # setUp patches apply; retrieve the function through a fresh module. + worker = load('dormant_reconcile', 'image/rootfs/usr/libexec/cybexos-reconcile') + worker.apply(self.alice) + commands = [call.args[0] for call in run.call_args_list] + self.assertEqual(len(commands), 2) + self.assertIn('--reconcile', commands[0]) + self.assertEqual(commands[1][0], '/usr/sbin/runuser') + + def test_no_accounts_remains_pending(self): + self.users.return_value = [] + self.assertEqual(RECONCILE.reconcile()['state'], 'pending') + self.apply.assert_not_called() + + +class UserUpgrade(unittest.TestCase): + def test_defaults_upgrade_even_after_apps_seeded_and_keep_personal_state(self): + with tempfile.TemporaryDirectory() as temporary, patch.dict(os.environ, {}, clear=True): + root = Path(temporary) + vendor, home = root / 'vendor', root / 'home' + (vendor / 'bin').mkdir(parents=True) + (vendor / 'lib').mkdir() + shutil.copyfile(ROOT / 'image/library/cybexos_managed_file.py', vendor / 'lib/managed_files.py') + relative = '.config/kitty/cybexos.conf' + seed = vendor / 'essential-seed' / relative + seed.parent.mkdir(parents=True) + seed.write_text('version one') + prepare_managed_defaults(vendor) + (vendor / 'reconcile-version').write_text('a' * 64) + (vendor / 'user-seed').mkdir() + (vendor / 'user-seed/app-tool').write_text('tool one') + INIT.initialize(home, vendor) + self.assertEqual((home / relative).read_text(), 'version one') + (home / 'app-tool').write_text('user updated tool') + (vendor / 'managed-seed' / relative).write_text('version two') + (vendor / 'user-seed/app-tool').write_text('tool two') + (vendor / 'reconcile-version').write_text('b' * 64) + INIT.initialize(home, vendor) + self.assertEqual((home / relative).read_text(), 'version two') + self.assertEqual((home / 'app-tool').read_text(), 'user updated tool') + (home / relative).write_text('personal preferences') + (vendor / 'reconcile-version').write_text('c' * 64) + INIT.initialize(home, vendor) + self.assertEqual((home / relative).read_text(), 'personal preferences') + status = json.loads((home / '.local/state/cybexos/defaults-progress.json').read_text()) + self.assertEqual(status['state'], 'ready') + self.assertEqual(status['preserved'], [relative]) + + def test_rpm_posttrans_only_queues_and_bridge_uses_packaged_code(self): + spec = (ROOT / 'image/cybexos-desktop.spec').read_text().split('%posttrans', 1)[1].split('%changelog')[0] + self.assertIn('cybexos-reconcile --queue', spec) + self.assertNotIn('ansible-playbook', spec) + self.assertNotIn('cybexos-configure-installed', spec) + import jinja2 + source = (ROOT / 'roles/desktop/templates/hermes-menubar-bridge.service.j2').read_text() + unit = jinja2.Template(source).render(primary_home='%h', hermes_bridge_executable='/usr/libexec/cybexos-hermes-menubar-bridge') + self.assertIn('ExecStart=/usr/bin/python3 /usr/libexec/cybexos-hermes-menubar-bridge', unit) + self.assertIn('--state %h/.local/state/hermes-menubar/conversations.json', unit) + package = (ROOT / 'image/package').read_text() + self.assertNotIn('f"{seed}/.local/libexec/hermes-menubar-bridge"', package) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/test_user_parity.py b/image/test_user_parity.py index 794031bd..d574acd0 100644 --- a/image/test_user_parity.py +++ b/image/test_user_parity.py @@ -58,6 +58,30 @@ def unit(text): class ProvisioningContract(unittest.TestCase): + def test_offline_gtk_task_skips_private_bus_creation(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + attempted = root / 'bus-started' + bus = root / 'dbus-run-session' + bus.write_text('#!/bin/sh\nprintf attempted > "' + str(attempted) + '"\nexit 99\n') + bus.chmod(0o755) + gtk = dict(task('roles/dotfiles/tasks/personal.yml', + 'Default GTK to dark until the shell applies its appearance')) + gtk['become'] = False + gtk['environment'] = {'PATH': str(root) + ':/usr/bin:/bin'} + playbook = root / 'offline-gtk.yml' + playbook.write_text(yaml.safe_dump([{ + 'hosts': 'localhost', 'connection': 'local', 'gather_facts': False, + 'vars': {'primary_user': 'fixture', 'cybexos_offline': True, + 'manage_personal_dotfiles': True}, + 'tasks': [gtk, {'ansible.builtin.assert': { + 'that': ['dotfiles_gtk_default.skipped | default(false)']}}], + }])) + result = subprocess.run(['ansible-playbook', '-i', 'localhost,', str(playbook)], + text=True, capture_output=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse(attempted.exists()) + def test_workstation_and_image_share_the_same_user_tasks(self): imports = { 'roles/dotfiles/tasks/main.yml': ['environment.yml', 'personal.yml', 'agent-skills.yml'], @@ -98,10 +122,10 @@ def test_moved_workstation_tasks_keep_their_gates(self): task(personal, 'Configure XDG user directories')['ansible.builtin.copy']['content']) firefox = task(personal, 'Converge only the CybexOS Firefox policy entry') self.assertIn('manage_personal_dotfiles', firefox['ansible.builtin.command']['argv'][1]) - # Offline, the installer target may not start a bus; online it must. + # Offline targets leave GTK initialization to the first desktop login. gtk = task(personal, 'Default GTK to dark until the shell applies its appearance') - self.assertEqual(gtk['failed_when'], ['dotfiles_gtk_default.rc != 0', - 'not cybexos_offline | default(false) | bool']) + self.assertIn('not cybexos_offline | default(false) | bool', gtk['when']) + self.assertNotIn('failed_when', gtk) portals = task('roles/desktop/tasks/portals.yml', 'Configure portal preference without patching Fedora files') content = portals['ansible.builtin.copy']['content'] self.assertIn('org.freedesktop.impl.portal.FileChooser=gtk', content) diff --git a/image/test_welcome_setup.py b/image/test_welcome_setup.py new file mode 100644 index 00000000..7f8f0f58 --- /dev/null +++ b/image/test_welcome_setup.py @@ -0,0 +1,60 @@ +"""Welcome's setup probes must not outlive the window or run on early exits.""" +import importlib.machinery +import importlib.util +import os +from pathlib import Path +import unittest +from unittest.mock import patch + +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') +loader = importlib.machinery.SourceFileLoader('welcome_setup', str(Path(__file__).parent / 'rootfs/usr/bin/cybexos-welcome')) +spec = importlib.util.spec_from_loader(loader.name, loader) +welcome = importlib.util.module_from_spec(spec) +loader.exec_module(welcome) + + +class SetupLifecycle(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = welcome.QGuiApplication.instance() or welcome.QGuiApplication([]) + + def test_constructor_performs_no_probes_before_autostart_or_singleton_checks(self): + with patch.object(welcome.QProcess, 'start') as start: + backend = welcome.Welcome() + self.assertFalse(backend.setup_timer.isActive()) + self.assertFalse(backend.setup_timeout.isActive()) + start.assert_not_called() + self.assertEqual(backend.network_process.processEnvironment().value('LC_ALL'), 'C') + + def test_missing_helper_has_error_state_without_reading_deleted_qprocess(self): + backend = welcome.Welcome() + backend.networkFailed(welcome.QProcess.FailedToStart) + backend.reconcileFailed(welcome.QProcess.FailedToStart) + self.assertEqual(backend.networkState, 'offline') + self.assertEqual(backend.reconcileState, 'error') + + def test_live_session_cannot_launch_installed_repair_actions(self): + backend = welcome.Welcome() + backend.live = True + with patch.object(welcome.QProcess, 'startDetached') as start: + backend.retryApps() + backend.retryHardware() + backend.startSetup() + start.assert_not_called() + self.assertFalse(backend.setup_timer.isActive()) + + def test_probe_timeout_is_bounded_and_shutdown_stops_timers(self): + backend = welcome.Welcome() + with patch.object(welcome.QProcess, 'state', return_value=welcome.QProcess.Running), \ + patch.object(welcome.QProcess, 'kill') as kill: + backend.setupTimedOut() + self.assertEqual(kill.call_count, 2) + backend.setup_timer.start() + backend.setup_timeout.start() + backend.stopSetup() + self.assertFalse(backend.setup_timer.isActive()) + self.assertFalse(backend.setup_timeout.isActive()) + + +if __name__ == '__main__': + unittest.main() diff --git a/image/tests b/image/tests index 7e44f9e9..0e63597a 100755 --- a/image/tests +++ b/image/tests @@ -131,7 +131,7 @@ class OfflineApplications(unittest.TestCase): for package in ("pinentry-gnome3", "fwupd-efi", "libcamera", "libcamera-ipa", "pipewire-plugin-libcamera", "nss-mdns", "hunspell-en", "bzip2", "7zip", "cifs-utils", "kernel-tools", "docker-buildx", "ibus-setup", - "intel-compute-runtime", "igt-gpu-tools"): + "intel-compute-runtime", "igt-gpu-tools", "intel-media-driver"): self.assertIn(package, packages) def test_kickstart_leaves_docker_on_demand_and_resolves_local_names(self): @@ -307,6 +307,7 @@ class WelcomeTests(unittest.TestCase): self.assertEqual(self.module.Welcome.SHELL_PAGES, { "appearance": ["settings", "open", "appearance"], "wallpaper": ["wallpaper", "browse"], + "network": ["settings", "open", "network"], }) backend = self.backend() with patch.object(self.module.QProcess, "startDetached", return_value=(True, 0)) as start: diff --git a/image/upgrade_qualification.py b/image/upgrade_qualification.py new file mode 100644 index 00000000..c3cdbdd8 --- /dev/null +++ b/image/upgrade_qualification.py @@ -0,0 +1,154 @@ +"""Offline N→N+1 RPM and Btrfs recovery checks inside a task-owned VM.""" +import json +from pathlib import Path +import re + +from build_support import digest +from vm_testing import run + +CANDIDATE = '/tmp/cybexos-qualification-candidate.rpm' +SNAPSHOT = '/usr/libexec/cybexos-system-snapshot' +MANAGED_MARKER = '# cybexos qualification: preserve this user edit' + + +def prepare_user_choices(vm, password, root_script): + script = r'''python3 - <<'PY' +import json +from pathlib import Path +home = Path('/home/qualification') +managed = home / '.config/kitty/cybexos.conf' +assert managed.is_file(), 'Managed Kitty setting is missing before upgrade' +marker = b'\n# cybexos qualification: preserve this user edit\n' +original = managed.read_bytes() +assert marker not in original +managed.write_bytes(original + marker) +settings = home / '.config/cybexos/shell.json' +data = json.loads(settings.read_text()) +# A pristine seed can omit position; the desktop's effective default is top. +position = data.get('position', 'top') +assert position in ('top', 'bottom') +data['position'] = 'bottom' if position == 'top' else 'top' +settings.write_text(json.dumps(data, indent=2) + '\n') +(home / 'qualification-personal-marker').write_text('qualification-preserve\n') +print(data['position']) +PY +chown qualification:qualification /home/qualification/qualification-personal-marker +''' + desired = root_script(vm, script, password).stdout.strip() + if desired not in ('top', 'bottom'): + raise RuntimeError('User preference fixture did not return a valid position') + return desired + + +def verify_user_choices(vm, password, root_script, position): + if position not in ('top', 'bottom'): + raise ValueError('Unknown qualification preference') + script = "python3 - <<'PY'\nimport json\nfrom pathlib import Path\n" + script += "home = Path('/home/qualification')\n" + script += "assert (home / 'qualification-personal-marker').read_text() == 'qualification-preserve\\n'\n" + script += "assert (home / '.config/kitty/cybexos.conf').read_text().count(" + repr(MANAGED_MARKER) + ") == 1\n" + script += "assert json.loads((home / '.config/cybexos/shell.json').read_text())['position'] == " + repr(position) + "\nPY\n" + root_script(vm, script, password) + + +def inspect_version(vm): + script = '''python3 - <<'PY' +import json, subprocess +import rpm +def fields(command): + result = subprocess.check_output(command, text=True).splitlines() + assert len(result) == 4 and result[0] == 'cybexos-desktop' + return {'name': result[0], 'epoch': result[1], 'version': result[2], 'release': result[3]} +format = '%{NAME}\\n%{EPOCHNUM}\\n%{VERSION}\\n%{RELEASE}\\n' +installed = fields(['rpm', '-q', '--qf', format, 'cybexos-desktop']) +candidate = fields(['rpm', '-qp', '--qf', format, '/tmp/cybexos-qualification-candidate.rpm']) +comparison = rpm.labelCompare( + (candidate['epoch'], candidate['version'], candidate['release']), + (installed['epoch'], installed['version'], installed['release'])) +print(json.dumps({'installed': installed, 'candidate': candidate, 'comparison': comparison})) +PY +''' + return json.loads(run([*vm.ssh, 'bash -s'], input=script, text=True, + capture_output=True, timeout=30).stdout) + + +def copy_candidate(vm, candidate): + original = Path(candidate) + if original.is_symlink(): + raise ValueError('Candidate RPM must be a regular .rpm file') + candidate = original.resolve(strict=True) + if not candidate.is_file() or candidate.suffix != '.rpm': + raise ValueError('Candidate RPM must be a regular .rpm file') + expected = digest(candidate) + with candidate.open('rb') as stream: + run([*vm.ssh, f'cat > {CANDIDATE}'], stdin=stream, capture_output=True, timeout=300) + actual = run([*vm.ssh, f'sha256sum {CANDIDATE}'], text=True, capture_output=True, + timeout=30).stdout.split()[0] + if actual != expected: + raise RuntimeError('Candidate RPM changed in transfer to guest') + return expected + + +def create_recovery_point(vm, password, root_script): + root_script(vm, "test -f /usr/libexec/cybexos-system-snapshot\n", password) + point = root_script(vm, f'{SNAPSHOT} create qualification-before-rpm-upgrade\n', password, + timeout=300).stdout.strip().splitlines()[-1] + if not re.fullmatch(r'[0-9A-Za-z_-]{8,80}', point): + raise RuntimeError('Recovery point ID was not valid') + index = json.loads(root_script(vm, f'{SNAPSHOT} list --json\n', password).stdout) + if not index.get('bootMenu') or not any(item.get('id') == point and item.get('bootable') + for item in index.get('points', [])): + raise RuntimeError('Recovery point did not have a bootable GRUB entry') + return point + + +def upgrade(vm, candidate, password, root_script): + sha = copy_candidate(vm, candidate) + versions = inspect_version(vm) + if versions['comparison'] <= 0: + raise RuntimeError('Candidate RPM is not newer than the baseline installed RPM') + config_before = root_script(vm, 'sha256sum /etc/cybexos/config.yml\n', password).stdout.split()[0] + root_script(vm, f"dnf -y --disablerepo='*' install {CANDIDATE}\n" + "systemctl daemon-reload\n" + "systemctl start cybexos-reconcile.service\n", password, timeout=1200) + status = json.loads(root_script(vm, '/usr/libexec/cybexos-reconcile --status\n', password).stdout) + if (status.get('state') != 'ready' or status.get('pending') is not False + or status.get('version') != status.get('desiredVersion')): + raise RuntimeError('Installed RPM reconciliation did not finish successfully') + accounts = status.get('accounts', {}) + if not isinstance(accounts, dict) or accounts.get('qualification', {}).get('state') != 'ready': + raise RuntimeError('Installed account reconciliation did not finish successfully') + after = inspect_version(vm) + if after['installed'] != versions['candidate']: + raise RuntimeError('Installed desktop RPM did not match the candidate version') + root_script(vm, f"test ! -e {CANDIDATE}.password\n", password) + config_after = root_script(vm, 'sha256sum /etc/cybexos/config.yml\n', password).stdout.split()[0] + if config_after != config_before: + raise RuntimeError('RPM upgrade changed saved installation choices') + return sha, versions + + +def select_recovery_boot(vm, point, password, root_script): + if not re.fullmatch(r'[0-9A-Za-z_-]{8,80}', point): + raise ValueError('Invalid recovery point ID') + script = "python3 - <<'PY'\nimport subprocess\n" + script += "subprocess.run(['grub2-reboot', " + repr('cybexos-recovery>cybexos-recovery-' + point) + "], check=True)\nPY\n" + root_script(vm, script, password) + + +def verify_recovery_boot(vm, point, password, root_script): + index = json.loads(root_script(vm, f'{SNAPSHOT} list --json\n', password).stdout) + if index.get('recoveryBoot') != point: + raise RuntimeError('VM did not boot the requested recovery point') + if not any(item.get('id') == point for item in index.get('points', [])): + raise RuntimeError('Requested recovery point was unavailable after boot') + root_script(vm, f'{SNAPSHOT} restore {point}\n', password, timeout=600) + + +def verify_restored(vm, before, password, root_script): + script = "test \"$(cat /home/qualification/qualification-personal-marker)\" = qualification-preserve\n" + root_script(vm, script, password) + result = run([*vm.ssh, 'rpm -q --qf "%{NAME}\\n%{EPOCHNUM}\\n%{VERSION}\\n%{RELEASE}\\n" cybexos-desktop'], + text=True, capture_output=True, timeout=30).stdout.splitlines() + if result != [before[key] for key in ('name', 'epoch', 'version', 'release')]: + raise RuntimeError('Recovery restore did not return to the baseline desktop RPM') diff --git a/image/vm_testing.py b/image/vm_testing.py index b38bbbd0..2489f7c3 100644 --- a/image/vm_testing.py +++ b/image/vm_testing.py @@ -2,12 +2,14 @@ import ctypes import os import re +import secrets import signal from pathlib import Path import shlex import shutil import socket import subprocess +import tempfile import time from build_support import SAFE_NAME, atomic_json, prepare_firmware, validate_qemu_path, verify_sidecar @@ -16,12 +18,42 @@ # Virtio block identifiers are limited to 20 bytes in the guest protocol. QUALIFICATION_DISK_SERIAL = "CYBEXOS-QUALIFY" +QUALIFICATION_UNUSED_SERIAL = "CYBEXOS-UNUSED" def run(args, **kwargs): return subprocess.run(args, check=True, **kwargs) +def poweroff_guest(vm, password, root_script, *, timeout=90, cleanup_script=''): + """Accept a shutdown SSH disconnect only after preparation and clean QEMU exit.""" + root_script(vm, 'sync\n', password) + marker = 'CYBEXOS_POWEROFF_READY_' + secrets.token_hex(16) + # Final access removal must share this connection with shutdown: no new + # SSH connection can authenticate after authorized_keys has been removed. + script = cleanup_script + "\nsync\nprintf '%s\\n' " + shlex.quote(marker) + script += '\nsystemctl poweroff --no-block\n' + disconnected = False + try: + root_script(vm, script, password) + except subprocess.CalledProcessError as error: + if error.returncode != 255 or marker not in (error.stdout or '').splitlines(): + raise + disconnected = True + vm.ssh_ready = False + try: + code = vm.process.wait(timeout=timeout) + except subprocess.TimeoutExpired as error: + detail = ' after SSH disconnected' if disconnected else '' + raise RuntimeError(f'Guest poweroff did not stop QEMU within {timeout}s{detail}') from error + if code != 0: + raise RuntimeError(f'Guest poweroff ended with QEMU exit {code}') + if vm.console: + vm.console.close() + vm.console = None + vm.release_runtime() + + def free_port(): with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) @@ -74,16 +106,11 @@ def audit_script(): if not Path('/run/cybexos-live').exists(): import pwd assert pwd.getpwuid(os.getuid()).pw_shell == '/usr/bin/fish' - subprocess.run(['sudo', '-k', '-n', 'true'], check=True) assert Path('/etc/cybexos/hardware.json').is_file() for unit in ('hyprpolkitagent', 'hypridle', 'voxtype'): subprocess.run(['systemctl', '--user', 'is-active', unit], check=True) for unit in ('tuned-ppd', 'fwupd-refresh.timer', 'cybexos-hardware-setup.timer'): subprocess.run(['systemctl', 'is-enabled', unit], check=True) - for scope in ([], ['--permanent']): - for protocol in ('tcp', 'udp'): - subprocess.run(['sudo', '-n', 'firewall-cmd', *scope, '--zone=cybexos', - '--query-port=53317/' + protocol], check=True) aliases = subprocess.check_output(['fish', '-ic', 'functions codex claude'], text=True) assert '--dangerously-bypass-approvals-and-sandbox' in aliases assert '--dangerously-skip-permissions' in aliases @@ -110,10 +137,11 @@ def stop_with_harness(): class TestVM: """Own exactly one disposable virtual disk; never attach host block devices.""" - def __init__(self, work, firmware="uefi", memory=16384): + def __init__(self, work, firmware="uefi", memory=16384, guard_disk=False): self.work = validate_qemu_path(Path(work).resolve()) self.firmware = firmware self.memory = memory + self.guard_disk = guard_disk self.owned = False self.process = None self.console = None @@ -121,24 +149,40 @@ def __init__(self, work, firmware="uefi", memory=16384): self.port = free_port() self.vnc_port = free_port() self.disk = self.work / "installed.qcow2" + self.unused_disk = self.work / "unused.qcow2" self.key = self.work / "id_ed25519" self.ssh_ready = False self.qmp_path = self.work / "qmp.sock" + self.runtime = None + + def release_runtime(self): + """Discard only this VM's private socket directory after it stops.""" + if self.runtime is None: + return + if self.process is not None and self.process.poll() is None: + raise RuntimeError("Cannot remove QMP runtime while the VM is running") + shutil.rmtree(self.runtime) + self.runtime = None - def screen_text(self): + def screen_text(self, timeout=20): """Read a disposable guest screenshot; never retain password entry frames.""" - if not shutil.which("tesseract"): - raise RuntimeError("Encrypted boot qualification requires tesseract for prompt recognition") screenshot = self.work / "prompt.png" - qmp = Qmp(str(self.qmp_path)) + qmp = None try: + if not shutil.which("tesseract"): + raise RuntimeError("Encrypted boot qualification requires tesseract for prompt recognition") + qmp = Qmp(str(self.qmp_path)) + qmp.socket.settimeout(min(10, timeout)) qmp.call("screendump", {"filename": str(screenshot), "format": "png"}) return run(["tesseract", str(screenshot), "stdout", "--psm", "11"], - text=True, capture_output=True, timeout=20).stdout + text=True, capture_output=True, timeout=timeout).stdout finally: - qmp.stream.close() - qmp.socket.close() screenshot.unlink(missing_ok=True) + if qmp is not None: + try: + qmp.stream.close() + finally: + qmp.socket.close() def unlock_disk(self, password, timeout=180): """Type once, only after recognizing the disk-unlock prompt. @@ -173,12 +217,25 @@ def prepare(self): self.work.chmod(0o700) self.owned = True run(["qemu-img", "create", "-q", "-f", "qcow2", str(self.disk), "100G"]) + if self.guard_disk: + run(["qemu-img", "create", "-q", "-f", "qcow2", str(self.unused_disk), "100G"]) run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(self.key)]) def start(self, iso=None, user="liveuser"): if self.process is not None and self.process.poll() is None: raise RuntimeError("Test VM is already running") - self.qmp_path.unlink(missing_ok=True) + self.release_runtime() + # Workflow artifact paths can exceed AF_UNIX's 107-byte pathname + # limit. Do not inherit a similarly long RUNNER_TEMP/TMPDIR here. + self.runtime = Path(tempfile.mkdtemp(prefix="cybexos-qmp-", dir="/tmp")) + self.qmp_path = self.runtime / "qmp.sock" + try: + self._start(iso, user) + except BaseException: + self.stop() + raise + + def _start(self, iso, user): self.ssh_ready = False self.ssh = ["ssh", "-i", str(self.key), "-p", str(self.port), "-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=10", "-o", "BatchMode=yes", "-o", "ConnectTimeout=3", "-o", "StrictHostKeyChecking=accept-new", "-o", f"UserKnownHostsFile={self.work / 'known_hosts'}", f"{user}@127.0.0.1"] @@ -190,12 +247,18 @@ def start(self, iso=None, user="liveuser"): "-netdev", f"user,id=net,restrict=on,hostfwd=tcp:127.0.0.1:{self.port}-:22", "-device", "virtio-net-pci,netdev=net", "-vnc", f"127.0.0.1:{self.vnc_port - 5900}", "-serial", f"file:{self.work / 'serial.log'}", "-qmp", f"unix:{self.qmp_path},server=on,wait=off", "-monitor", "none"] + if self.guard_disk: + args += ["-drive", f"file={self.unused_disk},format=qcow2,if=none,id=unused-disk,werror=report,rerror=report", + "-device", f"virtio-blk-pci,drive=unused-disk,serial={QUALIFICATION_UNUSED_SERIAL}"] if iso is not None: args += ["-cdrom", str(require_test_iso(iso)), "-boot", "d"] self.console = (self.work / "qemu.log").open("a") self.process = subprocess.Popen(args, stdout=self.console, stderr=subprocess.STDOUT, process_group=0, preexec_fn=stop_with_harness) - atomic_json(self.work / "vm.json", {"pid": self.process.pid, "ssh": self.ssh, "vnc_port": self.vnc_port, "qmp": str(self.qmp_path), "disk": str(self.disk)}) + state = {"pid": self.process.pid, "ssh": self.ssh, "vnc_port": self.vnc_port, "qmp": str(self.qmp_path), "disk": str(self.disk)} + if self.guard_disk: + state['unused_disk'] = str(self.unused_disk) + atomic_json(self.work / "vm.json", state) def alive(self): if self.process.poll() is not None: @@ -217,7 +280,7 @@ def type(self, text): qmp.stream.close() qmp.socket.close() - def wait_ssh(self, timeout=300, setup=True, setup_password=None): + def wait_ssh(self, timeout=300, setup=True, setup_password=None, *, redactions=()): """Poll SSH readiness and bootstrap only inside a recognized terminal. No guest debug agent is shipped. Keyboard injection remains the transport @@ -226,6 +289,7 @@ def wait_ssh(self, timeout=300, setup=True, setup_password=None): """ deadline, next_attempt, attempt = time.monotonic() + timeout, 0, 0 desktop_observed = False + last_error = "No SSH attempt completed" public = self.key.with_suffix(".pub").read_text().strip() command = "mkdir -p ~/.ssh; printf '%s\\n' " + shlex.quote(public) command += " > ~/.ssh/authorized_keys; chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys; " @@ -238,19 +302,30 @@ def wait_ssh(self, timeout=300, setup=True, setup_password=None): else: command += "sudo restorecon -RF ~/.ssh; sudo systemctl start sshd; sudo firewall-cmd --add-service=ssh" command = "HISTFILE=/dev/null; set +o history; " + command + "; exit\n" - while time.monotonic() < deadline: + while (remaining := deadline - time.monotonic()) > 0: self.alive() - if subprocess.run([*self.ssh, "true"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0: - self.ssh_ready = True - return + try: + result = subprocess.run([*self.ssh, "true"], stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, text=True, timeout=min(10, remaining)) + except subprocess.TimeoutExpired as error: + detail = error.stderr or "" + if isinstance(detail, bytes): + detail = detail.decode(errors="replace") + last_error = "SSH probe timed out: " + detail + else: + if result.returncode == 0: + self.ssh_ready = True + return + last_error = f"SSH exit {result.returncode}: {getattr(result, 'stderr', '') or ''}" if setup and time.monotonic() >= next_attempt and self.qmp_path.exists(): # Typing even public shell commands at GRUB can enter its - # editor and prevent boot. Both fresh fixtures show Welcome; - # require its actual desktop text before sending any keys. + # editor and prevent boot. Require an actual desktop surface; + # the installer may cover Welcome on current live images. if not desktop_observed: screen = " ".join(self.screen_text().lower().split()) desktop_observed = any(phrase in screen for phrase in ( - "make yourself at home", "welcome to your new desktop")) + "make yourself at home", "welcome to your new desktop", + "your next workspace", "make it yours")) if not desktop_observed: next_attempt = time.monotonic() + 5 time.sleep(1) @@ -277,7 +352,118 @@ def wait_ssh(self, timeout=300, setup=True, setup_password=None): self.keypress("meta_l+q") next_attempt = time.monotonic() + 15 time.sleep(1) - raise RuntimeError("SSH/desktop readiness deadline exceeded; inspect VM through vm-control") + # Collect one read-only frame after the unchanged readiness deadline. + # OCR/QMP failures must not hide the SSH failure or retain the frame. + try: + screen = self.screen_text(timeout=5) + except Exception as error: + screen = f"Unavailable ({type(error).__name__}: {error})" + def redacted(value, limit): + for secret in (setup_password, *redactions): + if secret: + value = value.replace(secret, "[redacted]") + return value.strip()[-limit:] + raise RuntimeError("SSH/desktop readiness deadline exceeded; " + f"last SSH: {redacted(last_error, 1800)}; " + f"screen OCR: {redacted(screen, 2200)}") + + def bootstrap_installed_ssh(self, password, timeout=120): + """Use a verified text console, then switch its keymap to US for setup. + + This works after a plain install without desktop autologin and after a + non-US install without guessing how punctuation maps in Hyprland. + Passwords are typed only at a recognized login/sudo prompt. + """ + self.keypress("ctrl+alt+f3") + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if re.search(r'login\s*:', self.screen_text(), re.IGNORECASE): + break + self.alive() + time.sleep(2) + else: + raise RuntimeError("Installed text-console login prompt was not recognized") + self.type("qualification\n") + self._wait_password_prompt(deadline) + self.type(password + "\n") + # A fresh standalone output line proves execution; the echoed command + # cannot satisfy it. These letters also work before US/NL/DE keymap + # normalization. Probe Y separately because German swaps Y and Z. + shell_command = "echo CONSOLEWORKS y\n" + last_screen, next_probe, probes = "", time.monotonic() + 2, 0 + while time.monotonic() < deadline: + last_screen = self.screen_text() + lines = [re.sub(r"[^A-Z]", "", line.upper()) for line in last_screen.splitlines()] + match = next((re.fullmatch(r"CONSOLEWORKS([YZ])", line) for line in lines + if re.fullmatch(r"CONSOLEWORKS([YZ])", line)), None) + if match: + y_key = "z" if match.group(1) == "Z" else "y" + break + if re.search(r'login incorrect|authentication failure', last_screen, re.IGNORECASE): + raise RuntimeError("Installed console login failed; no setup commands were sent") + # PAM or Fish initialization can flush an early line. Retry only + # this harmless probe, at most three times; never resend a secret. + if probes < 3 and time.monotonic() >= next_probe: + self.type(shell_command) + probes += 1 + next_probe = time.monotonic() + 10 + self.alive() + time.sleep(1) + else: + detail = last_screen.replace(password, '[redacted]')[-1800:] + raise RuntimeError(f"Installed text-console shell was not confirmed: {detail}") + self.type("clear\n") + time.sleep(1) + # A command-output marker handles both passwordless baseline sudo and + # current passworded sudo without assuming Fish's prompt contains @. + self.type("sudo echo CONSOLEAUTH\n") + sent_password = False + while time.monotonic() < deadline: + last_screen = self.screen_text() + if console_output(last_screen, "CONSOLEAUTH"): + break + if sudo_password_prompt(last_screen): + if not sent_password: + self.type(password + "\n") + sent_password = True + self.alive() + time.sleep(1) + else: + detail = last_screen.replace(password, '[redacted]')[-1800:] + raise RuntimeError(f"Installed sudo authentication was not confirmed: {detail}") + self.type("sudo loadke" + y_key + "s us\n") + time.sleep(2) + self.type("exec env HISTFILE=/dev/null bash --noprofile --norc\n") + time.sleep(1) + # Confirm the Bash transition and punctuation/keymap before setup. + # The contiguous marker appears only in printf's output. + self.type("HISTFILE=/dev/null; set +o history; printf 'CONSOLE%sREADY\\n' \"${BASH_VERSION:+BASH}\"\n") + while time.monotonic() < deadline: + last_screen = self.screen_text() + if console_output(last_screen, "CONSOLEBASHREADY"): + break + self.alive() + time.sleep(1) + else: + detail = last_screen.replace(password, '[redacted]')[-1800:] + raise RuntimeError(f"Installed Bash/keymap setup was not confirmed: {detail}") + public = self.key.with_suffix(".pub").read_text().strip() + command = "HISTFILE=/dev/null; set +o history; mkdir -p ~/.ssh; printf '%s\\n' " + command += shlex.quote(public) + " > ~/.ssh/authorized_keys; chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys; " + command += "sudo -n restorecon -RF /home/qualification/.ssh; sudo -n systemctl start sshd; " + command += "sudo -n firewall-cmd --add-service=ssh; exit\n" + self.type(command) + self.wait_ssh(timeout=max(15, int(deadline - time.monotonic())), setup=False, + redactions=(password,)) + + def _wait_password_prompt(self, deadline): + while time.monotonic() < deadline: + screen = " ".join(self.screen_text().lower().split()) + if re.search(r'password\s*:|passwort\s*:|wachtwoord\s*:', screen): + return + self.alive() + time.sleep(1) + raise RuntimeError("Installed password prompt was not recognized; no password was typed") def wait_desktop(self, timeout=120): deadline = time.monotonic() + timeout @@ -287,7 +473,12 @@ def wait_desktop(self, timeout=120): if subprocess.run([*self.ssh, command], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0: return time.sleep(1) - raise RuntimeError("Quickshell readiness deadline exceeded") + diagnosis = subprocess.run( + [*self.ssh, "systemctl --user status quickshell.service --no-pager; " + "journalctl --user -b -u quickshell.service --no-pager -n 35"], + capture_output=True, text=True, timeout=20) + details = (diagnosis.stdout + diagnosis.stderr).strip()[-6000:] + raise RuntimeError(f"Quickshell readiness deadline exceeded: {details}") def audit(self, applications=True): self.wait_desktop() @@ -322,7 +513,9 @@ def stop(self, graceful=False): self.process.wait() if self.console: self.console.close() + self.console = None self.ssh_ready = False + self.release_runtime() def cleanup(self, keep_artifacts=False): if not self.owned: @@ -333,7 +526,7 @@ def cleanup(self, keep_artifacts=False): for name in ("id_ed25519", "id_ed25519.pub", "known_hosts", "vm.json", "qmp.sock", "prompt.png"): (self.work / name).unlink(missing_ok=True) if not keep_artifacts: - for name in ("installed.qcow2", "OVMF_VARS.fd", "OVMF_VARS.qcow2", "serial.log", "qemu.log"): + for name in ("installed.qcow2", "unused.qcow2", "OVMF_VARS.fd", "OVMF_VARS.qcow2", "serial.log", "qemu.log"): (self.work / name).unlink(missing_ok=True) @@ -342,3 +535,21 @@ def is_disk_prompt(text): compact = " ".join(text.lower().split()) return bool(re.search(r"(?:passphrase|password).{0,180}(?:disk|luks|volume|crypt)", compact) or re.search(r"(?:disk|luks|volume|crypt).{0,180}(?:passphrase|password)", compact)) + + +def console_output(text, marker): + """Match an output line, never a prompt's echoed command containing it.""" + return any(re.sub(r"[^A-Z0-9]", "", line.upper()) == marker + for line in text.splitlines()) + + +def sudo_password_prompt(text): + """Only recognize a sudo password request for the disposable account.""" + canonical = re.search( + r'(?:\[sudo\]\s*)?(?:password\s+for|passwort\s+f[uü]r|wachtwoord\s+voor)\s+qualification\s*:', + text, re.IGNORECASE) + # Tesseract read the Dutch sudo prompt's initial w as u on a real boot. + # Require the full sudo prefix and fixture account for that one OCR form. + dutch_ocr = re.search(r'\[sudo\]\s*uachtwoord\s+voor\s+qualification\s*:', + text, re.IGNORECASE) + return bool(canonical or dutch_ocr) diff --git a/roles/base/tasks/accounts.yml b/roles/base/tasks/accounts.yml index a84a3ee3..0057690d 100644 --- a/roles/base/tasks/accounts.yml +++ b/roles/base/tasks/accounts.yml @@ -59,6 +59,15 @@ creates: "/var/lib/systemd/linger/{{ primary_user }}" when: not cybexos_offline | default(false) | bool +- name: Create the offline user lingering directory + ansible.builtin.file: + path: /var/lib/systemd/linger + state: directory + owner: root + group: root + mode: "0755" + when: cybexos_offline | default(false) | bool + - name: Enable user lingering in the offline installation target ansible.builtin.copy: dest: "/var/lib/systemd/linger/{{ primary_user }}" diff --git a/roles/base/tasks/reconcile-scrub.yml b/roles/base/tasks/reconcile-scrub.yml new file mode 100644 index 00000000..cd65d8e8 --- /dev/null +++ b/roles/base/tasks/reconcile-scrub.yml @@ -0,0 +1,57 @@ +--- +# Upgrade only scrub units whose content is still vendor-owned. +- name: Read the root filesystem type + ansible.builtin.command: findmnt --noheadings --output FSTYPE --target / + register: base_root_filesystem + changed_when: false + check_mode: false + +- name: Install weekly Btrfs scrub unit + cybexos_managed_file: + dest: /etc/systemd/system/cybexos-btrfs-scrub.service + mode: "0644" + content: | + [Unit] + Description=Scrub all mounted Btrfs filesystems + ConditionPathIsMountPoint=/ + # Persistent=true catches a missed run up at the next boot or resume; + # never spend a battery charge reading the whole disk. + ConditionACPower=true + + [Service] + Type=oneshot + Nice=19 + IOSchedulingClass=idle + # The idle I/O class needs a scheduler that honors it, and NVMe uses + # none. Cap the read rate so a scrub stays in the background regardless. + ExecStart=/usr/bin/btrfs scrub start -B -d --limit 200M / + notify: Reload systemd + when: base_root_filesystem.stdout | trim == 'btrfs' + +- name: Install weekly Btrfs scrub timer + cybexos_managed_file: + dest: /etc/systemd/system/cybexos-btrfs-scrub.timer + mode: "0644" + content: | + [Unit] + Description=Weekly Btrfs scrub + + [Timer] + OnCalendar=Sun *-*-* 03:30:00 + RandomizedDelaySec=45m + Persistent=true + + [Install] + WantedBy=timers.target + notify: Reload systemd + when: base_root_filesystem.stdout | trim == 'btrfs' + +- name: Remove Btrfs-only scrub units on other filesystems + cybexos_managed_file: + dest: "/etc/systemd/system/{{ item }}" + state: absent + loop: + - cybexos-btrfs-scrub.service + - cybexos-btrfs-scrub.timer + notify: Reload systemd + when: base_root_filesystem.stdout | trim != 'btrfs' diff --git a/roles/base/tasks/reconcile.yml b/roles/base/tasks/reconcile.yml new file mode 100644 index 00000000..3cbdaa68 --- /dev/null +++ b/roles/base/tasks/reconcile.yml @@ -0,0 +1,72 @@ +--- +# Upgrade reconciliation runs outside RPM, performs no package operations, and +# never resets a person's login shell or replaces locally edited policy files. +- name: Read the installed account identity + ansible.builtin.getent: + database: passwd + key: "{{ primary_user }}" + +- name: Ensure selected account groups exist + ansible.builtin.group: + name: "{{ item }}" + state: present + loop: "{{ base_account_groups + ((features.docker | bool and docker_sudoless | bool) | ternary(['docker'], [])) }}" + +- name: Add selected account groups while preserving the login shell + ansible.builtin.user: + name: "{{ primary_user }}" + groups: "{{ (base_account_groups + ((features.docker | bool and docker_sudoless | bool) | ternary(['docker'], []))) | join(',') }}" + append: true + +- name: Reconcile owned sudo policy + cybexos_managed_file: + dest: /etc/sudoers.d/10-wheel-nopasswd + mode: "0440" + state: "{{ passwordless_wheel | bool | ternary('present', 'absent') }}" + content: "%wheel ALL=(ALL:ALL) NOPASSWD: ALL\n" + +- name: Reconcile owned local Polkit policy + cybexos_managed_file: + dest: /etc/polkit-1/rules.d/49-wheel-local.rules + state: "{{ passwordless_local_polkit | bool | ternary('present', 'absent') }}" + content: | + polkit.addRule(function(action, subject) { + if (subject.isInGroup("wheel") && subject.active && subject.local) { + return polkit.Result.YES; + } + }); + +- name: Reconcile owned firewall defaults + cybexos_managed_file: + dest: /etc/firewalld/zones/cybexos.xml + content: "{{ lookup('template', 'cybexos-zone.xml.j2') }}" + register: reconcile_firewall + +- name: Reload updated firewall policy + ansible.builtin.systemd_service: + name: firewalld.service + state: reloaded + when: reconcile_firewall is changed + +- name: Preserve the selected authselect profile while enabling multicast DNS + ansible.builtin.import_tasks: mdns.yml + +- name: Enable the account user manager + ansible.builtin.command: + argv: [loginctl, enable-linger, "{{ primary_user }}"] + creates: "/var/lib/systemd/linger/{{ primary_user }}" + +- name: Reconcile owned filesystem maintenance defaults + ansible.builtin.import_tasks: reconcile-scrub.yml + +- name: Reconcile owned login policy + cybexos_managed_file: + dest: /etc/pam.d/sddm-autologin + content: "{{ lookup('file', '/usr/share/cybexos/login/sddm-autologin', rstrip=false) }}" + register: reconcile_login + +- name: Restore the login policy security label + ansible.builtin.command: + argv: [restorecon, /etc/pam.d/sddm-autologin] + changed_when: false + when: reconcile_login is changed diff --git a/roles/desktop/files/quickshell/Bar/Modules/Battery.qml b/roles/desktop/files/quickshell/Bar/Modules/Battery.qml index f16ea37d..530772c1 100644 --- a/roles/desktop/files/quickshell/Bar/Modules/Battery.qml +++ b/roles/desktop/files/quickshell/Bar/Modules/Battery.qml @@ -35,9 +35,7 @@ BarModule { // same brighter hover foreground as every other menubar icon. hoverColor: root.critical ? Theme.barRedText : root.low ? Theme.barAmber : Theme.barTextHi - tooltip: "Battery " + root.level + "%" - + (Battery.charging ? " · charging" - : Battery.full ? " · fully charged" : "") + tooltip: "Battery " + root.level + "% · " + Battery.statusText tooltipAlign: 1 Item { diff --git a/roles/desktop/files/quickshell/Common/Battery.qml b/roles/desktop/files/quickshell/Common/Battery.qml index 4faea8a9..beff505c 100644 --- a/roles/desktop/files/quickshell/Common/Battery.qml +++ b/roles/desktop/files/quickshell/Common/Battery.qml @@ -19,11 +19,11 @@ Singleton { readonly property real percent: StatusHelpers.batteryPercent(device) - // "charging" | "discharging" | "full" | "" — full stays distinct from - // charging. The bar draws both as plugged in and only its tooltip says - // which; the popover names them apart. + // Power source and charge state are independent at a firmware charge limit. readonly property string state: StatusHelpers.chargeState(device) - readonly property bool pluggedIn: StatusHelpers.isPluggedIn(device) + readonly property bool pluggedIn: StatusHelpers.isPluggedIn(device, UPower.onBattery) readonly property bool charging: state === "charging" readonly property bool full: state === "full" + readonly property string statusText: StatusHelpers.batteryStatus( + state, pluggedIn, BatteryHealth.known && BatteryHealth.enabled) } diff --git a/roles/desktop/files/quickshell/Common/StatusHelpers.js b/roles/desktop/files/quickshell/Common/StatusHelpers.js index ceb8a417..91007418 100644 --- a/roles/desktop/files/quickshell/Common/StatusHelpers.js +++ b/roles/desktop/files/quickshell/Common/StatusHelpers.js @@ -53,24 +53,36 @@ var BATTERY_STATE = { PendingDischarge: 6 }; -// The single definition of charge semantics for bar and popover. "full" is -// deliberately distinct from "charging" — the popover names the two states -// apart while the bar draws both as plugged in — and everything else, -// including having no battery at all, counts as running on battery. +// PendingCharge includes firmware charge preservation: AC is connected but +// no energy is entering the battery. Never promise time-to-full in that state. function chargeState(device) { var state = device ? device.state : undefined; - if (state === BATTERY_STATE.Charging || state === BATTERY_STATE.PendingCharge) + if (state === BATTERY_STATE.Charging) return "charging"; + if (state === BATTERY_STATE.PendingCharge) + return "pending-charge"; if (state === BATTERY_STATE.FullyCharged) return "full"; return "discharging"; } // Charging or already full: what the bar's glyph and accent color mean. -function isPluggedIn(device) { +function isPluggedIn(device, onBattery) { + if (typeof onBattery === "boolean") + return !onBattery; return chargeState(device) !== "discharging"; } +function batteryStatus(state, pluggedIn, limited) { + if (!pluggedIn) + return "On battery"; + if (state === "charging") + return "Charging"; + if (state === "full") + return "Fully charged"; + return limited ? "Plugged in · Charge limited" : "Plugged in · Not charging"; +} + // ---- media --------------------------------------------------------------- // Quickshell's MprisPlaybackState enum; pinned by the same qmltypes test as @@ -200,6 +212,7 @@ var exported = { signalPercent: signalPercent, BATTERY_STATE: BATTERY_STATE, chargeState: chargeState, + batteryStatus: batteryStatus, isPluggedIn: isPluggedIn, PLAYBACK_STATE: PLAYBACK_STATE, PLAYER_GLYPH: PLAYER_GLYPH, diff --git a/roles/desktop/files/quickshell/Common/Updates.qml b/roles/desktop/files/quickshell/Common/Updates.qml index 75b20eab..04e6bf8d 100644 --- a/roles/desktop/files/quickshell/Common/Updates.qml +++ b/roles/desktop/files/quickshell/Common/Updates.qml @@ -154,6 +154,8 @@ Singleton { return total + (total === 1 ? " update" : " updates") + " available"; if (checkError !== "") return checkError; + if (projectStatus === "desktop-channel-disabled" || projectStatus === "desktop-channel-invalid") + return "System checked · Desktop updates unavailable"; return "Up to date"; } diff --git a/roles/desktop/files/quickshell/Common/UpdatesHelpers.js b/roles/desktop/files/quickshell/Common/UpdatesHelpers.js index fe172e81..eae0f6fc 100644 --- a/roles/desktop/files/quickshell/Common/UpdatesHelpers.js +++ b/roles/desktop/files/quickshell/Common/UpdatesHelpers.js @@ -739,10 +739,17 @@ function projectErrorOf(record) { // the channel has nothing published yet. Not an error, so it never feeds // projectError; anything else (including older updaters) is "". function projectStatusOf(record) { - return record && record.status === "no-release" ? "no-release" : ""; + var known = ["no-release", "desktop-channel-ready", "desktop-channel-disabled", "desktop-channel-invalid"]; + return record && known.indexOf(record.status) >= 0 ? record.status : ""; } function projectStatusLabel(status) { + if (status === "desktop-channel-disabled") + return "CybexOS desktop updates are not configured"; + if (status === "desktop-channel-invalid") + return "CybexOS desktop update channel needs repair"; + if (status === "desktop-channel-ready") + return "CybexOS desktop updates arrive with system packages"; return status === "no-release" ? "No CybexOS release published yet" : ""; } diff --git a/roles/desktop/files/quickshell/Popovers/BatteryPopover.qml b/roles/desktop/files/quickshell/Popovers/BatteryPopover.qml index ce976c4e..92001388 100644 --- a/roles/desktop/files/quickshell/Popovers/BatteryPopover.qml +++ b/roles/desktop/files/quickshell/Popovers/BatteryPopover.qml @@ -16,15 +16,14 @@ Surface { readonly property int level: Math.round(Battery.percent) readonly property real chargeFraction: Math.max(0, Math.min(1, Battery.percent / 100)) - readonly property bool discharging: Battery.state === "discharging" + readonly property bool discharging: !Battery.pluggedIn readonly property bool critical: discharging && Battery.percent <= Settings.modOpts.batt.critAt readonly property bool warning: discharging && !critical && Battery.percent <= Settings.modOpts.batt.warnAt readonly property color batteryTone: critical ? Theme.red : warning ? Theme.amber : Theme.accent - readonly property string statusText: Battery.full ? "Fully charged" - : Battery.charging ? "Charging" : "On battery" + readonly property string statusText: Battery.statusText readonly property string batteryGlyph: critical ? "battery_alert" : level >= 95 ? "battery_full" : level >= 80 ? "battery_6_bar" @@ -32,8 +31,8 @@ Surface { : level >= 50 ? "battery_4_bar" : level >= 35 ? "battery_3_bar" : level >= 20 ? "battery_2_bar" : "battery_1_bar" - readonly property string estimateLabel: Battery.charging || Battery.full - ? "Time to full" : "Time remaining" + readonly property string estimateLabel: Battery.charging ? "Time to full" + : discharging ? "Time remaining" : "Charge estimate" readonly property real estimateSeconds: !displayDevice ? 0 : Battery.charging ? displayDevice.timeToFull : discharging ? displayDevice.timeToEmpty : 0 diff --git a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerPower.qml b/roles/desktop/files/quickshell/Popovers/Drawer/DrawerPower.qml index 84ca72b9..2e821c6a 100644 --- a/roles/desktop/files/quickshell/Popovers/Drawer/DrawerPower.qml +++ b/roles/desktop/files/quickshell/Popovers/Drawer/DrawerPower.qml @@ -23,8 +23,7 @@ Column { : Battery.charging ? displayDevice.timeToFull : discharging ? displayDevice.timeToEmpty : 0 readonly property string statusLine: { - const parts = [Battery.full ? "Fully charged" - : Battery.charging ? "Charging" : "On battery"]; + const parts = [Battery.statusText]; if (estimateSeconds > 0) parts.push(BatteryView.formatDuration(estimateSeconds)); if (displayDevice && displayDevice.changeRate > 0) diff --git a/roles/desktop/files/quickshell/Popovers/UpdatesPopover.qml b/roles/desktop/files/quickshell/Popovers/UpdatesPopover.qml index f5282b45..718a2c8e 100644 --- a/roles/desktop/files/quickshell/Popovers/UpdatesPopover.qml +++ b/roles/desktop/files/quickshell/Popovers/UpdatesPopover.qml @@ -113,7 +113,8 @@ Surface { return "Restart required"; if (checking) return "Checking for updates"; - return Updates.checkError !== "" ? "Updates" : "Up to date"; + return Updates.checkError !== "" || Updates.projectStatus === "desktop-channel-disabled" + || Updates.projectStatus === "desktop-channel-invalid" ? "Updates need attention" : "Up to date"; } readonly property string status: { diff --git a/roles/desktop/files/quickshell/scripts/update-client b/roles/desktop/files/quickshell/scripts/update-client index 9863d8b1..a82c7ba3 100644 --- a/roles/desktop/files/quickshell/scripts/update-client +++ b/roles/desktop/files/quickshell/scripts/update-client @@ -11,12 +11,16 @@ data_root=${XDG_DATA_HOME:-$HOME/.local/share}/cybexos config_file=${CYBEXOS_CONFIG_FILE:-/etc/cybexos/config.yml} release_updater=${CYBEXOS_RELEASE_UPDATE:-$data_root/current/update} backend=${CYBEXOS_UPDATE_BACKEND:-$HOME/.local/bin/cybexos-update-run} +rpm_channel=${CYBEXOS_UPDATE_CHANNEL:-/usr/libexec/cybexos-update-channel} managed=false [[ -x $release_updater && -r $config_file ]] && managed=true case $command_name in check) + if [[ -x $rpm_channel ]]; then + exec "$rpm_channel" status --json + fi if [[ $managed == true ]]; then exec "$release_updater" --check --json "$@" fi diff --git a/roles/desktop/templates/hermes-menubar-bridge.service.j2 b/roles/desktop/templates/hermes-menubar-bridge.service.j2 index 04509cbd..1474b168 100644 --- a/roles/desktop/templates/hermes-menubar-bridge.service.j2 +++ b/roles/desktop/templates/hermes-menubar-bridge.service.j2 @@ -7,7 +7,7 @@ Type=simple Environment=HOME={{ primary_home }} Environment=PATH={{ primary_home }}/.local/bin:/usr/local/bin:/usr/bin Environment=PYTHONUNBUFFERED=1 -ExecStart=/usr/bin/python3 {{ primary_home }}/.local/libexec/hermes-menubar-bridge --listen 127.0.0.1 --port 9120 --remote-only --state {{ primary_home }}/.local/state/hermes-menubar/conversations.json --remote-auth-state {{ primary_home }}/.local/state/hermes-menubar/remote-webui-auth.json +ExecStart=/usr/bin/python3 {{ hermes_bridge_executable | default(primary_home + "/.local/libexec/hermes-menubar-bridge") }} --listen 127.0.0.1 --port 9120 --remote-only --state {{ primary_home }}/.local/state/hermes-menubar/conversations.json --remote-auth-state {{ primary_home }}/.local/state/hermes-menubar/remote-webui-auth.json # The bridge exits cleanly only when asked to stop. A crash is retried quickly # at first, but the delay grows to 5 minutes so a startup failure (port taken, # missing python3-websockets, unwritable state) cannot restart it every two diff --git a/roles/desktop/templates/looknfeel.lua.j2 b/roles/desktop/templates/looknfeel.lua.j2 index 5d47047d..f38725ba 100644 --- a/roles/desktop/templates/looknfeel.lua.j2 +++ b/roles/desktop/templates/looknfeel.lua.j2 @@ -279,10 +279,10 @@ if power_saver.restore then hl.config(power_saver_config) end hl.window_rule({ match = { title = [[^CybexOS Settings$]] }, float = true, center = true }) -- The first-login welcome floats in the middle of the screen it opens on, at -- half the monitor's width and 48% of its height (1280x692 on a 2560x1440 --- desktop), and never below the window's own minimum of 760x580. Sizes are +-- desktop), and never below the window's own minimum of 760x660. Sizes are -- expressions: Hyprland 0.56 ignores "50%"-style strings, and the client's -- own guess is sized for Qt's primary screen, not necessarily this one. -hl.window_rule({ match = { class = [[^cybex$]], title = [[^Welcome to CybexOS$]] }, float = true, center = true, size = "max(760,monitor_w*0.5) max(580,monitor_h*0.48)" }) +hl.window_rule({ match = { class = [[^cybex$]], title = [[^Welcome to CybexOS$]] }, float = true, center = true, size = "max(760,monitor_w*0.5) max(660,monitor_h*0.48)" }) hl.window_rule({ match = { class = [[xdg-desktop-portal-gtk]] }, float = true }) hl.window_rule({ match = { class = [[org\.gnome\.Nautilus]], title = [[Properties]] }, float = true }) hl.window_rule({ match = { class = [[org\.gnome\.Nautilus]], title = [[Open.*]] }, float = true }) diff --git a/roles/dotfiles/tasks/personal.yml b/roles/dotfiles/tasks/personal.yml index 0f7e7d2c..4782f467 100644 --- a/roles/dotfiles/tasks/personal.yml +++ b/roles/dotfiles/tasks/personal.yml @@ -213,12 +213,26 @@ set -euo pipefail schema=org.gnome.desktop.interface # dconf writes go through a session bus, which Ansible does not have. - gs() { dbus-run-session -- gsettings "$@"; } - if [[ $(gs get "$schema" color-scheme) == "'default'" ]]; then + # Activated services must not inherit Ansible/command-substitution pipes: + # gvfsd-fuse can outlive its private bus and keep those pipes open forever. + capture=$(mktemp -d) + trap 'rm -rf -- "$capture"' EXIT + gs() { + local output status=0 + output=$(mktemp -d "$capture/call.XXXXXXXX") || return + GIO_USE_VFS=local timeout --kill-after=2s 15s dbus-run-session -- gsettings "$@" \ + >"$output/stdout" 2>"$output/stderr" || status=$? + cat "$output/stderr" >&2 + cat "$output/stdout" + return "$status" + } + color_scheme=$(gs get "$schema" color-scheme) + if [[ $color_scheme == "'default'" ]]; then gs set "$schema" color-scheme prefer-dark printf 'CHANGED: color-scheme prefer-dark\n' fi - if [[ $(gs get "$schema" gtk-theme) == "'Adwaita'" ]]; then + gtk_theme=$(gs get "$schema" gtk-theme) + if [[ $gtk_theme == "'Adwaita'" ]]; then gs set "$schema" gtk-theme adw-gtk3-dark printf 'CHANGED: gtk-theme adw-gtk3-dark\n' fi @@ -226,12 +240,9 @@ executable: /bin/bash register: dotfiles_gtk_default changed_when: "'CHANGED:' in dotfiles_gtk_default.stdout" - # An installer target may be unable to start even a private bus. The shell - # applies its own appearance at the first login, so only a live converge or - # repair has to succeed here. - failed_when: - - dotfiles_gtk_default.rc != 0 - - not cybexos_offline | default(false) | bool + # Offline targets have no desktop session. The shell applies these defaults + # at first login; only a live converge or repair initializes dconf here. when: - manage_personal_dotfiles | bool - not ansible_check_mode + - not cybexos_offline | default(false) | bool diff --git a/roles/xps-2026/defaults/main.yml b/roles/xps-2026/defaults/main.yml index f1bc9b71..f1c52377 100644 --- a/roles/xps-2026/defaults/main.yml +++ b/roles/xps-2026/defaults/main.yml @@ -19,7 +19,7 @@ xps_2026_vesa_backlight_edid_products: xps_2026_haptic_intensity: high xps_2026_camera_enabled: true -xps_2026_camera_bundle_version: 1.0.5.xps6 +xps_2026_camera_bundle_version: 1.0.5.xps7 xps_2026_camera_sources: - name: ipu7-drivers commit: a88b19096a738d0708742a78d6540d6d4a3021ff diff --git a/roles/xps-2026/files/camera/patches/0006-camera-hal-native-cvs-bridge.patch b/roles/xps-2026/files/camera/patches/0006-camera-hal-native-cvs-bridge.patch new file mode 100644 index 00000000..181be49f --- /dev/null +++ b/roles/xps-2026/files/camera/patches/0006-camera-hal-native-cvs-bridge.patch @@ -0,0 +1,85 @@ +From: CybexOS +Subject: [PATCH] camera-hal: follow the native CVS bridge on OV08X40 + +Fedora's native CVS exposes sensor -> CVS -> IPU7 CSI2. Resolve the actual +sensor I2C address through that bridge and configure its two pads and links. +Retain the direct sensor topology used with the legacy CVS driver. + +--- a/src/v4l2/MediaControl.cpp ++++ b/src/v4l2/MediaControl.cpp +@@ -1074,22 +1074,16 @@ + sinkEntityName.c_str()); + CheckAndLogError(!i2cBus, UNKNOWN_ERROR, "i2cBus is nullptr"); + ++ // Native CVS inserts a bridge between the sensor and CSI receiver. Match ++ // the actual sensor entity reachable from that receiver, not the bridge's ++ // name interpreted as an I2C address. ++ MediaEntity* sink = getEntityByName(sinkEntityName); ++ CheckAndLogError(!sink, UNKNOWN_ERROR, "CSI receiver is missing"); ++ const std::string prefix = sensorEntityName + " "; + for (auto& entity : mEntities) { +- int linksCount = entity.info.links; +- MediaLink* links = entity.links; +- char* entityName = nullptr; +- size_t sensorEntityNameLen = sensorEntityName.length(); +- for (int i = 0; i < linksCount; i++) { +- if (strcmp(links[i].sink->entity->info.name, sinkEntityName.c_str()) == 0) { +- entityName = entity.info.name; +- break; +- } +- } +- +- // entityName example: "imx319 10-0010", sensorEntityName example: "imx319" +- if (entityName && (strlen(entityName) > (sensorEntityNameLen + 1U))) { +- *i2cBus = entityName + sensorEntityNameLen + 1; +- LOG1("i2cBus is %s", i2cBus->c_str()); ++ if (strncmp(entity.info.name, prefix.c_str(), prefix.size()) == 0 && ++ checkHasSource(sink, entity.info.name)) { ++ *i2cBus = entity.info.name + prefix.size(); + return OK; + } + } +--- a/src/platformdata/CameraSensorsParser.cpp ++++ b/src/platformdata/CameraSensorsParser.cpp +@@ -210,7 +210,23 @@ + if (ele.isMember("enable")) { + link.enable = ele["enable"].asBool(); + } +- conf->links.push_back(link); ++ // The native CVS driver is a CSI bridge; legacy CVS has no media ++ // entity. Adapt the OV08X40 profile only when that bridge is present. ++ if (mMediaCtl && link.srcEntityName.find("ov08x40 ") == 0 && ++ link.sinkEntityName.find("Intel IPU7 CSI2 ") == 0 && ++ mMediaCtl->checkAvailableSensor("Intel CVS")) { ++ McLink downstream = link; ++ downstream.srcEntityName = "Intel CVS"; ++ downstream.srcEntity = mMediaCtl->getEntityIdByName("Intel CVS"); ++ downstream.srcPad = 1; ++ link.sinkEntityName = "Intel CVS"; ++ link.sinkEntity = downstream.srcEntity; ++ link.sinkPad = 0; ++ conf->links.push_back(link); ++ conf->links.push_back(downstream); ++ } else { ++ conf->links.push_back(link); ++ } + } + } + +@@ -275,6 +291,16 @@ + + fmt.formatType = FC_FORMAT; + conf->formats.push_back(fmt); ++ if (mMediaCtl && fmt.entityName.find("ov08x40 ") == 0 && fmt.pad == 0 && ++ mMediaCtl->checkAvailableSensor("Intel CVS")) { ++ McFormat bridge = fmt; ++ bridge.entityName = "Intel CVS"; ++ bridge.entity = mMediaCtl->getEntityIdByName("Intel CVS"); ++ bridge.pad = 0; ++ conf->formats.push_back(bridge); ++ bridge.pad = 1; ++ conf->formats.push_back(bridge); ++ } + } + } + diff --git a/roles/xps-2026/files/camera/v4l2-relayd-override.conf b/roles/xps-2026/files/camera/v4l2-relayd-override.conf index bea32c8b..5aa90552 100644 --- a/roles/xps-2026/files/camera/v4l2-relayd-override.conf +++ b/roles/xps-2026/files/camera/v4l2-relayd-override.conf @@ -13,4 +13,9 @@ PrivateNetwork=no InaccessibleDirectories= ReadOnlyDirectories= DevicePolicy=auto +# Do not retain a producer sample. Two loopback buffers keep GStreamer capture +# clients on their copy/requeue path; with three, v4l2loopback can return a +# still-held buffer again during the splash-to-camera transition. +ExecStart= +ExecStart=/bin/sh -c 'DEVICE=$(grep -l -m1 -E "^${CARD_LABEL}$" /sys/devices/virtual/video4linux/*/name | cut -d/ -f6); exec /usr/bin/v4l2-relayd -i "${VIDEOSRC}" $${SPLASHSRC:+-s "${SPLASHSRC}"} -o "appsrc name=appsrc caps=video/x-raw,format=${FORMAT},width=${WIDTH},height=${HEIGHT},framerate=${FRAMERATE} ! videoconvert ! v4l2sink name=v4l2sink device=/dev/$${DEVICE} enable-last-sample=false" $EXTRA_OPTS' ExecStartPost=-/usr/local/libexec/xps-ipu7-refresh-wireplumber diff --git a/roles/xps-2026/files/camera/xps-ipu7-abi-check b/roles/xps-2026/files/camera/xps-ipu7-abi-check index 1caa22ba..96c35db8 100755 --- a/roles/xps-2026/files/camera/xps-ipu7-abi-check +++ b/roles/xps-2026/files/camera/xps-ipu7-abi-check @@ -66,11 +66,58 @@ EXPECTED = { 16, (("base", 0), ("ipdata", 8)), ), + "ipu7_auxdrv_data": ( + 24, (("isr", 0), ("isr_threaded", 8), ("wake_isr_thread", 16)), + ), + "ipu7_syscom_context": ( + 48, (("num_input_queues", 0), ("num_output_queues", 2), + ("queue_configs", 8), ("queue_indices", 16), + ("queue_mem_dma_addr", 24), ("queue_mem", 32), ("queue_mem_size", 40)), + ), + "syscom_queue_config": ( + 16, (("token_array_mem", 0), ("queue_size", 8), + ("token_size_in_bytes", 12), ("max_capacity", 14)), + ), + "ia_gofo_boot_config": ( + 276, (("length", 0), ("config_version", 4), ("client_version_support", 8), + ("pkg_dir", 24), ("subsys_config", 28), ("uc_tile_frequency", 32), + ("checksum", 36), ("uc_tile_frequency_units", 38), ("padding", 39), + ("reserved", 40), ("syscom_context_config", 272)), + ), + "syscom_config_s": ( + 4, (("max_output_queues", 0), ("max_input_queues", 2)), + ), + "syscom_queue_params_config": ( + 8, (("token_array_mem", 0), ("token_size_in_bytes", 4), ("max_capacity", 6)), + ), +} + +# Fedora 7.2 grows the public auxiliary_device by eight bytes. PSYS uses the +# matching kernel headers, so its following fields move with that embedded +# object. This second complete signature was reviewed against stock 7.2.7 BTF; +# continue rejecting any other private layout rather than accepting arbitrary +# shifts or relaxing individual offset checks. +REVIEWED_ALTERNATES = { + "ipu7_bus_device": ( + 1000, + ( + ("auxdev", 0), ("auxdrv", 864), ("auxdrv_data", 872), + ("list", 880), ("subsys", 896), ("pdata", 904), ("mmu", 912), + ("isp", 920), ("ctrl", 928), ("dma_mask", 936), ("fw_sgt", 944), + ("fw_entry", 960), ("syscom", 968), ("boot_config", 976), + ("boot_config_dma_addr", 984), ("boot_config_size", 992), + ), + ), } MEMBER_RE = re.compile( - r"^\s*.*?\b([A-Za-z_][A-Za-z0-9_]*)\s*(?:\[[^]]+\])?;" + r"^\s*.*?\b([A-Za-z_][A-Za-z0-9_]*)\s*(?:\[[^]]+\])?" + r"\s*(?:__attribute__\(\([^;]+\)\))?;" + r"\s*/\*\s*(\d+)\s+\d+\s*\*/\s*$" +) +CALLBACK_RE = re.compile( + r"^\s*.*?\(\*([A-Za-z_][A-Za-z0-9_]*)\)\([^;]*\);" r"\s*/\*\s*(\d+)\s+\d+\s*\*/\s*$" ) SIZE_RE = re.compile(r"/\*\s*size:\s*(\d+),.*members:\s*(\d+)\s*\*/") @@ -93,7 +140,7 @@ def parse_layout(output: str) -> tuple[int, tuple[tuple[str, int], ...]]: size = None declared_members = None for line in output.splitlines(): - if match := MEMBER_RE.match(line): + if match := CALLBACK_RE.match(line) or MEMBER_RE.match(line): members.append((match.group(1), int(match.group(2)))) if match := SIZE_RE.search(line): size = int(match.group(1)) @@ -139,12 +186,16 @@ def stock_module(kernel: str, name: str) -> Path: return path -def check_kernel(kernel: str) -> None: +def layout_reviewed(structure: str, actual: tuple) -> bool: + return actual == EXPECTED[structure] or actual == REVIEWED_ALTERNATES.get(structure) + + +def check_kernel(kernel: str, *, external_cvs: bool = False) -> None: if not shutil.which("pahole"): raise AbiChanged("pahole is missing (install Fedora's dwarves package)") kernel_modules = Path(f"/usr/lib/modules/{kernel}/kernel") - for companion in ("intel-ipu7-psys.ko*", "intel_cvs.ko*"): + for companion in ("intel-ipu7-psys.ko*", "intel_ipu7_psys.ko*"): native = next(kernel_modules.rglob(companion), None) if native is not None: raise AbiChanged( @@ -152,6 +203,12 @@ def check_kernel(kernel: str) -> None: "refusing to shadow it with this DKMS bundle" ) + native_cvs = next(kernel_modules.rglob("intel_cvs.ko*"), None) + if native_cvs is not None: + stock_module(kernel, "intel_cvs") + if external_cvs: + raise AbiChanged("native Intel CVS is present; refusing the external CVS build") + base_module = stock_module(kernel, "intel_ipu7") stock_module(kernel, "intel_ipu7_isys") kernel_tree = Path(f"/usr/src/kernels/{kernel}") @@ -196,7 +253,7 @@ def check_kernel(kernel: str) -> None: if result.returncode: raise AbiChanged(f"stock module lacks BTF for struct {structure}") actual = parse_layout(result.stdout) - if actual != expected: + if not layout_reviewed(structure, actual): raise AbiChanged( f"struct {structure} layout differs: expected {expected}, got {actual}" ) @@ -217,6 +274,19 @@ struct sample { pass else: raise AssertionError("incomplete layout was accepted") + assert layout_reviewed("ipu7_bus_device", EXPECTED["ipu7_bus_device"]) + assert layout_reviewed("ipu7_bus_device", REVIEWED_ALTERNATES["ipu7_bus_device"]) + size, fields = REVIEWED_ALTERNATES["ipu7_bus_device"] + assert not layout_reviewed("ipu7_bus_device", (size + 8, fields)) + assert not layout_reviewed("ipu7_bus_device", (size, fields[:-1])) + callback_fixture = """ +struct callbacks { + void (*handler)(struct device *); /* 0 8 */ + void * base __attribute__((__aligned__(8))); /* 8 8 */ + /* size: 16, cachelines: 1, members: 2 */ +}; +""" + assert parse_layout(callback_fixture) == (16, (("handler", 0), ("base", 8))) print("IPU7 ABI parser self-test passed") @@ -224,9 +294,16 @@ def main() -> int: if len(sys.argv) == 2 and sys.argv[1] == "--self-test": self_test() return 0 - kernel = sys.argv[1] if len(sys.argv) == 2 else command_output(["uname", "-r"]) + args = sys.argv[1:] + external_cvs = bool(args and args[0] == "--external-cvs") + if external_cvs: + args = args[1:] + if len(args) > 1 or (args and args[0].startswith("--")): + print("usage: xps-ipu7-abi-check [--external-cvs] [kernel]", file=sys.stderr) + return 2 + kernel = args[0] if args else command_output(["uname", "-r"]) try: - check_kernel(kernel) + check_kernel(kernel, external_cvs=external_cvs) except AbiChanged as error: print( "IPU7_STOCK_ABI_CHANGED: Fedora's stock IPU7 ABI no longer matches " diff --git a/roles/xps-2026/files/camera/xps-ipu7-camera-check b/roles/xps-2026/files/camera/xps-ipu7-camera-check index cb14e780..489c1031 100644 --- a/roles/xps-2026/files/camera/xps-ipu7-camera-check +++ b/roles/xps-2026/files/camera/xps-ipu7-camera-check @@ -58,14 +58,8 @@ check 'no IPU7 kernel fault in this boot' bash -c \ "! journalctl -k -b --no-pager 2>/dev/null | grep -Eq 'RIP: .*ipu7|BUG:.*ipu7'" if [[ ${XPS_CAMERA_FRAME_TEST:-0} == 1 ]]; then - frame_log=$(mktemp) - timeout 10s v4l2-ctl --device=/dev/video50 --stream-mmap \ - --stream-count=3 --stream-to=/dev/null --verbose >"$frame_log" 2>&1 || true - if [[ $(grep -c 'cap dqbuf:' "$frame_log") -ge 3 ]]; then - pass 'captured three frames from the Hardware ISP camera' - else - fail "frame capture smoke test (details: $frame_log)" - fi + check_diagnostic 'three fresh Hardware ISP camera frames after warm-up' \ + /usr/local/libexec/xps-ipu7-camera-frame-check fi ((failures == 0)) diff --git a/roles/xps-2026/files/camera/xps-ipu7-camera-frame-check b/roles/xps-2026/files/camera/xps-ipu7-camera-frame-check new file mode 100644 index 00000000..d8b65b75 --- /dev/null +++ b/roles/xps-2026/files/camera/xps-ipu7-camera-frame-check @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Verify fresh relay frames in memory without saving camera images.""" + +import hashlib +import os +import re +import subprocess +import sys + + +def main(): + environment = dict(os.environ, LC_ALL="C") + details = subprocess.run( + ["v4l2-ctl", "--device=/dev/video50", "--get-fmt-video"], + capture_output=True, text=True, check=True, timeout=5, env=environment, + ).stdout + match = re.search(r"Size Image\s*:\s*(\d+)", details) + if not match or not 0 < int(match[1]) <= 32 * 1024 * 1024: + raise RuntimeError("Cannot determine a bounded camera frame size") + size = int(match[1]) + # The relay initially supplies splash/initialized loopback buffers. Skip a + # warm-up interval and require different complete images, not merely three + # successful dequeues. stdout is an anonymous pipe; pixels never reach disk. + captured = subprocess.run( + ["v4l2-ctl", "--device=/dev/video50", "--stream-mmap", + "--stream-skip=30", "--stream-count=3", "--stream-to=-"], + capture_output=True, check=True, timeout=15, env=environment, + ).stdout + if len(captured) != 3 * size: + raise RuntimeError("Did not receive three complete camera frames") + fingerprints = { + hashlib.sha256(captured[offset:offset + size]).digest() + for offset in range(0, len(captured), size) + } + if len(fingerprints) < 2: + raise RuntimeError("Only repeated/splash frames received; fresh images not proven") + print("Three fresh camera frames verified after warm-up; no images saved") + + +if __name__ == "__main__": + try: + main() + except (OSError, RuntimeError, subprocess.SubprocessError) as error: + print(f"Camera frame validation failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/roles/xps-2026/files/camera/xps-ipu7-camera-stack.spec b/roles/xps-2026/files/camera/xps-ipu7-camera-stack.spec index a66f4e6d..03039e43 100644 --- a/roles/xps-2026/files/camera/xps-ipu7-camera-stack.spec +++ b/roles/xps-2026/files/camera/xps-ipu7-camera-stack.spec @@ -21,7 +21,8 @@ Requires: gstreamer1-plugins-base The Fedora-specific extension required to expose the OVTI08F4/OV08X40 camera on the 2026 Dell XPS. Fedora's stock kernel continues to supply IPU7 base/ISYS, the sensor driver, ACPI bridge, and firmware. This package carries only pinned -PSYS and CVS DKMS sources plus the Intel HAL, binary interface, and icamerasrc. +PSYS and optional legacy CVS DKMS sources plus the Intel HAL, binary interface, +and icamerasrc. The native CVS driver is retained when provided by Fedora. %prep diff --git a/roles/xps-2026/tasks/camera.yml b/roles/xps-2026/tasks/camera.yml index cc482247..7c2485d9 100644 --- a/roles/xps-2026/tasks/camera.yml +++ b/roles/xps-2026/tasks/camera.yml @@ -223,6 +223,8 @@ dest: /usr/local/share/xps-2026/camera/patches/0004-ipu7-psys-register-bus.patch - src: camera/patches/0005-ipu7-psys-stock-core-layout.patch dest: /usr/local/share/xps-2026/camera/patches/0005-ipu7-psys-stock-core-layout.patch + - src: camera/patches/0006-camera-hal-native-cvs-bridge.patch + dest: /usr/local/share/xps-2026/camera/patches/0006-camera-hal-native-cvs-bridge.patch - src: camera/xps-ipu7-camera-stack.spec dest: /usr/local/share/xps-2026/camera/xps-ipu7-camera-stack.spec tags: [xps-2026, hardware, camera] @@ -341,7 +343,9 @@ - src: v4l2-relayd-override.conf dest: /etc/systemd/system/v4l2-relayd@ipu7.service.d/override.conf mode: "0644" - notify: Reload XPS 2026 systemd + notify: + - Reload XPS 2026 systemd + - Restart XPS 2026 camera tags: [xps-2026, hardware, camera] - name: Load the fail-closed relay condition before package work @@ -1171,6 +1175,9 @@ - src: xps-ipu7-camera-check dest: /usr/local/libexec/xps-ipu7-camera-check mode: "0755" + - src: xps-ipu7-camera-frame-check + dest: /usr/local/libexec/xps-ipu7-camera-frame-check + mode: "0755" - src: xps-ipu7-camera-sleep dest: /usr/lib/systemd/system-sleep/xps-ipu7-camera mode: "0755" diff --git a/roles/xps-2026/tasks/packages.yml b/roles/xps-2026/tasks/packages.yml index fd8e8d41..0ae11649 100644 --- a/roles/xps-2026/tasks/packages.yml +++ b/roles/xps-2026/tasks/packages.yml @@ -7,6 +7,8 @@ - cirrus-audio-firmware - intel-audio-firmware - intel-gpu-firmware + # NPU firmware is a separate Fedora subpackage from September 2026. + - intel-npu-firmware # Fedora owns and updates intel/ipu/ipu7ptl_fw.bin through this package. - intel-vsc-firmware - iwlwifi-mld-firmware @@ -22,3 +24,14 @@ state: present when: not ansible_check_mode tags: [xps-2026, hardware, packages] + +- name: Install full Panther Lake video-codec acceleration + ansible.builtin.dnf: + # RPM Fusion installs into dri-nonfree, ahead of the retained Fedora + # driver in libva's search path, and includes H.264/HEVC support. + name: intel-media-driver + state: present + when: + - features.proprietary_apps | bool + - not ansible_check_mode + tags: [xps-2026, hardware, packages] diff --git a/roles/xps-2026/templates/xps-ipu7-build.j2 b/roles/xps-2026/templates/xps-ipu7-build.j2 index 00224209..d138f2c8 100644 --- a/roles/xps-2026/templates/xps-ipu7-build.j2 +++ b/roles/xps-2026/templates/xps-ipu7-build.j2 @@ -322,7 +322,9 @@ validate_installed() { modinfo -k "$kernel" v4l2loopback >/dev/null modinfo -k "$kernel" intel_ipu7_psys >/dev/null modinfo -k "$kernel" intel_cvs >/dev/null - for module in ipu7-drivers vision-drivers; do + local -a required_modules + mapfile -t required_modules < <(required_dkms_modules "$kernel") + for module in "${required_modules[@]}"; do module_status=$(dkms status -m "$module" -v "$dkms_version" -k "$kernel" 2>/dev/null || true) [[ $module_status =~ :[[:space:]]+installed$ ]] done @@ -431,6 +433,8 @@ build_rpm() { <"$support_dir/patches/0005-ipu7-psys-stock-core-layout.patch" patch --batch --forward -d "$work/vision-drivers" -p1 \ <"$support_dir/patches/0003-vision-ptl-cvs-owns-rgb-power.patch" + patch --batch --forward -d "$work/ipu7-camera-hal" -p1 \ + <"$support_dir/patches/0006-camera-hal-native-cvs-bridge.patch" find "$work/ipu7-drivers" "$work/vision-drivers" \ -type f \( -name '*.orig' -o -name '*.rej' \) -delete @@ -498,7 +502,7 @@ build_rpm() { -e 's/^PACKAGE_NAME=.*/PACKAGE_NAME="vision-drivers"/' \ -e "s/^PACKAGE_VERSION=.*/PACKAGE_VERSION=\"$dkms_version\"/" \ "$stage/usr/src/vision-drivers-$dkms_version/dkms.conf" - sed -i 's|^MAKE="|MAKE="./xps-ipu7-abi-check $kernelver \&\& |' \ + sed -i 's|^MAKE="|MAKE="./xps-ipu7-abi-check --external-cvs $kernelver \&\& |' \ "$stage/usr/src/vision-drivers-$dkms_version/dkms.conf" printf '\nBUILD_DEPENDS[0]="ipu7-drivers"\n' >> \ "$stage/usr/src/vision-drivers-$dkms_version/dkms.conf" @@ -589,8 +593,26 @@ prune_dkms() { fi } +required_dkms_modules() { + local kernel=$1 + printf '%s\n' ipu7-drivers + # Keep the native CVS driver and its media bridge when Fedora provides it. + # The ABI guard additionally verifies its in-tree path and rejects shadowing. + if [[ $(modinfo -k "$kernel" -F intree intel_cvs 2>/dev/null || true) != Y ]]; then + printf '%s\n' vision-drivers + fi +} + camera_modules_loaded() { - [[ -d /sys/module/intel_ipu7_psys || -d /sys/module/intel_cvs ]] + local cvs_taint + [[ -d /sys/module/intel_ipu7_psys ]] && return 0 + [[ -d /sys/module/intel_cvs ]] || return 1 + # A loaded stock CVS is not replaced by this transaction, so it must not + # impose a reboot on a first-time PSYS-only installation. + # Check the in-memory module too: DKMS removal may have restored the native + # file on disk while an old external CVS remains loaded until reboot. + cvs_taint=$(cat /sys/module/intel_cvs/taint 2>/dev/null) || return 0 + [[ $cvs_taint == *O* || $(modinfo -F intree intel_cvs 2>/dev/null || true) != Y ]] } loaded_transaction_needs_reboot() { @@ -632,6 +654,7 @@ install_dkms() { local actual marker_previous marker_target modules_loaded=false reboot_satisfied=false local transaction_pending=false local changed=0 + local -a required_modules kernel=${2:-$(uname -r)} force=${3:-false} rollback=${4:-false} @@ -661,11 +684,12 @@ install_dkms() { echo "IPU7_STOCK_ABI_CHANGED: refusing optional PSYS/CVS installation for $kernel" >&2 return 78 fi + mapfile -t required_modules < <(required_dkms_modules "$kernel") # RPM Fusion akmods and DKMS share one locally generated signing key. This # makes Secure Boot enrollment a single, explicit owner action. /usr/bin/kmodgenca -a >/dev/null - for module in ipu7-drivers vision-drivers; do + for module in "${required_modules[@]}"; do # Do not pipe `dkms status` into `grep -q` under pipefail: when multiple # kernels are installed, grep exits after the first line and DKMS can get # SIGPIPE, making an existing registration look absent. @@ -678,12 +702,12 @@ install_dkms() { # Build both modules before installing either one. This limits a failed # forced rollback/rebuild to the existing on-disk module pair. if [[ $force == true ]]; then - for module in ipu7-drivers vision-drivers; do + for module in "${required_modules[@]}"; do dkms build --force -m "$module" -v "$dkms_version" -k "$kernel" changed=1 done fi - for module in ipu7-drivers vision-drivers; do + for module in "${required_modules[@]}"; do module_status=$(dkms status -m "$module" -v "$dkms_version" -k "$kernel" 2>/dev/null || true) if [[ $force == true || ! $module_status =~ :[[:space:]]+installed$ ]]; then dkms install --force -m "$module" -v "$dkms_version" -k "$kernel" diff --git a/tests/camera-frame.py b/tests/camera-frame.py new file mode 100644 index 00000000..87a08420 --- /dev/null +++ b/tests/camera-frame.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Reject loopback splash buffers and truncated captures without camera access.""" + +import os +from pathlib import Path +import subprocess +import sys +import tempfile + + +root = Path(__file__).resolve().parents[1] +checker = root / "roles/xps-2026/files/camera/xps-ipu7-camera-frame-check" +with tempfile.TemporaryDirectory(prefix="cybexos-camera-frame-test-") as directory: + command = Path(directory) / "v4l2-ctl" + command.write_text("""#!/usr/bin/env python3 +import os, sys +if '--get-fmt-video' in sys.argv: + print('Size Image : 128') +else: + assert '--stream-skip=30' in sys.argv + assert '--stream-count=3' in sys.argv + assert '--stream-to=-' in sys.argv + scenario = os.environ['FRAME_SCENARIO'] + if scenario == 'error': + sys.exit(1) + frames = [bytes(128)] * 3 + if scenario == 'fresh': + frames = [bytes([value]) * 128 for value in (17, 25, 42)] + elif scenario == 'truncated': + frames.pop() + sys.stdout.buffer.write(b''.join(frames)) +""") + command.chmod(0o755) + for scenario in ("fresh", "splash", "truncated", "error"): + result = subprocess.run( + [sys.executable, str(checker)], capture_output=True, text=True, + env=dict(os.environ, PATH=directory + os.pathsep + os.environ["PATH"], + FRAME_SCENARIO=scenario), + ) + assert (result.returncode == 0) == (scenario == "fresh"), ( + scenario, result.returncode, result.stdout, result.stderr, + ) +print("camera fresh-frame, splash, truncation, and capture-error fixtures passed") diff --git a/tests/camera-transaction.py b/tests/camera-transaction.py index 10b47eb7..7d14a378 100644 --- a/tests/camera-transaction.py +++ b/tests/camera-transaction.py @@ -44,8 +44,11 @@ def fixture_script() -> str: "loaded_transaction_needs_reboot", "reboot_marker_state", "write_reboot_marker", + "required_dkms_modules", + "camera_modules_loaded", ) ) + functions = functions.replace("/sys/module", "$FIXTURE_ROOT/sys-module") return f"""#!/usr/bin/bash set -euo pipefail rpm_dir=$FIXTURE_ROOT/rpms @@ -110,6 +113,23 @@ def fixture_script() -> str: FIXTURE_CACHED_NEVRA=$target_nevra case $SCENARIO in + native-cvs) + modinfo() {{ printf '%s\n' Y; }} + [[ $(required_dkms_modules fixture-kernel) == ipu7-drivers ]] + mkdir -p "$FIXTURE_ROOT/sys-module/intel_cvs" + : >"$FIXTURE_ROOT/sys-module/intel_cvs/taint" + ! camera_modules_loaded + ;; + external-cvs) + modinfo() {{ return 1; }} + [[ $(required_dkms_modules fixture-kernel) == $'ipu7-drivers\nvision-drivers' ]] + ;; + loaded-external-cvs-native-on-disk) + modinfo() {{ printf '%s\n' Y; }} + mkdir -p "$FIXTURE_ROOT/sys-module/intel_cvs" + printf 'OE\n' >"$FIXTURE_ROOT/sys-module/intel_cvs/taint" + camera_modules_loaded + ;; retained) prepare_rollback "manifest=$old_manifest" rollback=$(rollback_rpm "manifest=$old_manifest") @@ -260,3 +280,8 @@ def run(scenario: str) -> subprocess.CompletedProcess[str]: assert rejected.returncode != 0, f"{scenario} opened a resumed transaction" print("camera rollback and multi-invocation transaction fixtures passed") + +for scenario in ("native-cvs", "external-cvs", "loaded-external-cvs-native-on-disk"): + selected = run(scenario) + assert selected.returncode == 0, (scenario, selected.stdout, selected.stderr) +print("camera native/external CVS ownership fixtures passed") diff --git a/tests/lib/quickshell-live b/tests/lib/quickshell-live index ea7d99e3..2d7a20b4 100644 --- a/tests/lib/quickshell-live +++ b/tests/lib/quickshell-live @@ -60,57 +60,72 @@ qs_live_reconcile_processes() { qs_live_error "$QS_LIVE_SERVICE has no MainPID" return 1 } - mapfile -t pids < <(pgrep -x qs 2>/dev/null || true) - - for pid in "${pids[@]}"; do - [[ $pid == "$main" ]] && continue - command=$(qs_live_pid_command "$pid" || true) - process_cgroup=$(qs_live_pid_cgroup "$pid" || true) - uid=$(ps -o uid= -p "$pid" 2>/dev/null | tr -d '[:space:]') - qs_live_error "extra qs PID $pid: command=${command:-unreadable}; cgroup=${process_cgroup:-unreadable}" - - # IPC clients also use -p to select a configuration. They are not a second - # menubar, and may belong to a managed service such as Welcome. Let them - # finish normally before requiring the service to be the sole qs process. - if [[ $uid == "$(id -u)" && - $command =~ ^([^[:space:]]*/)?qs[[:space:]]+ipc([[:space:]]|$) ]]; then - local ipc_deadline=$((SECONDS + 5)) - qs_live_error "waiting for IPC client PID $pid to finish" - while kill -0 "$pid" 2>/dev/null && ((SECONDS < ipc_deadline)); do - sleep 0.1 - done - if kill -0 "$pid" 2>/dev/null; then - unknown=1 - qs_live_error "leaving active IPC client PID $pid running" - fi - continue + local scan_deadline=$((SECONDS + QS_LIVE_TIMEOUT)) expired deadline + while :; do + unknown=0 + expired=0 + ((SECONDS < scan_deadline)) || expired=1 + mapfile -t pids < <(pgrep -x qs 2>/dev/null || true) + if ((${#pids[@]} == 1)) && [[ ${pids[0]} == "$main" && $(qs_live_main_pid) == "$main" ]]; then + return 0 fi - if [[ $uid == "$(id -u)" && -n $process_cgroup && - $process_cgroup != "$service_cgroup" && - $command =~ (^|[[:space:]])([^[:space:]]*/)?qs([[:space:]]|$) && - $command =~ (^|[[:space:]])-(d|p)([[:space:]]|$) ]]; then - qs_live_error "terminating confirmed unmanaged developer PID $pid" - kill -TERM "$pid" - local deadline=$((SECONDS + 5)) - while kill -0 "$pid" 2>/dev/null && ((SECONDS < deadline)); do - sleep 0.1 - done - if kill -0 "$pid" 2>/dev/null; then - qs_live_error "developer PID $pid ignored SIGTERM; terminating it" - kill -KILL "$pid" + for pid in "${pids[@]}"; do + [[ $pid == "$main" ]] && continue + command=$(qs_live_pid_command "$pid" || true) + process_cgroup=$(qs_live_pid_cgroup "$pid" || true) + uid=$(ps -o uid= -p "$pid" 2>/dev/null | tr -d '[:space:]' || true) + qs_live_error "extra qs PID $pid: command=${command:-unreadable}; cgroup=${process_cgroup:-unreadable}" + # Short-lived clients can exit between pgrep and /proc inspection. + kill -0 "$pid" 2>/dev/null || continue + + # IPC clients also use -p, but never create another menubar. Welcome + # can start its next request as the previous one finishes, so every + # subsequent snapshot must be inspected, not just the first one. + if [[ $uid == "$(id -u)" && + $command =~ ^([^[:space:]]*/)?qs[[:space:]]+ipc([[:space:]]|$) ]]; then + deadline=$((SECONDS + 5)) + ((deadline <= scan_deadline)) || deadline=$scan_deadline + qs_live_error "waiting for IPC client PID $pid to finish" + while kill -0 "$pid" 2>/dev/null && ((SECONDS < deadline)); do + sleep 0.1 + done + if kill -0 "$pid" 2>/dev/null; then + unknown=1 + qs_live_error "leaving active IPC client PID $pid running" + fi + continue + fi + + if [[ $uid == "$(id -u)" && -n $process_cgroup && + $process_cgroup != "$service_cgroup" && + $command =~ (^|[[:space:]])([^[:space:]]*/)?qs([[:space:]]|$) && + $command =~ (^|[[:space:]])-(d|p)([[:space:]]|$) ]]; then + qs_live_error "terminating confirmed unmanaged developer PID $pid" + kill -TERM "$pid" + deadline=$((SECONDS + 5)) + ((deadline <= scan_deadline)) || deadline=$scan_deadline + while kill -0 "$pid" 2>/dev/null && ((SECONDS < deadline)); do + sleep 0.1 + done + if kill -0 "$pid" 2>/dev/null; then + qs_live_error "developer PID $pid ignored SIGTERM; terminating it" + kill -KILL "$pid" + fi + else + unknown=1 + qs_live_error "leaving unconfirmed extra PID $pid running" fi - else - unknown=1 - qs_live_error "leaving unconfirmed extra PID $pid running" + done + + if ((unknown != 0 || expired != 0)); then + qs_live_error "expected $main to be the sole qs PID; inspected: ${pids[*]:-none}" + return 1 fi + # A bounded rescan must observe the managed service alone. Never infer + # quiescence merely from the old clients having finished. + sleep 0.1 done - - mapfile -t pids < <(pgrep -x qs 2>/dev/null || true) - if ((unknown != 0)) || ((${#pids[@]} != 1)) || [[ ${pids[0]:-} != "$main" ]]; then - qs_live_error "expected $main to be the sole qs PID; found: ${pids[*]:-none}" - return 1 - fi } qs_live_begin() { diff --git a/tests/model-usage/test_private_state.py b/tests/model-usage/test_private_state.py index 2dbf0597..e6e0f04a 100644 --- a/tests/model-usage/test_private_state.py +++ b/tests/model-usage/test_private_state.py @@ -80,6 +80,13 @@ def test_group_or_world_writable_directory_is_rejected_without_chmod(self): directory.mkdir() directory.chmod(mode) path = directory / "cache.json" if leaf else directory / "private" / "cache.json" + if mode == 0o1777 and not leaf and os.getuid() == 0: + # Root-owned sticky ancestors intentionally support /tmp. + # CI runs as root, so this fixture has that same policy. + common.atomic_write_json(path, {}) + self.assertEqual(stat.S_IMODE(directory.stat().st_mode), mode) + self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700) + continue with self.assertRaises(PermissionError): common.atomic_write_json(path, {}) self.assertEqual(stat.S_IMODE(directory.stat().st_mode), mode) diff --git a/tests/quickshell/battery-popover.test.cjs b/tests/quickshell/battery-popover.test.cjs index 6c87aff9..cd9a737d 100644 --- a/tests/quickshell/battery-popover.test.cjs +++ b/tests/quickshell/battery-popover.test.cjs @@ -23,7 +23,7 @@ test("battery popover keeps the hero, meter, telemetry, profile hierarchy", () = /batteryGlyph:[\s\S]{0,420}?battery_full[\s\S]{0,360}?battery_1_bar/, "the hero glyph must reflect charge level"); assert.match(battery, - /statusText:\s*Battery\.full \? "Fully charged"[\s\S]{0,100}?"Charging"[\s\S]{0,80}?"On battery"/); + /statusText:\s*Battery\.statusText/); assert.match(battery, /id:\s*heroNumber[\s\S]{0,260}?font\.pixelSize:\s*Theme\.typography\.displayLarge[\s\S]{0,160}?font\.features:\s*Theme\.tabularNumberFeatures/); }); diff --git a/tests/quickshell/external-monitor-toggle.test.cjs b/tests/quickshell/external-monitor-toggle.test.cjs index a37e691d..f523efca 100644 --- a/tests/quickshell/external-monitor-toggle.test.cjs +++ b/tests/quickshell/external-monitor-toggle.test.cjs @@ -30,9 +30,22 @@ function monitor(name, disabled, description = "") { const LID_SIGNAL = "/org/freedesktop/login1: org.freedesktop.DBus.Properties." + "PropertiesChanged ('org.freedesktop.login1.Manager', {'LidClosed': }, @as [])"; -// Scenarios end when the socat stub exits (the helper then exits 75), so a -// gdbus stub must outlive it or the helper stops on the lid stream instead. -async function runScenario({ initialMonitors, socatBody, gdbusBody = "sleep 0.6\n", +// Keep both event streams alive until the behavior under test has occurred. +// Fixed subsecond stream lifetimes race with process scheduling in the full suite. +const waitForCall = ` +for attempt in {1..500}; do + [[ ! -s $MONITOR_TEST_CALLS ]] || break + sleep 0.01 +done +[[ -s $MONITOR_TEST_CALLS ]] +`; +const waitForStreamEnd = ` +for attempt in {1..600}; do + [[ ! -e $MONITOR_TEST_STREAM_DONE ]] || break + sleep 0.01 +done +`; +async function runScenario({ initialMonitors, socatBody, gdbusBody = waitForStreamEnd, pollSeconds = "30" }) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "monitor-toggle-state-")); const bin = path.join(tmp, "bin"); @@ -63,7 +76,7 @@ case "\${1:-}" in *) exit 1 ;; esac `); - executable(path.join(bin, "socat"), socatBody); + executable(path.join(bin, "socat"), `trap 'touch "$MONITOR_TEST_STREAM_DONE"' EXIT\n${socatBody}`); executable(path.join(bin, "gdbus"), gdbusBody); const server = net.createServer(); @@ -76,7 +89,7 @@ esac try { result = spawnSync("bash", [helper], { encoding: "utf8", - timeout: 3000, + timeout: 8000, env: { ...process.env, PATH: `${bin}:/usr/bin:/bin`, @@ -92,6 +105,7 @@ esac MONITOR_TEST_DP_STATUS: path.join(dpDir, "status"), MONITOR_TEST_LID_STATE: path.join(lid, "state"), MONITOR_TEST_TMP: tmp, + MONITOR_TEST_STREAM_DONE: path.join(tmp, "stream-done"), }, }); return { @@ -117,7 +131,7 @@ sleep 0.02 printf '%s' '${external}' >"$MONITOR_TEST_STATE" printf 'connected\\n' >"$MONITOR_TEST_DP_STATUS" printf 'monitoradded>>DP-1\\n' -sleep 0.30 +${waitForCall} `, }); @@ -133,13 +147,13 @@ test("opening the lid enables eDP once without reloading the config", async () = // logind signal can have woken the helper. const { result, calls } = await runScenario({ initialMonitors: [monitor("eDP-1", true, "Internal")], - socatBody: "sleep 0.45\n", + socatBody: waitForCall, gdbusBody: ` printf '%s\\n' 'Monitoring signals on object /org/freedesktop/login1 owned by org.freedesktop.login1' sleep 0.12 printf 'state: open\\n' >"$MONITOR_TEST_LID_STATE" printf '%s\\n' "${LID_SIGNAL}" -sleep 0.6 +${waitForStreamEnd} `, }); @@ -148,7 +162,10 @@ sleep 0.6 assert.match(calls, /eval .*output = "eDP-1".*disabled = false/); assert.doesNotMatch(calls, /reload|disabled = true/); assert.match(result.stderr, /enabling eDP-1 after stable lid\/output state/); - assert.match(result.stderr, /Hyprland event stream disconnected/); + // Both fake streams exit after the monitor call; their disconnect order + // depends on process scheduling, and either must request a restart. + assert.match(result.stderr, + /(Hyprland event stream disconnected|logind lid event stream disconnected)/); }); test("the safety poll still catches a lid change logind never announced", async () => { @@ -158,7 +175,7 @@ test("the safety poll still catches a lid change logind never announced", async socatBody: ` sleep 0.12 printf 'state: open\\n' >"$MONITOR_TEST_LID_STATE" -sleep 0.30 +${waitForCall} `, }); diff --git a/tests/quickshell/quickshell-live.test.cjs b/tests/quickshell/quickshell-live.test.cjs index 6f12eb9e..935df889 100644 --- a/tests/quickshell/quickshell-live.test.cjs +++ b/tests/quickshell/quickshell-live.test.cjs @@ -46,6 +46,69 @@ qs_live_reconcile_processes } }); + +test("live guard inspects successive IPC clients and still requires final quiescence", () => { + for (const mode of ["successive", "vanished", "persistent", "unknown", "managed"]) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "cybexos-qs-successors-")); + try { + fs.writeFileSync(path.join(directory, "pid"), "222"); + fs.writeFileSync(path.join(directory, "signals"), ""); + const result = spawnSync("bash", ["-c", String.raw` +set -euo pipefail +source "$1" +qs_live_main_pid() { echo 111; } +qs_live_control_group() { echo /fixture/quickshell.service; } +qs_live_pid_command() { + touch "$QS_TEST_DIR/inspected-$1" + if [[ $QS_TEST_MODE == unknown ]]; then echo 'qs --help'; + elif [[ $QS_TEST_MODE == managed ]]; then echo 'qs -p /fixture/quickshell'; + else echo 'qs ipc --any-display -p /fixture/quickshell call wallpaper results'; fi +} +qs_live_pid_cgroup() { + if [[ $QS_TEST_MODE == managed ]]; then echo /fixture/quickshell.service; + else echo /fixture/cybexos-welcome.service; fi +} +id() { echo 1000; } +ps() { + if [[ $QS_TEST_MODE == vanished ]]; then : > "$QS_TEST_DIR/pid"; return 1; fi + echo 1000 +} +pgrep() { echo 111; cat "$QS_TEST_DIR/pid"; } +kill() { + if [[ $1 == -0 ]]; then [[ $2 == "$(cat "$QS_TEST_DIR/pid")" ]]; return; fi + printf '%s\n' "$*" >> "$QS_TEST_DIR/signals" +} +sleep() { + if [[ $QS_TEST_MODE == persistent ]]; then SECONDS=30; return; fi + [[ -e "$QS_TEST_DIR/inspected-$(cat "$QS_TEST_DIR/pid")" ]] || return 0 + case $(cat "$QS_TEST_DIR/pid") in + 222) printf 333 > "$QS_TEST_DIR/pid" ;; + 333) printf 444 > "$QS_TEST_DIR/pid" ;; + 444) : > "$QS_TEST_DIR/pid" ;; + esac +} +qs_live_reconcile_processes +`, "quickshell-successor-test", liveHelper], { + cwd: repoDir, + encoding: "utf8", + env: {...process.env, QS_TEST_DIR: directory, QS_TEST_MODE: mode}, + }); + assert.equal(result.status, ["successive", "vanished"].includes(mode) ? 0 : 1, result.stderr); + assert.equal(fs.readFileSync(path.join(directory, "signals"), "utf8"), ""); + if (mode === "successive") { + assert.match(result.stderr, /extra qs PID 222: command=qs ipc/); + assert.match(result.stderr, /extra qs PID 333: command=qs ipc/); + assert.match(result.stderr, /extra qs PID 444: command=qs ipc/); + assert.match(result.stderr, /cgroup=\/fixture\/cybexos-welcome.service/); + } else if (mode !== "vanished") { + assert.match(result.stderr, /expected 111 to be the sole qs PID/); + } + } finally { + fs.rmSync(directory, {recursive: true, force: true}); + } + } +}); + function checkJournal(log) { return spawnSync("bash", ["-c", String.raw` set -u diff --git a/tests/quickshell/reminder-helper.test.cjs b/tests/quickshell/reminder-helper.test.cjs index cd979e52..5dcf796d 100644 --- a/tests/quickshell/reminder-helper.test.cjs +++ b/tests/quickshell/reminder-helper.test.cjs @@ -6,6 +6,11 @@ const path = require("node:path"); const { spawnSync } = require("node:child_process"); const helper = path.resolve(__dirname, "../../assets/scripts/quickshell-reminder"); +const fixtureRoots = new Set(); +test.after(() => { + for (const root of fixtureRoots) + fs.rmSync(root, { recursive: true, force: true }); +}); function executable(file, source) { fs.writeFileSync(file, source, { mode: 0o755 }); @@ -13,6 +18,7 @@ function executable(file, source) { function fixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "quickshell-reminder-")); + fixtureRoots.add(root); const bin = path.join(root, "bin"); const state = path.join(root, "state"); const active = path.join(root, "active"); diff --git a/tests/quickshell/status-helpers.test.cjs b/tests/quickshell/status-helpers.test.cjs index 83f1ee6f..6831ffb3 100644 --- a/tests/quickshell/status-helpers.test.cjs +++ b/tests/quickshell/status-helpers.test.cjs @@ -46,10 +46,10 @@ test("signal percent rounds and keeps -1 for an unknown strength", () => { // ---- battery ------------------------------------------------------------- -test("charge state separates charging from full and treats the rest as battery", () => { +test("charge state separates active charging, preservation and full", () => { const state = H.BATTERY_STATE; assert.equal(H.chargeState({ state: state.Charging }), "charging"); - assert.equal(H.chargeState({ state: state.PendingCharge }), "charging"); + assert.equal(H.chargeState({ state: state.PendingCharge }), "pending-charge"); assert.equal(H.chargeState({ state: state.FullyCharged }), "full"); assert.equal(H.chargeState({ state: state.Discharging }), "discharging"); assert.equal(H.chargeState({ state: state.PendingDischarge }), "discharging"); @@ -58,6 +58,18 @@ test("charge state separates charging from full and treats the rest as battery", assert.equal(H.chargeState(null), "discharging"); }); +test("power source is independent of charge state and preservation is explained", () => { + const paused = { state: H.BATTERY_STATE.PendingCharge }; + assert.equal(H.isPluggedIn(paused), true); + assert.equal(H.isPluggedIn(paused, true), false); + assert.equal(H.isPluggedIn({ state: H.BATTERY_STATE.Discharging }, false), true); + assert.equal(H.batteryStatus("pending-charge", true, true), "Plugged in · Charge limited"); + assert.equal(H.batteryStatus("pending-charge", true, false), "Plugged in · Not charging"); + assert.equal(H.batteryStatus("charging", true, true), "Charging"); + assert.equal(H.batteryStatus("full", true, true), "Fully charged"); + assert.equal(H.batteryStatus("pending-charge", false, true), "On battery"); +}); + test("bar and popover agree: a full battery is plugged in but not charging", () => { const full = { state: H.BATTERY_STATE.FullyCharged }; assert.equal(H.chargeState(full) === "charging", false); diff --git a/tests/quickshell/system-theme-gtk.test.cjs b/tests/quickshell/system-theme-gtk.test.cjs index c65f36da..a80d5424 100644 --- a/tests/quickshell/system-theme-gtk.test.cjs +++ b/tests/quickshell/system-theme-gtk.test.cjs @@ -171,8 +171,10 @@ function runTask(task, bin, env = {}) { const script = task["ansible.builtin.shell"]; assert.equal(task.args.executable, "/bin/bash"); assert.doesNotMatch(script, /\{\{/, "the stubbed script must not need templating"); + for (const name of ["mktemp", "rm", "cat", "timeout"]) + if (!fs.existsSync(path.join(bin, name))) fs.symlinkSync(which(name), path.join(bin, name)); return spawnSync(bash, ["-c", script], { - encoding: "utf8", env: { PATH: bin, HOME: bin, ...env }, + encoding: "utf8", env: { PATH: bin, HOME: bin, ...env }, timeout: 3000, }); } @@ -187,10 +189,11 @@ test("environment.d no longer pins GTK_THEME", () => { }); test("the converge seeds dark GTK only on keys still at their schema default", t => { - // Shared with installed images, which also run it offline. + // Shared with installed images; offline targets defer it to first login. const task = yamlTask("roles/dotfiles/tasks/personal.yml", "Default GTK to dark until the shell applies its appearance"); - assert.deepEqual(task.when, ["manage_personal_dotfiles | bool", "not ansible_check_mode"]); + assert.deepEqual(task.when, ["manage_personal_dotfiles | bool", "not ansible_check_mode", + "not cybexos_offline | default(false) | bool"]); assert.equal(task.become_user, "{{ primary_user }}"); assert.equal(task.changed_when, "'CHANGED:' in dotfiles_gtk_default.stdout"); @@ -230,6 +233,88 @@ test("the converge seeds dark GTK only on keys still at their schema default", t } }); +test("GTK initialization does not wait for a private-bus descendant holding output open", async t => { + const task = yamlTask("roles/dotfiles/tasks/personal.yml", + "Default GTK to dark until the shell applies its appearance"); + const state = scratch(t), pids = path.join(state, "pids"); + const stub = stubBin(t, { + "dbus-run-session": `[ "$GIO_USE_VFS" = local ] || exit 90\n` + + `${which("sleep")} 30 &\nprintf '%s\\n' "$!" >> "$PIDS"\n` + + `[ "$1" = -- ] && shift\nexec "$@"`, + gsettings: `if [ "$1" = get ]; then printf "'chosen'\\n"; fi`, + }); + try { + const result = runTask(task, stub.bin, { PIDS: pids, TMPDIR: state }); + assert.equal(result.status, 0, String(result.error || result.stderr)); + assert.equal(fs.readFileSync(pids, "utf8").trim().split("\n").length, 2); + assert.deepEqual(fs.readdirSync(state), ["pids"], "capture directories must be removed"); + } finally { + if (fs.existsSync(pids)) { + const owned = fs.readFileSync(pids, "utf8").trim().split("\n").map(Number); + for (const pid of owned) { + try { process.kill(pid, "SIGTERM"); } catch (error) { if (error.code !== "ESRCH") throw error; } + } + // They are children of the stub process. PID 1 reaps them; wait + // until each has exited rather than leaving test sleepers behind. + for (let attempt = 0; attempt < 100; attempt++) { + const alive = owned.filter(pid => { + try { return !/\) Z /.test(fs.readFileSync(`/proc/${pid}/stat`, "utf8")); } + catch (error) { if (error.code === "ENOENT") return false; throw error; } + }); + if (!alive.length) break; + assert.ok(attempt < 99, "fixture sleeper did not exit"); + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + } +}); + +test("a previous private-bus descendant cannot contaminate the next settings read", t => { + const task = yamlTask("roles/dotfiles/tasks/personal.yml", + "Default GTK to dark until the shell applies its appearance"); + const state = scratch(t), pidfile = path.join(state, "pid"); + const stub = stubBin(t, { + "dbus-run-session": `[ "$1" = -- ] && shift\n` + + `if [ "$2" = get ] && [ "$4" = color-scheme ]; then\n` + + ` ( while [ ! -f "$STATE/second-read" ]; do ${which("sleep")} 0.01; done\n` + + ` printf "'late-daemon-output'\\n"\n` + + ` printf done > "$STATE/written" ) &\n` + + ` printf '%s\\n' "$!" > "$STATE/pid"\nfi\nexec "$@"`, + gsettings: `if [ "$1" = get ]; then\n` + + ` if [ "$3" = color-scheme ]; then printf "'prefer-light'\\n"; else\n` + + ` printf "'Adwaita'\\n"\n printf ready > "$STATE/second-read"\n` + + ` while [ ! -f "$STATE/written" ]; do ${which("sleep")} 0.01; done\n` + + ` fi\nfi`, + }); + try { + const result = runTask(task, stub.bin, { STATE: state, TMPDIR: state }); + assert.equal(result.status, 0, String(result.error || result.stderr)); + assert.deepEqual(stub.calls().filter(line => line.startsWith("gsettings set")), + [`gsettings set ${SCHEMA} gtk-theme adw-gtk3-dark`]); + assert.doesNotMatch(result.stdout, /late-daemon-output/); + assert.ok(!fs.readdirSync(state).some(name => name.startsWith("tmp."))); + } finally { + // Normal completion proves the child finished its final write. On a + // failed assertion/timeout, terminate only this fixture's recorded PID. + if (fs.existsSync(pidfile)) { + try { process.kill(Number(fs.readFileSync(pidfile, "utf8").trim()), "SIGTERM"); } + catch (error) { if (error.code !== "ESRCH") throw error; } + } + } +}); + +test("GTK initialization reports failed settings reads", t => { + const task = yamlTask("roles/dotfiles/tasks/personal.yml", + "Default GTK to dark until the shell applies its appearance"); + const stub = stubBin(t, { + "dbus-run-session": `[ "$1" = -- ] && shift\nexec "$@"`, + gsettings: `printf 'settings read failed\\n' >&2\nexit 7`, + }); + const result = runTask(task, stub.bin); + assert.equal(result.status, 7, result.stderr); + assert.match(result.stderr, /settings read failed/); +}); + test("the converge drops only CybexOS's GTK_THEME from the user manager", t => { const task = yamlTask("roles/dotfiles/tasks/main.yml", "Drop the retired GTK_THEME pin from the running user manager"); diff --git a/tests/quickshell/update-client.test.cjs b/tests/quickshell/update-client.test.cjs index 84241ab8..34a8d45c 100644 --- a/tests/quickshell/update-client.test.cjs +++ b/tests/quickshell/update-client.test.cjs @@ -29,6 +29,7 @@ function fixture() { CYBEXOS_CONFIG_FILE: config, CYBEXOS_RELEASE_UPDATE: release, CYBEXOS_UPDATE_BACKEND: backend, + CYBEXOS_UPDATE_CHANNEL: path.join(root, "rpm-channel"), }, }; } @@ -61,6 +62,17 @@ test("an uninitialized source deployment checks cleanly and updates packages", t assert.equal(update.stdout, "backend:run --no-flatpak\n"); }); +test("ISO installs report their desktop channel and still use DNF for updates", t => { + const f = fixture(); + t.after(() => fs.rmSync(f.root, { recursive: true, force: true })); + fs.writeFileSync(f.env.CYBEXOS_UPDATE_CHANNEL, + '#!/bin/sh\nprintf \'{"available":false,"status":"desktop-channel-disabled"}\\n\'\n', { mode: 0o755 }); + const check = run(["check"], f.env); + assert.equal(check.status, 0, check.stderr); + assert.equal(JSON.parse(check.stdout).status, "desktop-channel-disabled"); + assert.equal(run(["start"], f.env).stdout, "backend:start --json --system-unit\n"); +}); + test("an initialized installation retains verified project updates", t => { const f = fixture(); t.after(() => fs.rmSync(f.root, { recursive: true, force: true })); diff --git a/tests/quickshell/updates.test.cjs b/tests/quickshell/updates.test.cjs index 326a6caa..27dd7de1 100644 --- a/tests/quickshell/updates.test.cjs +++ b/tests/quickshell/updates.test.cjs @@ -439,9 +439,13 @@ test("the one-line summary says what is pending, else what could not be checked" const vm = require("node:vm"); const source = read("Common/Updates.qml"); const summary = source.match(/readonly property string summary: \{([\s\S]*?)\n \}/)[1]; - const state = { busy: false, total: 0, checkError: "" }; + const state = { busy: false, total: 0, checkError: "", projectStatus: "" }; const run = () => vm.runInNewContext("(() => {" + summary + "})()", state); assert.equal(run(), "Up to date"); + state.projectStatus = "desktop-channel-disabled"; + assert.equal(run(), "System checked · Desktop updates unavailable"); + state.projectStatus = "desktop-channel-ready"; + assert.equal(run(), "Up to date"); state.checkError = "Couldn’t check CybexOS releases"; assert.equal(run(), "Couldn’t check CybexOS releases"); state.total = 2; diff --git a/tests/run b/tests/run index 75789163..1c243bf0 100755 --- a/tests/run +++ b/tests/run @@ -706,7 +706,7 @@ assert "ExecStart=/usr/bin/systemctl start v4l2-relayd@ipu7.service" in resume_u assert "module_status=$(dkms status" in camera_builder assert 'dkms status -m "$module" -v "$bundle_version" 2>/dev/null | grep -q' not in camera_builder assert '"$stage/usr/src/vision-drivers-$dkms_version/xps-ipu7-abi-check"' in camera_builder -assert "MAKE=\"./xps-ipu7-abi-check $kernelver" in camera_builder +assert "MAKE=\"./xps-ipu7-abi-check --external-cvs $kernelver" in camera_builder assert 'BUILD_DEPENDS[0]="ipu7-drivers"' in camera_builder assert 'dkms_version=$(dkms_version_for_manifest "$inputs")' in camera_builder assert 'dkms status -m "$module" -v "$dkms_version"' in camera_builder @@ -813,6 +813,10 @@ PY out=$(python3 tests/camera-transaction.py 2>&1) || rc=1 fi + if ((rc == 0)); then + out=$(python3 tests/camera-frame.py 2>&1) || rc=1 + fi + if ((rc == 0)); then out=$(python3 roles/xps-2026/files/camera/xps-ipu7-abi-check --self-test 2>&1) || rc=1 fi diff --git a/tests/verify-system b/tests/verify-system index a8b764bb..66d6c981 100755 --- a/tests/verify-system +++ b/tests/verify-system @@ -419,10 +419,11 @@ if $xps_2026; then panther_packages=( alsa-sof-firmware linux-firmware cirrus-audio-firmware - intel-audio-firmware intel-gpu-firmware intel-vsc-firmware + intel-audio-firmware intel-gpu-firmware intel-npu-firmware intel-vsc-firmware iwlwifi-mld-firmware libva-intel-media-driver libvpl intel-vpl-gpu-rt wireless-regdb pipewire-module-filter-chain-lv2 lsp-plugins-lv2 ) + feature_enabled proprietary_apps && panther_packages+=(intel-media-driver) check 'explicit Panther Lake media/firmware RPMs' rpm -q "${panther_packages[@]}" check 'XPS speaker tuning user unit is enabled' systemctl --user is-enabled --quiet xps-speaker-tuning.service @@ -484,8 +485,13 @@ if $xps_2026; then /usr/lib/systemd/system-sleep/xps-ipu7-camera check 'IPU7 PSYS DKMS built for the running kernel' bash -c \ 'dkms status -m ipu7-drivers -v "$1" -k "$(uname -r)" 2>/dev/null | grep -q ": installed$"' _ "$camera_dkms_version" - check 'Intel CVS DKMS built for the running kernel' bash -c \ - 'dkms status -m vision-drivers -v "$1" -k "$(uname -r)" 2>/dev/null | grep -q ": installed$"' _ "$camera_dkms_version" + if [[ $(modinfo -F intree intel_cvs 2>/dev/null) == Y ]]; then + check 'Fedora native Intel CVS retained' bash -c \ + '[[ $(modinfo -n intel_cvs) == */kernel/* ]]' + else + check 'Intel CVS DKMS built for the running kernel' bash -c \ + 'dkms status -m vision-drivers -v "$1" -k "$(uname -r)" 2>/dev/null | grep -q ": installed$"' _ "$camera_dkms_version" + fi if [[ -r /var/lib/xps-hardware/ipu7/reboot-required ]] && grep -q "^boot_id=$(cat /proc/sys/kernel/random/boot_id) " \ /var/lib/xps-hardware/ipu7/reboot-required; then