diff --git a/docs/README.md b/docs/README.md
index 581b34f..47066b9 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -30,7 +30,7 @@ Docs are split by the question they answer:
- [0015 — Propose a post-cutoff bootc-only installer scope (Proposed)](adr/0015-bootc-only-installer-scope.md)
- [0016 — Version-2 bootc-only recipe contract (Accepted)](adr/0016-bootc-only-recipe-contract.md)
- [0017 — Publish Debian packages through frostyard/apt-publisher](adr/0017-publish-debian-packages-through-apt-publisher.md)
-- [0018 — Read each image's core flatpak set from an OCI label (Proposed)](adr/0018-image-published-core-flatpaks-label.md)
+- [0018 — Read each image's core flatpak set from an OCI label](adr/0018-image-published-core-flatpaks-label.md)
### Design
@@ -40,6 +40,7 @@ Docs are split by the question they answer:
- [Firn recipe schema (version 2; temporary bootc-v1 compatibility)](specs/recipe-schema.md)
- [Firn progress protocol (version 1)](specs/progress-protocol.md)
+- [Core Flatpaks image label (version 1)](specs/core-flatpaks-label.md)
### Plans
diff --git a/docs/adr/0006-install-time-offline-first-flatpaks.md b/docs/adr/0006-install-time-offline-first-flatpaks.md
index 54afce5..d22ccd3 100644
--- a/docs/adr/0006-install-time-offline-first-flatpaks.md
+++ b/docs/adr/0006-install-time-offline-first-flatpaks.md
@@ -1,6 +1,6 @@
# 0006 — Install-time, offline-first flatpak provisioning on both paths
-- **Status:** Accepted
+- **Status:** Accepted; core-set source superseded by [0018](0018-image-published-core-flatpaks-label.md)
- **Date:** 2026-08-11
## Context
diff --git a/docs/adr/0018-image-published-core-flatpaks-label.md b/docs/adr/0018-image-published-core-flatpaks-label.md
index 076befa..1abdc0c 100644
--- a/docs/adr/0018-image-published-core-flatpaks-label.md
+++ b/docs/adr/0018-image-published-core-flatpaks-label.md
@@ -1,6 +1,6 @@
# 0018 — Read each image's core flatpak set from an OCI label
-- **Status:** Proposed
+- **Status:** Accepted
- **Date:** 2026-10-06
## Context
@@ -147,9 +147,12 @@ mechanism. When accepted, ADR-0006 gains a status note pointing here.
ISO still puts its GNOME seed on any image it installs. That is existing
behavior, unchanged here. Installing only the requested apps from a
medium is a separate problem with its own decision.
-- **Fail before writing.** A malformed label with `core_flatpaks = true`
- stops the install before any disk write. The TUI never offers the toggle
- for such an image, so only headless recipes can reach that error.
+- **Fail before writing.** A malformed or unreadable label with
+ `core_flatpaks = true` stops the install before any disk write. The TUI
+ does not offer the toggle for an image whose preview shows a malformed
+ label, but a wizard recipe can still reach the error: when the preview's
+ inspection failed and the toggle stayed, or when the tag moved between the
+ preview and the install. Headless recipes can reach it directly.
- **Saved recipes change meaning.** A saved Sundog or Floe recipe with
`core_flatpaks = true` installs that image's set, or none, instead of the
GNOME list. The release notes say so.
@@ -180,8 +183,10 @@ mechanism. When accepted, ADR-0006 gains a status note pointing here.
- Shapes: [design/architecture.md](../design/architecture.md) (system
configuration, flatpaks), [specs/recipe-schema.md](../specs/recipe-schema.md)
(`core_flatpaks`), [specs/progress-protocol.md](../specs/progress-protocol.md)
- (`no_core_set`), a new core-flatpaks label spec created with the
- implementation, [roadmap Phase 10](../plans/roadmap.md#phase-10)
+ (`no_core_set`, `core_flatpaks_label_invalid`,
+ `core_flatpaks_label_unreadable`),
+ [specs/core-flatpaks-label.md](../specs/core-flatpaks-label.md) (the label
+ contract), [roadmap Phase 10](../plans/roadmap.md#phase-10)
- Builds on: [ADR-0006](0006-install-time-offline-first-flatpaks.md)
(offline-first provisioning, whose core-set source this replaces),
[ADR-0010](0010-single-installer-iso-in-snosi.md) (snosi owns the ISO and
diff --git a/docs/design/architecture.md b/docs/design/architecture.md
index a71d814..abb5cd8 100644
--- a/docs/design/architecture.md
+++ b/docs/design/architecture.md
@@ -12,7 +12,8 @@ Living document. Rationale:
boundary: firn ships binary + kiosk unit + contracts; snosi ships the
single installer ISO).
Contracts: [specs/recipe-schema.md](../specs/recipe-schema.md),
-[specs/progress-protocol.md](../specs/progress-protocol.md).
+[specs/progress-protocol.md](../specs/progress-protocol.md),
+[specs/core-flatpaks-label.md](../specs/core-flatpaks-label.md).
## Current bootc-only recipe boundary
@@ -64,6 +65,16 @@ headless-only; the wizard instead accepts pasted keys and creates its own
private password files. The exact parity/delta table lives in the
[recipe schema](../specs/recipe-schema.md#interactive-wizard-parity).
+The flatpaks page inspects the chosen image's
+[core Flatpaks label](../specs/core-flatpaks-label.md) when it opens, behind a
+progress spinner with a 30-second timeout, using preflight's local-first selection without signature
+verification, and again whenever the chosen image changes. It lists a
+published set's app names beside the toggle; explains instead of offering
+the toggle when the image publishes no set or a malformed one (and clears any
+earlier answer); and keeps the toggle with an "available at install time"
+note when inspection fails. The preview never enters the recipe: preflight
+reads the label again at install.
+
Each interactive run owns a randomly named 0700 directory below `/run/firn`.
The reviewed recipe references only 0600 secret files in that directory, and
the wizard's canonical serializer returns the accepted review bytes directly
@@ -184,13 +195,14 @@ and record delimiters before the writer runs.
Flatpaks follow ADR-0006: copy from the medium's seeded
repo, download the remainder into the mounted target, report (never
silently drop) what was unreachable. With `core_flatpaks`, the core set
-comes from first-setup's `core.json` in the deployment, or, when composefs
-makes that unreadable ([ADR-0012](../adr/0012-bootc-install-from-ram-installer.md)),
-from the ISO's `/usr/share/firn/core-flatpaks.json`; neither depends on the
-image being installed.
-[ADR-0018 (Proposed)](../adr/0018-image-published-core-flatpaks-label.md)
-replaces both with an `org.frostyard.core-flatpaks` label each image
-publishes ([roadmap Phase 10](../plans/roadmap.md#phase-10)).
+comes from the selected image's `org.frostyard.core-flatpaks` label
+([ADR-0018](../adr/0018-image-published-core-flatpaks-label.md),
+[spec](../specs/core-flatpaks-label.md)). `preflight-image` reads it from
+the same inspection that selects the source, before any disk write: a
+malformed label fails the install there, and an image with no set is
+reported as `no_core_set`. The flatpak step installs the recipe's explicit
+apps, then the core set, each once. Firn no longer reads first-setup's
+`core.json` or the ISO's `/usr/share/firn/core-flatpaks.json`.
### Trust
@@ -262,9 +274,10 @@ uses the same engine preflight as headless installation.
[ADR-0012](../adr/0012-bootc-install-from-ram-installer.md)
- Scope and contract: [ADR-0015 (Proposed)](../adr/0015-bootc-only-installer-scope.md),
[ADR-0016 (Accepted)](../adr/0016-bootc-only-recipe-contract.md),
- [ADR-0018 (Proposed)](../adr/0018-image-published-core-flatpaks-label.md),
+ [ADR-0018](../adr/0018-image-published-core-flatpaks-label.md),
[roadmap Phase 9](../plans/roadmap.md#phase-9-proposed-post-cutoff-bootc-only-transition-bounded-cross-repo-not-started)
- Contracts: [specs/recipe-schema.md](../specs/recipe-schema.md),
- [specs/progress-protocol.md](../specs/progress-protocol.md)
+ [specs/progress-protocol.md](../specs/progress-protocol.md),
+ [specs/core-flatpaks-label.md](../specs/core-flatpaks-label.md)
- Release and distribution: [ADR-0017](../adr/0017-publish-debian-packages-through-apt-publisher.md)
- Built in: [roadmap — Phases 1–7](../plans/roadmap.md)
diff --git a/docs/plans/roadmap.md b/docs/plans/roadmap.md
index 182868f..9972837 100644
--- a/docs/plans/roadmap.md
+++ b/docs/plans/roadmap.md
@@ -275,8 +275,10 @@ this phase does not rewrite its historical A/B evidence or mark it done.
unsupported overrides wholesale and fall back to bootc-only built-ins.
Remaining rollout work includes inventory and migration of stored v1
recipes, release-date/expiry recording, and coordination with snosi on
- its shipped catalog and ISO/tool payload. Sundog's ref/key and Snowfield's
- scope need confirmation; Sundog is not in `builtinCatalog()`. Keep Firn's media boundary from
+ its shipped catalog and ISO/tool payload. Snowfield's scope needs
+ confirmation. Sundog is offered on the ISO through snosi's shipped
+ `/etc/firn/catalog.json`, which carries its ref and cosign key; it is not
+ in `builtinCatalog()`, which applies only without that file. Keep Firn's media boundary from
[ADR-0010](../adr/0010-single-installer-iso-in-snosi.md) and UEFI floor
from [ADR-0004](../adr/0004-single-installer-scope-and-support-matrix.md).
2. **Qualification and sole-path decision gate:** after the above changes,
@@ -288,7 +290,7 @@ this phase does not rewrite its historical A/B evidence or mark it done.
| --- | --- | --- |
| Snow | Secure Boot on + TPM present + `tpm2-luks-passphrase`; Secure Boot off + TPM absent + `none` | Published image ref and cosign key; published snosi ISO build; device identity; install-to-login and hostname/user/config checks. |
| Floe | Secure Boot off + TPM absent + `luks-passphrase` | Same provenance, device, login and configuration checks; verify boot-time passphrase unlock. |
- | Sundog | Confirm image/key/catalog first; if secure-capable, Secure Boot on + TPM present + `tpm2-luks` | Same provenance, device, login and configuration checks; if not secure-capable, explicitly reallocate the Secure Boot cell to a supported image. |
+ | Sundog | Image, key and catalog entry ship in snosi's catalog; if secure-capable, Secure Boot on + TPM present + `tpm2-luks` | Same provenance, device, login and configuration checks; if not secure-capable, explicitly reallocate the Secure Boot cell to a supported image. |
| Snowfield | Decide whether in the post-cutoff catalog/support scope before qualification | If included, assign a supported distinct hardware cell and record the same provenance, ISO, login and configuration evidence; otherwise record exclusion and rationale. |
For each qualified cell, capture the exact published image digest/ref,
@@ -312,48 +314,49 @@ this phase does not rewrite its historical A/B evidence or mark it done.
decision has been made. **Not complete; implementation in progress.**
-## Phase 10 — Image-published core flatpaks (small, cross-repo) — 📝 proposed
+## Phase 10 — Image-published core flatpaks (small, cross-repo) — ⏳ in progress
-**Proposed.** [ADR-0018 (Proposed)](../adr/0018-image-published-core-flatpaks-label.md)
+**In progress.** [ADR-0018](../adr/0018-image-published-core-flatpaks-label.md)
moves the `core_flatpaks` set from first-setup's `core.json` and the ISO's
`/usr/share/firn/core-flatpaks.json` to an `org.frostyard.core-flatpaks`
-label each image publishes. Today every image, including Sundog and Floe,
-gets first-setup's GNOME list when the user opts in. Order matters: snosi
-labels ship before firn stops reading the old paths.
+label each image publishes ([spec](../specs/core-flatpaks-label.md)).
+Before it, every image, including Sundog and Floe, got first-setup's GNOME
+list when the user opted in. Order matters: snosi labels ship before a firn
+release stops reading the old paths.
Out of scope: offline flatpak seeding. Published ISOs carry no seed, and a
locally seeded ISO keeps today's whole-tree copy (ADR-0018 Consequences).
Migrating existing installs and removing Sundog's native apps belong to
snosi.
-1. **Settle the label contract (firn docs).** ADR-0018, still Proposed,
- defines the parser cases, preflight-time validation, and which image the
- set describes.
-2. **Snosi: publish the label.** Add `flatpaks/snow.json` and
- `flatpaks/sundog.json` (Snowfield uses `snow.json`; Floe has none) and
- validate them in CI. Pass the label at every `buildah-package.sh` call
- site. Generate first-setup's `core.json` on Snow from `snow.json` with a
- drift check. CI checks the label on the final pushed images and its
- absence on Floe.
-3. **Firn: consume the label.** Accept ADR-0018 and write the label spec
- (new `docs/specs/` entry) with this code. `preflight-image` returns the
- selected source's labels, parses them when `core_flatpaks = true`, and
- keeps the set for `runFlatpaks` in `internal/steps/bootc.go`;
- `internal/flatpak` replaces `CoreSet` / `InstallerCoreSet` with the
- parser. Update the `core_flatpaks` row in the
- [recipe schema](../specs/recipe-schema.md), the
- [progress protocol](../specs/progress-protocol.md), and
- [architecture](../design/architecture.md) in the same change, and correct
- the Phase 9 Sundog note (Sundog is offered through snosi's
- `/etc/firn/catalog.json`). Tag only after step 2's images are published.
-4. **Firn TUI.** Inspect the chosen image with a timeout and again on image
- change; distinguish inspect failure, no core set, malformed label and a
- valid set; hiding the toggle clears it. Unit-test navigation and failure
- states, and re-sync `test/e2e-tui.sh` per the drive-tui-e2e skill against
- a fixture rather than live registry labels.
+1. ✅ **Settle the label contract (firn docs).** ADR-0018 defines the parser
+ cases, preflight-time validation, and which image the set describes
+ (#110).
+2. ✅ **Snosi: publish the label** (frostyard/snosi#1054).
+ `flatpaks/snow.json` (Snow and Snowfield) and `flatpaks/sundog.json`;
+ Floe has none. CI validates the files, every packaging lane passes the
+ label and checks it, and the secure lane checks the pushed digest. The
+ ISO fallback and seed read `flatpaks/legacy/firn-core-flatpaks.json`,
+ generated from `snow.json`; first-setup never read its own `core.json`.
+3. ✅ **Firn: consume the label.** ADR-0018 accepted; the
+ [label spec](../specs/core-flatpaks-label.md) lands with the code.
+ `preflight-image` returns the selected source's labels, parses them when
+ `core_flatpaks = true` (malformed fails with
+ `core_flatpaks_label_invalid`), and keeps the set for `runFlatpaks`,
+ which installs explicit apps then the core set, each once.
+ `internal/flatpak` no longer reads first-setup's `core.json` or the ISO
+ fallback. Tag only after step 2's images are published.
+4. ✅ **Firn TUI.** The flatpaks page inspects the chosen image with a
+ timeout and again on image change, and distinguishes inspect failure, no
+ core set, a malformed label and a valid set; hiding the toggle clears it.
+ Unit tests cover image switching and every state. `test/e2e-tui.sh`
+ asserts Floe's real outcome (no toggle, an explanation); the guest
+ already pulls Floe from GHCR, so this adds no new registry dependency.
5. **Snosi: retire the fallback.** Once the ISO carries a step-3 firn
- release, remove `/usr/share/firn/core-flatpaks.json` from
- `shared/firn-installer/mkosi.conf` and the Justfile's `_firn-binary`.
+ release, remove the `/usr/share/firn/core-flatpaks.json` embedding from
+ `shared/firn-installer/mkosi.conf` and the Justfile's `_firn-binary`,
+ and point the seed at `flatpaks/snow.json` so
+ `flatpaks/legacy/firn-core-flatpaks.json` can go.
- **Done when:** Snow and Sundog installs with `core_flatpaks = true` each
land their own image's set, a malformed label fails preflight before any
diff --git a/docs/specs/core-flatpaks-label.md b/docs/specs/core-flatpaks-label.md
new file mode 100644
index 0000000..251b109
--- /dev/null
+++ b/docs/specs/core-flatpaks-label.md
@@ -0,0 +1,102 @@
+# Spec: Core Flatpaks image label (version 1)
+
+This contract governs the `org.frostyard.core-flatpaks` OCI image label: the
+core Flatpak set an image publishes for firn to install when a recipe sets
+`core_flatpaks = true`. Snosi produces it from `flatpaks/` in every image
+build; firn's `preflight-image` step consumes it (`internal/flatpak`
+`ParseCoreLabel`, `internal/steps/preflight.go`). It replaces first-setup's
+`core.json` and the installer ISO's `/usr/share/firn/core-flatpaks.json` as
+the source of the core set ([ADR-0018](../adr/0018-image-published-core-flatpaks-label.md)).
+
+## Interface
+
+The label value is a single JSON object:
+
+```json
+{"version":1,"flatpaks":[{"id":"org.mozilla.firefox","name":"Firefox"}]}
+```
+
+| Field | Type | Required | Constraints |
+| --- | --- | --- | --- |
+| `version` | integer | yes | Exactly `1`. |
+| `flatpaks` | array of objects | yes | May be empty (no core set). |
+| `flatpaks[].id` | string | yes | Flatpak application ID: at least three dot-separated elements of `[A-Za-z0-9_-]`, none starting with a digit, `-` only in the last element, at most 255 characters. |
+| `flatpaks[].name` | string | yes | Display name; not empty or whitespace only, and printable: no control or format characters (newlines, tabs, escape sequences, zero-width joiners). Plain spaces and non-ASCII letters are allowed. |
+
+Field names are exact and case-sensitive. An image with no core set
+publishes no label key.
+
+## Rules
+
+1. Firn parses the label fail-closed. Every row below other than "No core
+ set" and "Accepted" is **malformed**:
+
+ | Label content | Result |
+ | --- | --- |
+ | Key absent | No core set |
+ | Empty string | Malformed |
+ | `null`, or not a single JSON object (invalid JSON, trailing data) | Malformed |
+ | `version` missing, null, not an integer, or not `1` (`1.0` and `true` are not `1`) | Malformed |
+ | `flatpaks` missing or null | Malformed |
+ | Any field other than `version`, `flatpaks`, and per entry `id`, `name`, including case variants such as `VERSION` or `Id` | Malformed |
+ | An entry that is null, or whose `id` is missing, empty, or not a valid application ID | Malformed |
+ | `name` missing, empty, or whitespace only | Malformed |
+ | `name` containing a non-printable character (control or format character) | Malformed |
+ | `flatpaks` is an empty array | No core set |
+ | Duplicate `id` | Accepted; later duplicates dropped, first occurrence kept |
+
+2. Firn reads the label in `preflight-image`, before any disk write, from
+ the image that step selects: the local containers-storage copy when it is
+ selected, otherwise the registry image. For a signed recipe that is the
+ cosign-verified digest firn deploys. For an unsigned recipe bootc resolves
+ the tag again when it pulls, so a tag that moves during the install can
+ deploy a newer build than the label described
+ ([firn#109](https://github.com/frostyard/firn/issues/109)).
+3. With `core_flatpaks = true`:
+ - a malformed label fails `preflight-image` with error code
+ `core_flatpaks_label_invalid`, in dry-run and real runs alike;
+ - labels that could not be read fail `preflight-image` with error code
+ `core_flatpaks_label_unreadable`. A signed `@sha256:` reference can
+ verify while its registry inspection failed and no matching local copy
+ exists; its labels are then unknown, not absent, and the install stops
+ rather than report `no_core_set`;
+ - no core set emits the `no_core_set` warning and summary item, and the
+ install continues;
+ - otherwise the set's IDs are installed after the recipe's explicit
+ `flatpaks`, each ID once at its first occurrence, through ADR-0006's
+ offline-first provisioning.
+4. With `core_flatpaks = false`, firn does not parse the label; a malformed
+ label cannot affect the install.
+5. A new format increments `version`. Firn releases that do not know a
+ version treat its label as malformed.
+6. Producers must not publish an empty `flatpaks` array or an empty-string
+ value; an image without a core set carries no label key.
+7. The TUI wizard previews the label on its flatpaks page, inspecting the
+ chosen image with rule 2's local-first selection (no signature
+ verification) under a 30-second timeout, behind a progress spinner that
+ swallows keys typed meanwhile, and again when the chosen image changes.
+ The local copy is inspected before the registry, so a hanging registry
+ cannot hide a local image. It:
+ - lists a valid set's names beside the `core_flatpaks` toggle;
+ - shows no toggle, and an explanation, for no core set or a malformed
+ label, and sets `core_flatpaks` to false;
+ - keeps the toggle, noting the list is read at install time, when
+ inspection fails or returns no readable metadata, and inspects again on
+ the next visit.
+
+ The preview is display-only and never written into the recipe.
+
+## Derived artifacts
+
+| Artifact | Derivation |
+| --- | --- |
+| Snosi `flatpaks/*.json` and `flatpaks/core-flatpaks.py` | Produce the label per product and check it on every packaged and pushed image; snosi's `test/core-flatpaks-test.sh` mirrors rule 1's rejections. |
+| Progress codes `no_core_set`, `core_flatpaks_label_invalid`, `core_flatpaks_label_unreadable` | [Progress protocol](progress-protocol.md#stable-codes). |
+| `core_flatpaks` recipe field | [Recipe schema](recipe-schema.md). |
+
+## References
+
+- Rationale: [ADR-0018](../adr/0018-image-published-core-flatpaks-label.md),
+ [ADR-0006](../adr/0006-install-time-offline-first-flatpaks.md)
+- Context: [design/architecture.md](../design/architecture.md) (system
+ configuration, flatpaks)
diff --git a/docs/specs/progress-protocol.md b/docs/specs/progress-protocol.md
index 73ac368..6d2ca53 100644
--- a/docs/specs/progress-protocol.md
+++ b/docs/specs/progress-protocol.md
@@ -66,11 +66,13 @@ version-1 wire contract.
| Code | Event surfaces | Meaning |
| --- | --- | --- |
| `cleanup_failed` | `warning` | A registered pipeline cleanup failed; the terminal event still follows and the run is unsuccessful. |
+| `core_flatpaks_label_invalid` | `error` | Core Flatpaks were requested and the selected image's `org.frostyard.core-flatpaks` label is malformed ([core Flatpaks label](core-flatpaks-label.md)); raised in `preflight-image`, so no destructive step has run. |
+| `core_flatpaks_label_unreadable` | `error` | Core Flatpaks were requested but the selected image's labels could not be read (its inspection failed although the image was otherwise resolved); raised in `preflight-image`, so no destructive step has run. Check registry access and retry. |
| `flatpak_unreachable` | `warning`, `summary` | A requested Flatpak could not be reached and was not installed. |
| `group_missing` | `warning`, `summary` | A requested supplementary user group was absent from the installed image. |
| `image_verification_failed` | `error` | Bootc image digest resolution or cosign verification failed in `preflight-image`; no destructive step has run. |
| `image_verification_retried` | `warning` | A cosign verification attempt in `preflight-image` failed (transient registry responses can do this) and a bounded retry follows; verification is not weakened, and the run still fails closed with `image_verification_failed` if the final attempt fails. |
-| `no_core_set` | `warning`, `summary` | Core Flatpaks were requested but the selected image publishes no core set. |
+| `no_core_set` | `warning`, `summary` | Core Flatpaks were requested but the selected image publishes no core set (no label, or an empty one); emitted in `preflight-image` and the install continues. |
| `no_tpm` | `warning` | No TPM was detected; only choices that do not require it remain valid. |
| `recipe_v1_deprecated` | `warning` | Accepted bootc version-1 recipe; message is the `deprecated-version` text in the [recipe schema](recipe-schema.md#bootc-version-1-compatibility). Emitted by install and the TUI before the first `step_start`. `firn validate` writes the deprecation to stderr instead of emitting progress and exits 0. |
| `step_failed` | `error` | A pipeline step failed without a more specific stable code. |
@@ -110,5 +112,9 @@ contain the key.
## References
- Rationale: [ADR-0007](../adr/0007-tui-only-frontend-single-binary.md),
- [ADR-0016](../adr/0016-bootc-only-recipe-contract.md)
+ [ADR-0016](../adr/0016-bootc-only-recipe-contract.md),
+ [ADR-0018](../adr/0018-image-published-core-flatpaks-label.md)
+ (`no_core_set`, `core_flatpaks_label_invalid`,
+ `core_flatpaks_label_unreadable`; see
+ [core Flatpaks label](core-flatpaks-label.md))
- Context: [design/architecture.md](../design/architecture.md)
diff --git a/docs/specs/recipe-schema.md b/docs/specs/recipe-schema.md
index fa5b71e..d9e9c46 100644
--- a/docs/specs/recipe-schema.md
+++ b/docs/specs/recipe-schema.md
@@ -64,11 +64,14 @@ advance past each prompt; the recipe always serializes the accepted values.
| `timezone` | string | no | IANA zone name, e.g. `America/Chicago`; MUST exist in the target's zoneinfo. Empty or omitted preserves the image default. |
| `keyboard` | string | no | `LAYOUT[:VARIANT[:MODEL]]` XKB triplet. Empty or omitted preserves the image default. |
| `flatpaks` | array of string | no | Flatpak application IDs. |
-| `core_flatpaks` | bool | no | Install the image-defined core set where published. Default `false`. |
+| `core_flatpaks` | bool | no | Install the core set the selected image publishes in its `org.frostyard.core-flatpaks` label ([core Flatpaks label](core-flatpaks-label.md)), after `flatpaks`. A malformed label fails preflight; an image with no set installs none and reports `no_core_set`. Default `false`. |
| `root_ssh_authorized_key` / `_file` | string / path | no | At most one. The inline value or referenced file contains one or more newline-separated OpenSSH public-key records; every line MUST be non-empty and match a supported key type, base64 key body, and optional comment. |
The TUI initially offers user creation with the `sudo` group, but initially
-leaves `core_flatpaks` disabled. Rebuilding either form preserves the user's
+leaves `core_flatpaks` disabled. It offers `core_flatpaks` only when the
+chosen image publishes a core set or could not be inspected, and never
+serializes `core_flatpaks = true` for an image whose toggle it hid
+([core Flatpaks label](core-flatpaks-label.md), rule 7). Rebuilding either form preserves the user's
accepted values rather than reapplying these initial selections.
### `[system.user]` (optional table; omit to create no user)
@@ -189,5 +192,7 @@ fail closed with `bad-version`; no downgrade or conversion is attempted.
[ADR-0004](../adr/0004-single-installer-scope-and-support-matrix.md),
[ADR-0006](../adr/0006-install-time-offline-first-flatpaks.md),
[ADR-0015](../adr/0015-bootc-only-installer-scope.md),
- [ADR-0016](../adr/0016-bootc-only-recipe-contract.md)
+ [ADR-0016](../adr/0016-bootc-only-recipe-contract.md),
+ [ADR-0018](../adr/0018-image-published-core-flatpaks-label.md)
+ (`core_flatpaks`; see [core Flatpaks label](core-flatpaks-label.md))
- Context: [design/architecture.md](../design/architecture.md)
diff --git a/internal/bootcimg/bootcimg_test.go b/internal/bootcimg/bootcimg_test.go
index 1ef9a84..e158525 100644
--- a/internal/bootcimg/bootcimg_test.go
+++ b/internal/bootcimg/bootcimg_test.go
@@ -499,9 +499,11 @@ func TestCheckImage(t *testing.T) {
if !tt.wantErr && err != nil {
t.Fatalf("CheckImage() error = %v", err)
}
+ // Local first: a hanging registry must not consume the
+ // deadline before the local copy is inspected.
want := []call{
- {name: "skopeo", args: []string{"inspect", "docker://ghcr.io/tuna-os/yellowfin:gnome-hwe"}},
{name: "skopeo", args: []string{"inspect", "containers-storage:ghcr.io/tuna-os/yellowfin:gnome-hwe"}},
+ {name: "skopeo", args: []string{"inspect", "docker://ghcr.io/tuna-os/yellowfin:gnome-hwe"}},
}
if len(calls) != len(want) {
t.Fatalf("CheckImage() made calls %v, want %v", calls, want)
@@ -527,10 +529,10 @@ func TestCheckImage_StripsTransportPrefix(t *testing.T) {
if len(calls) != 2 {
t.Fatalf("calls = %v", calls)
}
- if got, want := calls[0].args[1], "docker://ghcr.io/ublue-os/bluefin:stable"; got != want {
- t.Errorf("remote ref = %q, want %q", got, want)
- }
- if got, want := calls[1].args[1], "containers-storage:ghcr.io/ublue-os/bluefin:stable"; got != want {
+ if got, want := calls[0].args[1], "containers-storage:ghcr.io/ublue-os/bluefin:stable"; got != want {
t.Errorf("local ref = %q, want %q", got, want)
}
+ if got, want := calls[1].args[1], "docker://ghcr.io/ublue-os/bluefin:stable"; got != want {
+ t.Errorf("remote ref = %q, want %q", got, want)
+ }
}
diff --git a/internal/bootcimg/verify.go b/internal/bootcimg/verify.go
index aedd5ee..e7299d6 100644
--- a/internal/bootcimg/verify.go
+++ b/internal/bootcimg/verify.go
@@ -31,64 +31,93 @@ const verifyAttempts = 3
var verifyBackoff = []time.Duration{5 * time.Second, 15 * time.Second}
type inspectManifest struct {
- Digest string `json:"Digest"`
+ Digest string `json:"Digest"`
+ Labels map[string]string `json:"Labels"`
+}
+
+// Source is the image preflight selected: the reference bootc installs and
+// the config labels of that same image (firn ADR-0018 reads the core
+// Flatpak set from them).
+type Source struct {
+ Ref string
+ Labels map[string]string
+ // Inspected reports that Labels came from a successful inspection of
+ // the selected image. When false the labels are unknown, not absent:
+ // a signed digest can verify while its registry inspection failed.
+ Inspected bool
}
// CheckAndPinImage checks that image is reachable or cached. When keyPath is
// set, it also resolves the source Firn will actually install to an immutable
// digest, verifies that digest with cosign, and returns the pinned reference.
-// Resolving before verification closes the tag-movement race. warn, when
-// non-nil, receives one message per failed non-final cosign attempt (the
-// attempt's stderr is embedded by the runner error).
-func CheckAndPinImage(ctx context.Context, r *runner.Runner, image, keyPath string, warn func(string)) (string, error) {
+// Resolving before verification closes the tag-movement race. Labels come
+// from the inspection of the image selected: the local copy when it wins,
+// otherwise the registry image. warn, when non-nil, receives one message per
+// failed non-final cosign attempt (the attempt's stderr is embedded by the
+// runner error).
+func CheckAndPinImage(ctx context.Context, r *runner.Runner, image, keyPath string, warn func(string)) (Source, error) {
if keyPath != "" && !IsRegistryRef(image) {
- return "", fmt.Errorf("bootcimg: cosign verification requires a registry image reference, got %q", image)
+ return Source{}, fmt.Errorf("bootcimg: cosign verification requires a registry image reference, got %q", image)
}
bare := bareImageRef(image)
- remoteOut, remoteErr := r.Run(ctx, "skopeo", "inspect", "docker://"+bare)
+ // Local first: it needs no network, so a registry that hangs until the
+ // caller's deadline cannot starve the inspection of an image that is
+ // already here (and would be selected).
localOut, localErr := r.Run(ctx, "skopeo", "inspect", "containers-storage:"+bare)
+ remoteOut, remoteErr := r.Run(ctx, "skopeo", "inspect", "docker://"+bare)
+ var local, remote inspectManifest
+ localOK := localErr == nil && json.Unmarshal(localOut, &local) == nil
+ remoteOK := remoteErr == nil && json.Unmarshal(remoteOut, &remote) == nil
if keyPath == "" {
- var local inspectManifest
- if localErr == nil && json.Unmarshal(localOut, &local) == nil {
- return image, nil
+ // Unsigned sources keep the tag, so bootc resolves it again when it
+ // pulls; a tag that moves meanwhile can deploy a newer build than
+ // these labels describe (ADR-0018, firn#109).
+ if localOK {
+ return Source{Ref: image, Labels: local.Labels, Inspected: true}, nil
}
if remoteErr == nil {
- return image, nil
+ return Source{Ref: image, Labels: remote.Labels, Inspected: remoteOK}, nil
}
- return "", fmt.Errorf("bootcimg: image %q is not reachable in its registry and not present in local containers-storage: %w", image, remoteErr)
+ return Source{}, fmt.Errorf("bootcimg: image %q is not reachable in its registry and not present in local containers-storage: %w", image, remoteErr)
}
+ var labels map[string]string
+ inspected := false
digest, pinned := digestReference(bare)
if !pinned {
// Match CheckImage's embedded-image rule: a valid local manifest wins
// even if the registry advertises a newer tag. The selected digest is
// still verified against the registry signature before installation.
- var local inspectManifest
- localSelected := localErr == nil && json.Unmarshal(localOut, &local) == nil
- if localSelected {
+ if localOK {
digest = local.Digest
if !sha256DigestRE.MatchString(digest) {
- return "", fmt.Errorf("bootcimg: selected local image %q has no valid sha256 digest", image)
+ return Source{}, fmt.Errorf("bootcimg: selected local image %q has no valid sha256 digest", image)
}
+ labels, inspected = local.Labels, true
} else {
digest = manifestDigest(remoteOut, remoteErr)
+ labels, inspected = remote.Labels, remoteOK
}
if digest == "" {
if remoteErr != nil {
- return "", fmt.Errorf("bootcimg: resolving verified digest for %q: %w", image, remoteErr)
+ return Source{}, fmt.Errorf("bootcimg: resolving verified digest for %q: %w", image, remoteErr)
}
- return "", fmt.Errorf("bootcimg: resolving verified digest for %q: skopeo returned no valid sha256 digest", image)
+ return Source{}, fmt.Errorf("bootcimg: resolving verified digest for %q: skopeo returned no valid sha256 digest", image)
}
bare = repositoryName(bare) + "@" + digest
} else if !sha256DigestRE.MatchString(digest) {
- return "", fmt.Errorf("bootcimg: invalid immutable digest in image reference %q", image)
+ return Source{}, fmt.Errorf("bootcimg: invalid immutable digest in image reference %q", image)
+ } else if localOK && local.Digest == digest {
+ labels, inspected = local.Labels, true
+ } else if remoteOK {
+ labels, inspected = remote.Labels, true
}
if err := verifyImageSignature(ctx, r, keyPath, bare, warn); err != nil {
- return "", err
+ return Source{}, err
}
- return bare, nil
+ return Source{Ref: bare, Labels: labels, Inspected: inspected}, nil
}
// verifyImageSignature runs cosign verify against the pinned reference,
diff --git a/internal/bootcimg/verify_test.go b/internal/bootcimg/verify_test.go
index 6999e10..f5fb215 100644
--- a/internal/bootcimg/verify_test.go
+++ b/internal/bootcimg/verify_test.go
@@ -63,8 +63,8 @@ func TestCheckAndPinImageVerifiesResolvedDigest(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if want := "ghcr.io/frostyard/snow@" + remoteDigest; got != want {
- t.Fatalf("pinned source = %q, want %q", got, want)
+ if want := "ghcr.io/frostyard/snow@" + remoteDigest; got.Ref != want {
+ t.Fatalf("pinned source = %q, want %q", got.Ref, want)
}
}
@@ -79,8 +79,8 @@ func TestCheckAndPinImageOfflineCachedImage(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if want := "ghcr.io/frostyard/snow@" + localDigest; got != want {
- t.Fatalf("offline pinned source = %q, want cached %q", got, want)
+ if want := "ghcr.io/frostyard/snow@" + localDigest; got.Ref != want {
+ t.Fatalf("offline pinned source = %q, want cached %q", got.Ref, want)
}
}
@@ -97,8 +97,8 @@ func TestCheckAndPinImageBindsVerificationToSelectedLocalDigest(t *testing.T) {
t.Fatal(err)
}
want := "registry.example.com:5000/snow@" + localDigest
- if got != want || verified != want {
- t.Fatalf("source = %q, verified = %q, want selected digest %q", got, verified, want)
+ if got.Ref != want || verified != want {
+ t.Fatalf("source = %q, verified = %q, want selected digest %q", got.Ref, verified, want)
}
}
@@ -146,8 +146,8 @@ func TestCheckAndPinImageVerificationFailures(t *testing.T) {
if err != nil {
t.Fatalf("verification after transient failures = %v, want success", err)
}
- if want := "ghcr.io/frostyard/snow@" + remoteDigest; got != want {
- t.Fatalf("pinned source = %q, want %q", got, want)
+ if want := "ghcr.io/frostyard/snow@" + remoteDigest; got.Ref != want {
+ t.Fatalf("pinned source = %q, want %q", got.Ref, want)
}
} else {
if err == nil || !strings.Contains(err.Error(), tc.err.Error()) {
@@ -214,3 +214,108 @@ func TestCheckAndPinImageRejectsLocalTransportWithVerification(t *testing.T) {
t.Fatalf("local transport error = %v", err)
}
}
+
+// TestCheckAndPinImageLabelsFollowSelectedImage pins ADR-0018's rule that
+// the core Flatpak label is read from the image preflight selects, never
+// from the other inspection.
+func TestCheckAndPinImageLabelsFollowSelectedImage(t *testing.T) {
+ inspect := func(digest, set string) []byte {
+ return []byte(`{"Digest":"` + digest + `","Labels":{"containers.bootc":"1","set":"` + set + `"}}`)
+ }
+ notCached := errors.New("not cached")
+ offline := errors.New("network unreachable")
+ for _, tc := range []struct {
+ name string
+ image string
+ key string
+ remoteOut []byte
+ remoteErr error
+ localOut []byte
+ localErr error
+ wantRef string
+ wantSet string // "" means no labels
+ unknown bool // labels could not be read (Inspected false)
+ }{
+ {name: "unsigned, local copy wins over a newer registry image",
+ image: "ghcr.io/frostyard/snow:latest",
+ remoteOut: inspect(remoteDigest, "remote"), localOut: inspect(localDigest, "local"),
+ wantRef: "ghcr.io/frostyard/snow:latest", wantSet: "local"},
+ {name: "unsigned, registry when nothing is cached",
+ image: "ghcr.io/frostyard/snow:latest",
+ remoteOut: inspect(remoteDigest, "remote"), localErr: notCached,
+ wantRef: "ghcr.io/frostyard/snow:latest", wantSet: "remote"},
+ {name: "signed tag, local copy wins",
+ image: "ghcr.io/frostyard/snow:latest", key: "/keys/cosign.pub",
+ remoteOut: inspect(remoteDigest, "remote"), localOut: inspect(localDigest, "local"),
+ wantRef: "ghcr.io/frostyard/snow@" + localDigest, wantSet: "local"},
+ {name: "signed tag, registry when nothing is cached",
+ image: "ghcr.io/frostyard/snow:latest", key: "/keys/cosign.pub",
+ remoteOut: inspect(remoteDigest, "remote"), localErr: notCached,
+ wantRef: "ghcr.io/frostyard/snow@" + remoteDigest, wantSet: "remote"},
+ {name: "signed digest, local copy of that digest",
+ image: "ghcr.io/frostyard/snow@" + localDigest, key: "/keys/cosign.pub",
+ remoteOut: inspect(localDigest, "remote"), localOut: inspect(localDigest, "local"),
+ wantRef: "ghcr.io/frostyard/snow@" + localDigest, wantSet: "local"},
+ {name: "signed digest, local copy of another digest is ignored",
+ image: "ghcr.io/frostyard/snow@" + remoteDigest, key: "/keys/cosign.pub",
+ remoteOut: inspect(remoteDigest, "remote"), localOut: inspect(localDigest, "local"),
+ wantRef: "ghcr.io/frostyard/snow@" + remoteDigest, wantSet: "remote"},
+ {name: "signed digest, offline with no matching local copy: labels unknown",
+ image: "ghcr.io/frostyard/snow@" + remoteDigest, key: "/keys/cosign.pub",
+ remoteErr: offline, localErr: notCached,
+ wantRef: "ghcr.io/frostyard/snow@" + remoteDigest, unknown: true},
+ {name: "unsigned, unreadable registry inspection: labels unknown",
+ image: "ghcr.io/frostyard/snow:latest",
+ remoteOut: []byte("not json"), localErr: notCached,
+ wantRef: "ghcr.io/frostyard/snow:latest", unknown: true},
+ {name: "inspection without labels",
+ image: "ghcr.io/frostyard/snow:latest", key: "/keys/cosign.pub",
+ remoteOut: []byte(`{"Digest":"` + remoteDigest + `"}`), localErr: notCached,
+ wantRef: "ghcr.io/frostyard/snow@" + remoteDigest},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var calls [][]string
+ r := verifyRunner(t, tc.remoteOut, tc.remoteErr, tc.localOut, tc.localErr, nil, &calls)
+ got, err := CheckAndPinImage(context.Background(), r, tc.image, tc.key, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Ref != tc.wantRef {
+ t.Fatalf("source ref = %q, want %q", got.Ref, tc.wantRef)
+ }
+ if gotSet := got.Labels["set"]; gotSet != tc.wantSet {
+ t.Fatalf("labels came from %q, want %q (labels %v)", gotSet, tc.wantSet, got.Labels)
+ }
+ if got.Inspected == tc.unknown {
+ t.Fatalf("Inspected = %v, want %v", got.Inspected, !tc.unknown)
+ }
+ })
+ }
+}
+
+// A registry that hangs until the caller's deadline must not starve the
+// inspection of a local copy, which is the one selected.
+func TestCheckAndPinImageLocalInspectSurvivesHangingRegistry(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ r := runner.NewFake(
+ func(ctx context.Context, name string, args ...string) ([]byte, error) {
+ if strings.HasPrefix(args[1], "docker://") {
+ <-ctx.Done() // the registry hangs until the deadline
+ return nil, ctx.Err()
+ }
+ if ctx.Err() != nil {
+ return nil, ctx.Err() // exec.CommandContext would refuse to start
+ }
+ return []byte(`{"Digest":"` + localDigest + `","Labels":{"set":"local"}}`), nil
+ },
+ func(name string) (string, error) { return "/usr/bin/" + name, nil },
+ )
+ got, err := CheckAndPinImage(ctx, r, "ghcr.io/frostyard/snow:latest", "", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.Inspected || got.Labels["set"] != "local" {
+ t.Fatalf("source = %+v, want the local copy's labels", got)
+ }
+}
diff --git a/internal/flatpak/flatpak.go b/internal/flatpak/flatpak.go
index 7b2ad98..ca34be4 100644
--- a/internal/flatpak/flatpak.go
+++ b/internal/flatpak/flatpak.go
@@ -11,7 +11,6 @@ package flatpak
import (
"context"
- "encoding/json"
"errors"
"fmt"
"io"
@@ -291,68 +290,3 @@ func dirSize(ctx context.Context, r *runner.Runner, path string) int64 {
n, _ := strconv.ParseInt(fields[0], 10, 64)
return n
}
-
-// coreJSONPath is where snosi images publish their core flatpak set.
-// The file is owned by the frostyard/first-setup repository and ships
-// in its package — the single source of truth for the system flatpaks
-// firn installs with core_flatpaks (snosi-firstboot line 36 read the
-// same file on first boot before firn took the install-time role).
-const coreJSONPath = "usr/share/org.frostyard.FirstSetup/snow_first_setup/core.json"
-
-// InstallerCoreJSONPath is where the single-installer ISO embeds the
-// same core flatpak list, read as a fallback when the deployment does
-// not expose one. Composefs-native bootc images (every snosi desktop
-// image) keep /usr in composefs objects that are not readable as plain
-// files at install time, so CoreSet against the deployment root returns
-// ok=false and core_flatpaks would install nothing. The ISO build
-// (snosi firn-installer) copies first-setup's core.json here so the list
-// is always readable. A var so tests can point it elsewhere.
-var InstallerCoreJSONPath = "/usr/share/firn/core-flatpaks.json"
-
-// CoreSet reads the image-defined core flatpak app IDs from a mounted
-// image root (bootc deployment root or A/B erofs root). Images that
-// publish no readable core set (e.g. server images without
-// snow-first-setup, or composefs deployments whose /usr is not
-// materialized at install time) return ok=false — the caller falls back
-// to InstallerCoreSet, then reports it, per the recipe's core_flatpaks
-// contract ("where the image family publishes one"). The list is
-// deduplicated: it is human-maintained and has carried duplicates
-// (snosi-firstboot's sort -u comment).
-func CoreSet(imageRoot string) (ids []string, ok bool, err error) {
- return parseCoreJSON(filepath.Join(imageRoot, coreJSONPath))
-}
-
-// InstallerCoreSet reads the core flatpak list the installer medium
-// embedded at InstallerCoreJSONPath, if present. Same result shape as
-// CoreSet; a missing file returns ok=false.
-func InstallerCoreSet() (ids []string, ok bool, err error) {
- return parseCoreJSON(InstallerCoreJSONPath)
-}
-
-// parseCoreJSON reads and deduplicates the core flatpak app IDs from a
-// core.json at path. A missing file is not an error (ok=false).
-func parseCoreJSON(path string) (ids []string, ok bool, err error) {
- data, readErr := os.ReadFile(path)
- if readErr != nil {
- if os.IsNotExist(readErr) {
- return nil, false, nil
- }
- return nil, false, fmt.Errorf("flatpak: reading core set: %w", readErr)
- }
- var parsed struct {
- Core []struct {
- ID string `json:"id"`
- } `json:"core"`
- }
- if err := json.Unmarshal(data, &parsed); err != nil {
- return nil, false, fmt.Errorf("flatpak: parsing core.json: %w", err)
- }
- seen := map[string]bool{}
- for _, e := range parsed.Core {
- if e.ID != "" && !seen[e.ID] {
- seen[e.ID] = true
- ids = append(ids, e.ID)
- }
- }
- return ids, true, nil
-}
diff --git a/internal/flatpak/flatpak_test.go b/internal/flatpak/flatpak_test.go
index 391ca1c..62a5071 100644
--- a/internal/flatpak/flatpak_test.go
+++ b/internal/flatpak/flatpak_test.go
@@ -5,7 +5,6 @@ package flatpak
import (
"context"
"fmt"
- "os"
"path/filepath"
"slices"
"strings"
@@ -398,54 +397,3 @@ func TestFlatpakListSkipsHeaderAndBlank(t *testing.T) {
t.Errorf("flatpakList = %v", got)
}
}
-
-func TestCoreSetReadsAndDedupes(t *testing.T) {
- root := t.TempDir()
- dir := filepath.Join(root, coreJSONPath)
- if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
- t.Fatal(err)
- }
- body := `{"core":[{"id":"org.gnome.Console"},{"id":"org.mozilla.firefox"},{"id":"org.gnome.Console"}]}`
- if err := os.WriteFile(dir, []byte(body), 0o644); err != nil {
- t.Fatal(err)
- }
- ids, ok, err := CoreSet(root)
- if err != nil || !ok {
- t.Fatalf("CoreSet ok=%v err=%v", ok, err)
- }
- if !slices.Equal(ids, []string{"org.gnome.Console", "org.mozilla.firefox"}) {
- t.Fatalf("CoreSet ids = %v (want deduped, in order)", ids)
- }
-}
-
-func TestCoreSetMissingIsNotAnError(t *testing.T) {
- ids, ok, err := CoreSet(t.TempDir())
- if err != nil || ok || ids != nil {
- t.Fatalf("CoreSet on empty root: ids=%v ok=%v err=%v", ids, ok, err)
- }
-}
-
-// TestInstallerCoreSetFallback covers the composefs case: the deployment
-// publishes no readable core set, but the installer medium embeds one.
-func TestInstallerCoreSetFallback(t *testing.T) {
- // Deployment root has no core.json (mimics composefs /usr).
- if _, ok, _ := CoreSet(t.TempDir()); ok {
- t.Fatal("expected no core set from an empty deployment root")
- }
- // The installer-embedded copy is readable.
- embedded := filepath.Join(t.TempDir(), "core-flatpaks.json")
- if err := os.WriteFile(embedded, []byte(`{"core":[{"id":"org.gnome.Loupe"}]}`), 0o644); err != nil {
- t.Fatal(err)
- }
- prev := InstallerCoreJSONPath
- InstallerCoreJSONPath = embedded
- t.Cleanup(func() { InstallerCoreJSONPath = prev })
-
- ids, ok, err := InstallerCoreSet()
- if err != nil || !ok {
- t.Fatalf("InstallerCoreSet ok=%v err=%v", ok, err)
- }
- if !slices.Equal(ids, []string{"org.gnome.Loupe"}) {
- t.Fatalf("InstallerCoreSet ids = %v", ids)
- }
-}
diff --git a/internal/flatpak/label.go b/internal/flatpak/label.go
new file mode 100644
index 0000000..8096359
--- /dev/null
+++ b/internal/flatpak/label.go
@@ -0,0 +1,154 @@
+package flatpak
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "regexp"
+ "strings"
+ "unicode"
+)
+
+// CoreLabel is the OCI image label carrying the image's core Flatpak set
+// (docs/specs/core-flatpaks-label.md, ADR-0018).
+const CoreLabel = "org.frostyard.core-flatpaks"
+
+// coreLabelVersion is the only label format version this firn understands.
+const coreLabelVersion = 1
+
+// appIDRE is Flatpak's application ID grammar: at least three dot-separated
+// elements of [A-Za-z0-9_-], none starting with a digit, '-' only in the
+// last element. The 255-character limit is checked separately.
+var appIDRE = regexp.MustCompile(`^(?:[A-Za-z_][A-Za-z0-9_]*\.){2,}[A-Za-z_-][A-Za-z0-9_-]*$`)
+
+// ErrMalformedCoreLabel wraps every rejection of a present label, so
+// callers can tell a malformed label from other failures.
+var ErrMalformedCoreLabel = errors.New("malformed " + CoreLabel + " label")
+
+// coreLabel mirrors the label's JSON. Pointers distinguish a missing or
+// null field from a zero value.
+type coreLabel struct {
+ Version *int `json:"version"`
+ Flatpaks *[]coreLabelItem `json:"flatpaks"`
+}
+
+type coreLabelItem struct {
+ ID *string `json:"id"`
+ Name *string `json:"name"`
+}
+
+// CoreApp is one entry of an image's core Flatpak set.
+type CoreApp struct {
+ ID string
+ Name string
+}
+
+// ParseCoreLabel returns the app IDs of an image's core Flatpak set from the
+// value of its CoreLabel. present reports whether the label key exists.
+//
+// An absent label, or a valid label with an empty flatpaks array, is no
+// core set: (nil, nil). Anything else that does not match the spec is an
+// error wrapping ErrMalformedCoreLabel. Duplicate IDs are dropped, keeping
+// the first occurrence.
+func ParseCoreLabel(value string, present bool) ([]string, error) {
+ apps, err := ParseCoreLabelApps(value, present)
+ if err != nil {
+ return nil, err
+ }
+ var ids []string
+ for _, app := range apps {
+ ids = append(ids, app.ID)
+ }
+ return ids, nil
+}
+
+// ParseCoreLabelApps is ParseCoreLabel keeping each app's display name, for
+// the wizard's preview.
+func ParseCoreLabelApps(value string, present bool) ([]CoreApp, error) {
+ if !present {
+ return nil, nil
+ }
+ malformed := func(format string, args ...any) error {
+ return fmt.Errorf("%w: %s", ErrMalformedCoreLabel, fmt.Sprintf(format, args...))
+ }
+ if value == "" {
+ return nil, malformed("empty value")
+ }
+ // Decode accepts a bare null as an empty object; the label must be one.
+ if bytes.Equal(bytes.TrimSpace([]byte(value)), []byte("null")) {
+ return nil, malformed("not a JSON object")
+ }
+ dec := json.NewDecoder(strings.NewReader(value))
+ dec.DisallowUnknownFields()
+ var label coreLabel
+ if err := dec.Decode(&label); err != nil {
+ return nil, malformed("%v", err)
+ }
+ if _, err := dec.Token(); !errors.Is(err, io.EOF) {
+ return nil, malformed("trailing data after the JSON object")
+ }
+ if label.Version == nil {
+ return nil, malformed("version is missing or null")
+ }
+ if *label.Version != coreLabelVersion {
+ return nil, malformed("unsupported version %d", *label.Version)
+ }
+ if label.Flatpaks == nil {
+ return nil, malformed("flatpaks is missing or null")
+ }
+ // encoding/json matches field names case-insensitively, so
+ // DisallowUnknownFields alone accepts "VERSION" or "Id". The spec's
+ // names are exact; check them at both object levels.
+ if err := exactFieldNames(value); err != nil {
+ return nil, malformed("%v", err)
+ }
+ var apps []CoreApp
+ seen := make(map[string]bool, len(*label.Flatpaks))
+ for i, item := range *label.Flatpaks {
+ if item.ID == nil || *item.ID == "" || len(*item.ID) > 255 || !appIDRE.MatchString(*item.ID) {
+ return nil, malformed("flatpaks[%d]: invalid Flatpak application ID", i)
+ }
+ if item.Name == nil || strings.TrimSpace(*item.Name) == "" {
+ return nil, malformed("flatpaks[%d]: name is missing or empty", i)
+ }
+ // Names reach the installer console before the image is verified;
+ // control characters and escape sequences must not.
+ if strings.IndexFunc(*item.Name, func(r rune) bool { return !unicode.IsPrint(r) }) >= 0 {
+ return nil, malformed("flatpaks[%d]: name contains a non-printable character", i)
+ }
+ if !seen[*item.ID] {
+ seen[*item.ID] = true
+ apps = append(apps, CoreApp{ID: *item.ID, Name: *item.Name})
+ }
+ }
+ return apps, nil
+}
+
+// exactFieldNames rejects any key in the label object, or in a flatpaks
+// entry, that is not spelled exactly as the spec spells it. It runs after
+// the typed decode, so the shapes it walks are already known to be valid.
+func exactFieldNames(value string) error {
+ var top map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(value), &top); err != nil {
+ return err
+ }
+ for key := range top {
+ if key != "version" && key != "flatpaks" {
+ return fmt.Errorf("unknown field %q", key)
+ }
+ }
+ var items []map[string]json.RawMessage
+ if err := json.Unmarshal(top["flatpaks"], &items); err != nil {
+ return err
+ }
+ for i, item := range items {
+ for key := range item {
+ if key != "id" && key != "name" {
+ return fmt.Errorf("flatpaks[%d]: unknown field %q", i, key)
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/flatpak/label_test.go b/internal/flatpak/label_test.go
new file mode 100644
index 0000000..fbb5cad
--- /dev/null
+++ b/internal/flatpak/label_test.go
@@ -0,0 +1,112 @@
+package flatpak
+
+import (
+ "errors"
+ "slices"
+ "strings"
+ "testing"
+)
+
+// TestParseCoreLabel pins every row of the ADR-0018 / core-flatpaks-label
+// spec table. The rejection cases mirror snosi's producer test
+// (test/core-flatpaks-test.sh) so producer and consumer agree on them.
+func TestParseCoreLabel(t *testing.T) {
+ const app = `{"id":"org.example.App","name":"App"}`
+ for _, tc := range []struct {
+ name string
+ value string
+ present bool
+ want []string // nil with wantErr false means no core set
+ wantErr bool
+ }{
+ {name: "key absent", present: false},
+ {name: "valid set", present: true,
+ value: `{"version":1,"flatpaks":[{"id":"org.mozilla.firefox","name":"Firefox"},` + app + `]}`,
+ want: []string{"org.mozilla.firefox", "org.example.App"}},
+ {name: "re-encoded with whitespace", present: true,
+ value: "{\n \"version\": 1,\n \"flatpaks\": [" + app + "]\n}\n",
+ want: []string{"org.example.App"}},
+ {name: "empty flatpaks array is no core set", present: true,
+ value: `{"version":1,"flatpaks":[]}`},
+ {name: "duplicates keep the first occurrence", present: true,
+ value: `{"version":1,"flatpaks":[{"id":"org.b.B","name":"B"},` + app + `,{"id":"org.b.B","name":"B again"}]}`,
+ want: []string{"org.b.B", "org.example.App"}},
+ {name: "name with spaces and non-ASCII letters", present: true,
+ value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"Visionneuse d’images"}]}`,
+ want: []string{"org.example.App"}},
+ {name: "dash in the last element", present: true,
+ value: `{"version":1,"flatpaks":[{"id":"org.example.my-app","name":"App"}]}`,
+ want: []string{"org.example.my-app"}},
+
+ {name: "empty string", present: true, value: "", wantErr: true},
+ {name: "null", present: true, value: "null", wantErr: true},
+ {name: "not JSON", present: true, value: "not json", wantErr: true},
+ {name: "array, not object", present: true, value: "[" + app + "]", wantErr: true},
+ {name: "trailing data", present: true, value: `{"version":1,"flatpaks":[` + app + `]} {}`, wantErr: true},
+ {name: "version missing", present: true, value: `{"flatpaks":[` + app + `]}`, wantErr: true},
+ {name: "version null", present: true, value: `{"version":null,"flatpaks":[` + app + `]}`, wantErr: true},
+ {name: "version 2", present: true, value: `{"version":2,"flatpaks":[` + app + `]}`, wantErr: true},
+ {name: "version 1.0", present: true, value: `{"version":1.0,"flatpaks":[` + app + `]}`, wantErr: true},
+ {name: "version true", present: true, value: `{"version":true,"flatpaks":[` + app + `]}`, wantErr: true},
+ {name: "version string", present: true, value: `{"version":"1","flatpaks":[` + app + `]}`, wantErr: true},
+ {name: "flatpaks missing", present: true, value: `{"version":1}`, wantErr: true},
+ {name: "flatpaks null", present: true, value: `{"version":1,"flatpaks":null}`, wantErr: true},
+ {name: "unknown top-level field", present: true, value: `{"version":1,"flatpaks":[` + app + `],"extra":1}`, wantErr: true},
+ {name: "uppercase top-level fields", present: true, value: `{"VERSION":1,"FLATPAKS":[` + app + `]}`, wantErr: true},
+ {name: "capitalized version only", present: true, value: `{"Version":1,"flatpaks":[` + app + `]}`, wantErr: true},
+ {name: "uppercase entry fields", present: true, value: `{"version":1,"flatpaks":[{"ID":"org.example.App","NAME":"App"}]}`, wantErr: true},
+ {name: "case-variant duplicate key", present: true, value: `{"version":1,"Version":2,"flatpaks":[` + app + `]}`, wantErr: true},
+ {name: "unknown entry field", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"App","x":1}]}`, wantErr: true},
+ {name: "null entry", present: true, value: `{"version":1,"flatpaks":[null]}`, wantErr: true},
+ {name: "id missing", present: true, value: `{"version":1,"flatpaks":[{"name":"App"}]}`, wantErr: true},
+ {name: "id empty", present: true, value: `{"version":1,"flatpaks":[{"id":"","name":"App"}]}`, wantErr: true},
+ {name: "id with two elements", present: true, value: `{"version":1,"flatpaks":[{"id":"org.App","name":"App"}]}`, wantErr: true},
+ {name: "element starting with a digit", present: true, value: `{"version":1,"flatpaks":[{"id":"org.1example.App","name":"App"}]}`, wantErr: true},
+ {name: "dash before the last element", present: true, value: `{"version":1,"flatpaks":[{"id":"org.ex-ample.App","name":"App"}]}`, wantErr: true},
+ {name: "id with a space", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.My App","name":"App"}]}`, wantErr: true},
+ {name: "id with a leading option dash", present: true, value: `{"version":1,"flatpaks":[{"id":"--system","name":"App"}]}`, wantErr: true},
+ {name: "id over 255 characters", present: true,
+ value: `{"version":1,"flatpaks":[{"id":"org.example.` + strings.Repeat("a", 250) + `","name":"App"}]}`, wantErr: true},
+ {name: "name with an escape sequence", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"B\u001b[2J\u001b[31mEVIL"}]}`, wantErr: true},
+ {name: "name with a newline", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"B\nC"}]}`, wantErr: true},
+ {name: "name with a tab", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"B\tC"}]}`, wantErr: true},
+ {name: "name with a zero-width joiner", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":"B\u200dC"}]}`, wantErr: true},
+ {name: "name missing", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App"}]}`, wantErr: true},
+ {name: "name empty", present: true, value: `{"version":1,"flatpaks":[{"id":"org.example.App","name":" "}]}`, wantErr: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := ParseCoreLabel(tc.value, tc.present)
+ if tc.wantErr {
+ if !errors.Is(err, ErrMalformedCoreLabel) {
+ t.Fatalf("ParseCoreLabel(%q) error = %v, want ErrMalformedCoreLabel", tc.value, err)
+ }
+ if got != nil {
+ t.Fatalf("ParseCoreLabel(%q) returned %v alongside an error", tc.value, got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("ParseCoreLabel(%q) error = %v", tc.value, err)
+ }
+ if !slices.Equal(got, tc.want) {
+ t.Fatalf("ParseCoreLabel(%q) = %v, want %v", tc.value, got, tc.want)
+ }
+ })
+ }
+}
+
+// The error names the actual problem, so the wizard note and the preflight
+// error tell the user what is wrong with the label.
+func TestParseCoreLabelMessages(t *testing.T) {
+ for value, want := range map[string]string{
+ `{"version":1}`: "flatpaks is missing or null",
+ `{"flatpaks":[]}`: "version is missing or null",
+ `{"version":1,"FLATPAKS":[]}`: `unknown field "FLATPAKS"`,
+ `{"version":1,"flatpaks":[],"Version":1}`: `unknown field "Version"`,
+ } {
+ _, err := ParseCoreLabel(value, true)
+ if err == nil || !strings.Contains(err.Error(), want) {
+ t.Errorf("ParseCoreLabel(%s) error = %v, want it to mention %q", value, err, want)
+ }
+ }
+}
diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go
index 242aee3..7ccf218 100644
--- a/internal/pipeline/pipeline.go
+++ b/internal/pipeline/pipeline.go
@@ -59,6 +59,10 @@ type Env struct {
// cosign verification is requested, it is the verified immutable digest;
// the original recipe ref remains the day-two tracking reference.
BootcSourceRef string
+ // CoreFlatpaks is the core Flatpak set preflight-image read from the
+ // selected image's label when the recipe sets core_flatpaks (ADR-0018);
+ // empty when the image publishes none.
+ CoreFlatpaks []string
// SecureImageRoot is an extracted tree of the secure image's
// usr/lib/{snosi,shim} subtrees (secureboot.ExtractSecureImageRoot),
// populated by bootc-install for a secure bootc install and consumed by
diff --git a/internal/progress/progress.go b/internal/progress/progress.go
index 26fa279..9da9046 100644
--- a/internal/progress/progress.go
+++ b/internal/progress/progress.go
@@ -113,18 +113,20 @@ func (f EmitterFunc) Emit(event Event) error { return f(event) }
// Warning and error codes stable enough to be part of the contract are
// collected here as they are introduced (spec rule 3).
const (
- CodeStepFailed = "step_failed"
- CodeCleanupFailed = "cleanup_failed"
- CodeImageVerifyFailed = "image_verification_failed"
- CodeImageVerifyRetried = "image_verification_retried"
- CodeNoTPM = "no_tpm"
- CodeFlatpakUnreachable = "flatpak_unreachable"
- CodeGroupMissing = "group_missing"
- CodeNoCoreSet = "no_core_set"
- CodeStoreUnmountFailed = "store_umount_failed"
- CodeStoreCleanupFailed = "store_cleanup_failed"
- CodeStreamTruncated = "stream_truncated"
- CodeRecipeV1Deprecated = "recipe_v1_deprecated"
+ CodeStepFailed = "step_failed"
+ CodeCleanupFailed = "cleanup_failed"
+ CodeImageVerifyFailed = "image_verification_failed"
+ CodeImageVerifyRetried = "image_verification_retried"
+ CodeNoTPM = "no_tpm"
+ CodeFlatpakUnreachable = "flatpak_unreachable"
+ CodeGroupMissing = "group_missing"
+ CodeNoCoreSet = "no_core_set"
+ CodeCoreLabelInvalid = "core_flatpaks_label_invalid"
+ CodeCoreLabelUnreadable = "core_flatpaks_label_unreadable"
+ CodeStoreUnmountFailed = "store_umount_failed"
+ CodeStoreCleanupFailed = "store_cleanup_failed"
+ CodeStreamTruncated = "stream_truncated"
+ CodeRecipeV1Deprecated = "recipe_v1_deprecated"
)
func isTerminal(e Event) bool {
diff --git a/internal/steps/bootc.go b/internal/steps/bootc.go
index 0997c28..f9149cf 100644
--- a/internal/steps/bootc.go
+++ b/internal/steps/bootc.go
@@ -561,39 +561,15 @@ func findBootcUKI(root string) (uki string, allEFI []string) {
}
func runFlatpaks(ctx context.Context, env *pipeline.Env) error {
- apps := env.Recipe.System.Flatpaks
- if env.Recipe.System.CoreFlatpaks {
- // The core set ships in the image's /usr. On an ostree
- // deployment that tree is materialized on disk; on
- // composefs-native targets /usr lives in composefs objects and
- // is not readable as plain files at install time — report,
- // don't guess.
- etcDir, err := sysconfig.EtcDir(ctx, env.Runner, targetDir(env))
- if err != nil {
- return err
- }
- core, ok, err := flatpak.CoreSet(filepath.Dir(etcDir))
- if err != nil {
- return err
- }
- if !ok {
- // Composefs-native deployments (every snosi desktop image)
- // keep /usr in composefs objects unreadable at install time,
- // so the deployment publishes no readable core set. Fall back
- // to the copy the installer medium embeds.
- core, ok, err = flatpak.InstallerCoreSet()
- if err != nil {
- return err
- }
- }
- if !ok {
- msg := "core_flatpaks: no readable core set in the deployment or on the installer medium"
- if err := env.Emit(progress.Warning{Code: progress.CodeNoCoreSet, Message: msg}); err != nil {
- return err
- }
- env.AddSummary(progress.CodeNoCoreSet, msg)
+ // Explicit apps first, then the core set preflight-image read from the
+ // image's label (ADR-0018); each ID once, at its first occurrence.
+ var apps []string
+ seen := make(map[string]bool)
+ for _, app := range append(append([]string{}, env.Recipe.System.Flatpaks...), env.CoreFlatpaks...) {
+ if !seen[app] {
+ seen[app] = true
+ apps = append(apps, app)
}
- apps = append(apps, core...)
}
if len(apps) == 0 {
return nil
diff --git a/internal/steps/core_flatpaks_test.go b/internal/steps/core_flatpaks_test.go
new file mode 100644
index 0000000..0a3b9c1
--- /dev/null
+++ b/internal/steps/core_flatpaks_test.go
@@ -0,0 +1,246 @@
+package steps
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/frostyard/firn/internal/flatpak"
+ "github.com/frostyard/firn/internal/pipeline"
+ "github.com/frostyard/firn/internal/progress"
+ "github.com/frostyard/firn/internal/recipe"
+ "github.com/frostyard/firn/internal/runner"
+)
+
+// coreLabelFake answers preflight for a clean UEFI machine whose image
+// carries labels; every command is recorded in commands.
+func coreLabelFake(t *testing.T, labels map[string]string, commands *[]string) *runner.Runner {
+ return coreLabelFakeWith(t, labels, commands, nil)
+}
+
+// coreLabelFakeWith is coreLabelFake with an optional override for skopeo.
+func coreLabelFakeWith(t *testing.T, labels map[string]string, commands *[]string,
+ skopeo func(args []string) ([]byte, error)) *runner.Runner {
+ t.Helper()
+ const lsblkJSON = `{"blockdevices": [
+ {"path": "/dev/vda", "type": "disk", "size": 64000000000, "fstype": null, "label": null, "mountpoints": [null]}]}`
+ inspect := `{"Digest":"` + verifiedBootcDigest + `","Labels":{"containers.bootc":"1"`
+ for k, v := range labels {
+ inspect += `,` + jsonString(k) + `:` + jsonString(v)
+ }
+ inspect += `}}`
+ return runner.NewFake(
+ func(_ context.Context, name string, args ...string) ([]byte, error) {
+ *commands = append(*commands, strings.Join(append([]string{name}, args...), " "))
+ switch name {
+ case "lsblk":
+ return []byte(lsblkJSON), nil
+ case "findmnt":
+ return []byte("overlay\n"), nil
+ case "skopeo":
+ if skopeo != nil {
+ return skopeo(args)
+ }
+ return []byte(inspect), nil
+ }
+ return []byte(""), nil
+ },
+ func(name string) (string, error) { return "/usr/bin/" + name, nil },
+ )
+}
+
+func jsonString(s string) string {
+ return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
+}
+
+func coreRecipe(t *testing.T, coreFlatpaks bool) *recipe.Loaded {
+ t.Helper()
+ src := strings.Replace(bootcBase, "%s", "none", 1)
+ if coreFlatpaks {
+ src += "core_flatpaks = true\n"
+ }
+ return load(t, src)
+}
+
+func runCore(t *testing.T, l *recipe.Loaded, r *runner.Runner, dryRun bool) (*pipeline.Env, []progress.Event, error) {
+ t.Helper()
+ var events []progress.Event
+ env := &pipeline.Env{
+ Recipe: &l.Recipe, Runner: r, UEFI: true, Version: "test",
+ TargetDir: filepath.Join(t.TempDir(), "target"),
+ ScratchDir: filepath.Join(t.TempDir(), "scratch"),
+ Emitter: progress.EmitterFunc(func(e progress.Event) error {
+ events = append(events, e)
+ return nil
+ }),
+ }
+ err := Assemble(l).Run(context.Background(), env, dryRun)
+ return env, events, err
+}
+
+// A malformed label with core_flatpaks requested stops the install in
+// preflight-image, before any disk write, in both dry-run and real runs
+// (ADR-0018).
+func TestCoreFlatpaksMalformedLabelFailsPreflight(t *testing.T) {
+ for _, dryRun := range []bool{true, false} {
+ var commands []string
+ r := coreLabelFake(t, map[string]string{flatpak.CoreLabel: `{"version":2,"flatpaks":[]}`}, &commands)
+ _, events, err := runCore(t, coreRecipe(t, true), r, dryRun)
+ if !errors.Is(err, flatpak.ErrMalformedCoreLabel) {
+ t.Fatalf("dryRun=%v: error = %v, want a malformed-label error", dryRun, err)
+ }
+ terminal, ok := events[len(events)-1].(progress.Error)
+ if !ok || terminal.Step != "preflight-image" || terminal.Code != progress.CodeCoreLabelInvalid {
+ t.Fatalf("dryRun=%v: terminal event = %#v", dryRun, events[len(events)-1])
+ }
+ for _, command := range commands {
+ for _, destructive := range []string{"sfdisk", "wipefs", "mkfs", "cryptsetup", "bootc", "podman"} {
+ if strings.HasPrefix(command, destructive+" ") {
+ t.Fatalf("dryRun=%v: %s ran after a malformed label", dryRun, command)
+ }
+ }
+ }
+ }
+}
+
+// Without core_flatpaks the label is never parsed, so a malformed one
+// cannot block the install.
+func TestCoreFlatpaksLabelIgnoredWhenNotRequested(t *testing.T) {
+ var commands []string
+ r := coreLabelFake(t, map[string]string{flatpak.CoreLabel: "not json"}, &commands)
+ env, _, err := runCore(t, coreRecipe(t, false), r, true)
+ if err != nil {
+ t.Fatalf("dry run = %v, want success with core_flatpaks off", err)
+ }
+ if env.CoreFlatpaks != nil {
+ t.Fatalf("CoreFlatpaks = %v, want none", env.CoreFlatpaks)
+ }
+}
+
+// An image that publishes no core set is reported in preflight and the
+// install continues.
+func TestCoreFlatpaksAbsentLabelWarns(t *testing.T) {
+ for name, labels := range map[string]map[string]string{
+ "no label": nil,
+ "empty array": {flatpak.CoreLabel: `{"version":1,"flatpaks":[]}`},
+ } {
+ var commands []string
+ env, events, err := runCore(t, coreRecipe(t, true), coreLabelFake(t, labels, &commands), true)
+ if err != nil {
+ t.Fatalf("%s: dry run = %v, want success", name, err)
+ }
+ var warned bool
+ for _, e := range events {
+ if w, ok := e.(progress.Warning); ok && w.Code == progress.CodeNoCoreSet {
+ warned = true
+ }
+ }
+ if !warned {
+ t.Fatalf("%s: no %s warning in %v", name, progress.CodeNoCoreSet, events)
+ }
+ if len(env.Summary) != 1 || env.Summary[0].Code != progress.CodeNoCoreSet {
+ t.Fatalf("%s: summary = %v", name, env.Summary)
+ }
+ if env.CoreFlatpaks != nil {
+ t.Fatalf("%s: CoreFlatpaks = %v, want none", name, env.CoreFlatpaks)
+ }
+ }
+}
+
+func TestCoreFlatpaksValidLabelIsKeptForTheFlatpakStep(t *testing.T) {
+ var commands []string
+ label := `{"version":1,"flatpaks":[{"id":"org.kde.okular","name":"Okular"},{"id":"org.kde.kcalc","name":"KCalc"}]}`
+ env, _, err := runCore(t, coreRecipe(t, true), coreLabelFake(t, map[string]string{flatpak.CoreLabel: label}, &commands), true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"org.kde.okular", "org.kde.kcalc"}; !slices.Equal(env.CoreFlatpaks, want) {
+ t.Fatalf("CoreFlatpaks = %v, want %v", env.CoreFlatpaks, want)
+ }
+ if len(env.Summary) != 0 {
+ t.Fatalf("summary = %v, want none", env.Summary)
+ }
+}
+
+// runFlatpaks installs explicit apps first, then the core set, each ID
+// once at its first occurrence.
+func TestRunFlatpaksMergesExplicitThenCore(t *testing.T) {
+ target := t.TempDir()
+ var installed []string
+ r := runner.NewFake(
+ func(_ context.Context, name string, args ...string) ([]byte, error) {
+ switch name {
+ case "ls":
+ return nil, errors.New("no such file") // plain var/lib/flatpak target
+ case "du":
+ return []byte("0\t/x\n"), nil
+ case "env":
+ if i := slices.Index(args, "install"); i >= 0 {
+ installed = append(installed, args[len(args)-1])
+ }
+ }
+ return []byte(""), nil
+ },
+ func(name string) (string, error) { return "/usr/bin/" + name, nil },
+ )
+ l := load(t, strings.Replace(bootcBase, "%s", "none", 1)+
+ `flatpaks = ["org.mozilla.firefox", "org.kde.okular", "org.mozilla.firefox"]`+"\n")
+ env := &pipeline.Env{
+ Recipe: &l.Recipe, Runner: r, TargetDir: target,
+ CoreFlatpaks: []string{"org.kde.okular", "org.kde.kcalc"},
+ Emitter: progress.EmitterFunc(func(progress.Event) error { return nil }),
+ }
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := runFlatpaks(context.Background(), env); err != nil {
+ t.Fatal(err)
+ }
+ if want := []string{"org.mozilla.firefox", "org.kde.okular", "org.kde.kcalc"}; !slices.Equal(installed, want) {
+ t.Fatalf("installed = %v, want %v", installed, want)
+ }
+}
+
+// A signed digest can verify while its registry inspection failed. Its
+// labels are then unknown, which must not pass as "no core set".
+func TestCoreFlatpaksUninspectableImageFailsPreflight(t *testing.T) {
+ cosignKey := filepath.Join(t.TempDir(), "cosign.pub")
+ if err := os.WriteFile(cosignKey, []byte("public key"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ src := strings.Replace(strings.Replace(bootcBase, "%s", "none", 1),
+ `ref = "ghcr.io/frostyard/snow:latest"`,
+ `ref = "ghcr.io/frostyard/snow@`+verifiedBootcDigest+`"`+"\ncosign_pub_key = \""+cosignKey+`"`, 1)
+ offline := func([]string) ([]byte, error) { return nil, errors.New("registry 503") }
+ for _, core := range []bool{true, false} {
+ var commands []string
+ body := src
+ if core {
+ body += "core_flatpaks = true\n"
+ }
+ _, events, err := runCore(t, load(t, body), coreLabelFakeWith(t, nil, &commands, offline), true)
+ if !core {
+ if err != nil {
+ t.Fatalf("core_flatpaks off: dry run = %v, want success (labels not needed)", err)
+ }
+ continue
+ }
+ if err == nil || !strings.Contains(err.Error(), "could not inspect image") {
+ t.Fatalf("core_flatpaks on: error = %v, want an inspection failure", err)
+ }
+ terminal, ok := events[len(events)-1].(progress.Error)
+ if !ok || terminal.Step != "preflight-image" || terminal.Code != progress.CodeCoreLabelUnreadable {
+ t.Fatalf("terminal event = %#v, want a preflight-image %s error",
+ events[len(events)-1], progress.CodeCoreLabelUnreadable)
+ }
+ for _, e := range events {
+ if w, ok := e.(progress.Warning); ok && w.Code == progress.CodeNoCoreSet {
+ t.Fatal("unknown labels were reported as no_core_set")
+ }
+ }
+ }
+}
diff --git a/internal/steps/preflight.go b/internal/steps/preflight.go
index a50bd8c..c086895 100644
--- a/internal/steps/preflight.go
+++ b/internal/steps/preflight.go
@@ -7,6 +7,7 @@ import (
"github.com/frostyard/firn/internal/bootcimg"
"github.com/frostyard/firn/internal/disk"
+ "github.com/frostyard/firn/internal/flatpak"
"github.com/frostyard/firn/internal/pipeline"
"github.com/frostyard/firn/internal/platform"
"github.com/frostyard/firn/internal/progress"
@@ -54,7 +55,7 @@ func preflightSteps(p *pipeline.Pipeline, r *recipe.Recipe) []pipeline.Step {
steps = append(steps, pipeline.Step{
Name: "preflight-image", Weight: 1, Preflight: true,
Run: func(ctx context.Context, env *pipeline.Env) error {
- pinned, err := bootcimg.CheckAndPinImage(ctx, env.Runner, env.Recipe.Image.Ref, env.Recipe.Image.CosignPubKey,
+ source, err := bootcimg.CheckAndPinImage(ctx, env.Runner, env.Recipe.Image.Ref, env.Recipe.Image.CosignPubKey,
func(msg string) {
_ = env.Emit(progress.Warning{Code: progress.CodeImageVerifyRetried, Message: msg})
})
@@ -64,12 +65,15 @@ func preflightSteps(p *pipeline.Pipeline, r *recipe.Recipe) []pipeline.Step {
}
return err
}
- env.BootcSourceRef = pinned
+ env.BootcSourceRef = source.Ref
if env.Recipe.Image.CosignPubKey != "" {
if err := env.Emit(progress.Info{Message: "bootc image signature verified at immutable digest"}); err != nil {
return err
}
}
+ if env.Recipe.System.CoreFlatpaks {
+ return readCoreFlatpaks(env, source)
+ }
return nil
},
})
@@ -105,3 +109,32 @@ func diskPaths(devices []disk.Device) string {
}
return strings.Join(paths, ", ")
}
+
+// readCoreFlatpaks parses the selected image's core Flatpak label before any
+// disk write (ADR-0018): a malformed label fails the install here, and an
+// image that publishes no set is reported, not fatal.
+func readCoreFlatpaks(env *pipeline.Env, source bootcimg.Source) error {
+ if !source.Inspected {
+ // Unknown labels are not an absent label: installing without the
+ // requested set, or skipping its validation, would be silent.
+ return pipeline.WithErrorCode(progress.CodeCoreLabelUnreadable,
+ fmt.Errorf("core_flatpaks: could not inspect image %s to read its %s label; check registry access and retry",
+ env.Recipe.Image.Ref, flatpak.CoreLabel))
+ }
+ value, present := source.Labels[flatpak.CoreLabel]
+ ids, err := flatpak.ParseCoreLabel(value, present)
+ if err != nil {
+ return pipeline.WithErrorCode(progress.CodeCoreLabelInvalid,
+ fmt.Errorf("core_flatpaks: image %s: %w", env.Recipe.Image.Ref, err))
+ }
+ if len(ids) == 0 {
+ msg := fmt.Sprintf("core_flatpaks: image %s publishes no core Flatpak set", env.Recipe.Image.Ref)
+ if err := env.Emit(progress.Warning{Code: progress.CodeNoCoreSet, Message: msg}); err != nil {
+ return err
+ }
+ env.AddSummary(progress.CodeNoCoreSet, msg)
+ return nil
+ }
+ env.CoreFlatpaks = ids
+ return env.Emit(progress.Info{Message: fmt.Sprintf("core_flatpaks: image publishes %d core Flatpaks", len(ids))})
+}
diff --git a/internal/tui/core_preview.go b/internal/tui/core_preview.go
new file mode 100644
index 0000000..0c61961
--- /dev/null
+++ b/internal/tui/core_preview.go
@@ -0,0 +1,210 @@
+package tui
+
+import (
+ "context"
+ "errors"
+ "os"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/frostyard/firn/internal/bootcimg"
+ "github.com/frostyard/firn/internal/flatpak"
+)
+
+// corePreviewTimeout bounds the flatpaks page's image inspection, so an
+// unreachable registry delays the page instead of hanging it.
+const corePreviewTimeout = 30 * time.Second
+
+// corePreviewState is what the flatpaks page knows about the chosen image's
+// core Flatpak set (ADR-0018). The zero value, unavailable, keeps the
+// toggle: install-time preflight reads the label again either way.
+type corePreviewState int
+
+const (
+ corePreviewUnavailable corePreviewState = iota // inspect failed or not attempted
+ corePreviewNone // image publishes no core set
+ corePreviewInvalid // label present but malformed
+ corePreviewAvailable // valid, non-empty set
+)
+
+// corePreview describes the core set of the image whose ref it records. It
+// is display-only: the recipe carries just core_flatpaks, and preflight
+// reads the label from the image it selects at install time.
+type corePreview struct {
+ ref string
+ state corePreviewState
+ apps []flatpak.CoreApp
+ detail string
+}
+
+// refreshCorePreview inspects the chosen image when the preview describes a
+// different one (first visit, a new image, or Start over), using the same
+// local-first selection preflight uses, without signature verification.
+// quit reports that the user aborted while it ran.
+func (w *wizard) refreshCorePreview(ctx context.Context) (quit bool, err error) {
+ ref := w.c.entry.Ref
+ if w.preview.ref == ref && ref != "" {
+ return false, nil
+ }
+ inspect := func(ctx context.Context) corePreview { return inspectCorePreview(ctx, w, ref) }
+ if w.showProgress == nil {
+ w.preview = inspect(ctx)
+ return false, nil
+ }
+ preview, quit, err := w.showProgress(ctx, "Reading "+w.c.entry.Name+"'s core app list…", inspect)
+ if quit || err != nil {
+ return quit, err
+ }
+ w.preview = preview
+ return false, nil
+}
+
+// progressFunc runs inspect while showing title, returning its result.
+// quit reports a user abort.
+type progressFunc func(ctx context.Context, title string, inspect func(context.Context) corePreview) (preview corePreview, quit bool, err error)
+
+// showInspecting draws a spinner while inspect runs, so a slow registry
+// reads as progress rather than a hung installer, and keystrokes typed
+// meanwhile are consumed here instead of reaching the flatpaks form. Like
+// page, it renders to stdout (the kiosk routes stderr to the journal).
+func showInspecting(ctx context.Context, title string, inspect func(context.Context) corePreview) (corePreview, bool, error) {
+ ictx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ m := &inspectingModel{
+ spin: spinner.New(spinner.WithSpinner(spinner.Line)),
+ title: title,
+ inspect: func() corePreview { return inspect(ictx) },
+ }
+ _, err := tea.NewProgram(m,
+ tea.WithContext(ctx),
+ tea.WithInput(os.Stdin),
+ tea.WithOutput(os.Stdout),
+ ).Run()
+ if ctx.Err() != nil {
+ return corePreview{}, false, ctx.Err()
+ }
+ if errors.Is(err, tea.ErrInterrupted) {
+ return corePreview{}, true, nil
+ }
+ if err != nil {
+ return corePreview{}, false, err
+ }
+ return m.result, false, nil
+}
+
+// inspectingModel is the spinner shown while the preview inspection runs.
+type inspectingModel struct {
+ spin spinner.Model
+ title string
+ inspect func() corePreview
+ done bool
+ result corePreview
+}
+
+type previewDoneMsg struct{ preview corePreview }
+
+func (m *inspectingModel) Init() tea.Cmd {
+ return tea.Batch(m.spin.Tick, func() tea.Msg { return previewDoneMsg{m.inspect()} })
+}
+
+func (m *inspectingModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case previewDoneMsg:
+ m.result, m.done = msg.preview, true
+ return m, tea.Quit
+ case tea.KeyMsg:
+ if msg.Type == tea.KeyCtrlC || msg.Type == tea.KeyEsc {
+ return m, tea.Interrupt
+ }
+ return m, nil // swallow keys typed while waiting
+ }
+ var cmd tea.Cmd
+ m.spin, cmd = m.spin.Update(msg)
+ return m, cmd
+}
+
+func (m *inspectingModel) View() string {
+ if m.done {
+ return ""
+ }
+ return m.spin.View() + " " + m.title + "\n"
+}
+
+func inspectCorePreview(ctx context.Context, w *wizard, ref string) corePreview {
+ p := corePreview{ref: ref}
+ if w.opts.Runner == nil || ref == "" {
+ p.detail = "no image inspector"
+ return p
+ }
+ ictx, cancel := context.WithTimeout(ctx, corePreviewTimeout)
+ defer cancel()
+ source, err := bootcimg.CheckAndPinImage(ictx, w.opts.Runner, ref, "", nil)
+ if err == nil && !source.Inspected {
+ err = errors.New("image inspection returned no readable metadata")
+ }
+ if err != nil {
+ // Keep the next visit retrying rather than caching a transient
+ // failure such as a slow or offline registry.
+ p.ref = ""
+ p.detail = firstLine(err.Error())
+ return p
+ }
+ value, present := source.Labels[flatpak.CoreLabel]
+ apps, err := flatpak.ParseCoreLabelApps(value, present)
+ switch {
+ case errors.Is(err, flatpak.ErrMalformedCoreLabel):
+ p.state = corePreviewInvalid
+ p.detail = firstLine(err.Error())
+ case err != nil:
+ p.ref = ""
+ p.detail = firstLine(err.Error())
+ case len(apps) == 0:
+ p.state = corePreviewNone
+ default:
+ p.state = corePreviewAvailable
+ p.apps = apps
+ }
+ return p
+}
+
+// firstLine returns s's first line with non-printable runes replaced, so
+// registry or skopeo text cannot put escape sequences on the console.
+func firstLine(s string) string {
+ line, _, _ := strings.Cut(s, "\n")
+ return strings.Map(func(r rune) rune {
+ if unicode.IsPrint(r) {
+ return r
+ }
+ return '?'
+ }, line)
+}
+
+// wrapNames joins app names into lines no wider than width display cells.
+func wrapNames(apps []flatpak.CoreApp, width int) string {
+ var lines []string
+ var line string
+ for i, app := range apps {
+ item := app.Name
+ if i < len(apps)-1 {
+ item += ","
+ }
+ switch {
+ case line == "":
+ line = item
+ case lipgloss.Width(line)+1+lipgloss.Width(item) > width:
+ lines = append(lines, line)
+ line = item
+ default:
+ line += " " + item
+ }
+ }
+ if line != "" {
+ lines = append(lines, line)
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/internal/tui/core_preview_test.go b/internal/tui/core_preview_test.go
new file mode 100644
index 0000000..dfee548
--- /dev/null
+++ b/internal/tui/core_preview_test.go
@@ -0,0 +1,283 @@
+package tui
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/huh"
+
+ "github.com/frostyard/firn/internal/flatpak"
+ "github.com/frostyard/firn/internal/runner"
+)
+
+const previewDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
+
+// previewRunner serves skopeo inspections whose core label depends on the
+// image ref; refs missing from labels fail as unreachable. inspected counts
+// skopeo invocations.
+func previewRunner(labels map[string]string, inspected *int) *runner.Runner {
+ return runner.NewFake(
+ func(_ context.Context, name string, args ...string) ([]byte, error) {
+ if name != "skopeo" {
+ return nil, errors.New("unexpected " + name)
+ }
+ *inspected++
+ ref := args[1][strings.Index(args[1], ":")+1:]
+ ref = strings.TrimPrefix(ref, "//")
+ value, ok := labels[ref]
+ if !ok {
+ return nil, errors.New("network unreachable")
+ }
+ body := `{"Digest":"` + previewDigest + `","Labels":{"containers.bootc":"1"`
+ if value != "ABSENT" {
+ body += `,"` + flatpak.CoreLabel + `":"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
+ }
+ return []byte(body + `}}`), nil
+ },
+ func(name string) (string, error) { return "/usr/bin/" + name, nil },
+ )
+}
+
+const (
+ snowRef = "ghcr.io/frostyard/snow:latest"
+ sundogRef = "ghcr.io/frostyard/sundog:latest"
+ floeRef = "ghcr.io/frostyard/floe:latest"
+ brokenRef = "ghcr.io/frostyard/broken:latest"
+ emptyRef = "ghcr.io/frostyard/empty:latest"
+)
+
+var previewLabels = map[string]string{
+ snowRef: `{"version":1,"flatpaks":[{"id":"org.gnome.Loupe","name":"Image Viewer"},{"id":"org.mozilla.firefox","name":"Firefox"}]}`,
+ sundogRef: `{"version":1,"flatpaks":[{"id":"org.kde.okular","name":"Okular"}]}`,
+ floeRef: "ABSENT",
+ brokenRef: `{"version":2,"flatpaks":[]}`,
+ emptyRef: `{"version":1,"flatpaks":[]}`,
+}
+
+func previewWizard(inspected *int) *wizard {
+ return &wizard{opts: WizardOpts{Runner: previewRunner(previewLabels, inspected)}}
+}
+
+func TestCorePreviewStates(t *testing.T) {
+ for _, tc := range []struct {
+ ref string
+ state corePreviewState
+ names []string
+ cached bool
+ }{
+ {ref: snowRef, state: corePreviewAvailable, names: []string{"Image Viewer", "Firefox"}, cached: true},
+ {ref: floeRef, state: corePreviewNone, cached: true},
+ {ref: emptyRef, state: corePreviewNone, cached: true},
+ {ref: brokenRef, state: corePreviewInvalid, cached: true},
+ {ref: "ghcr.io/frostyard/offline:latest", state: corePreviewUnavailable, cached: false},
+ } {
+ var inspected int
+ w := previewWizard(&inspected)
+ w.c.entry = CatalogEntry{Name: "x", Ref: tc.ref}
+ mustRefresh(t, w)
+ if w.preview.state != tc.state {
+ t.Fatalf("%s: state = %v, want %v (detail %q)", tc.ref, w.preview.state, tc.state, w.preview.detail)
+ }
+ var names []string
+ for _, app := range w.preview.apps {
+ names = append(names, app.Name)
+ }
+ if strings.Join(names, ",") != strings.Join(tc.names, ",") {
+ t.Fatalf("%s: names = %v, want %v", tc.ref, names, tc.names)
+ }
+ before := inspected
+ mustRefresh(t, w)
+ if reused := inspected == before; reused != tc.cached {
+ t.Fatalf("%s: second visit reused the preview = %v, want %v", tc.ref, reused, tc.cached)
+ }
+ }
+}
+
+// Choosing another image (going back, or Start over) re-inspects, and a
+// hidden toggle never leaves core_flatpaks set for the new image.
+func TestCorePreviewFollowsImageChangesAndClearsHiddenToggle(t *testing.T) {
+ var inspected int
+ w := previewWizard(&inspected)
+
+ w.c.entry = CatalogEntry{Name: "snow", Ref: snowRef}
+ mustRefresh(t, w)
+ w.flatpaksForm()
+ w.c.coreFlatpaks = true // the user accepts Snow's set
+
+ w.c.entry = CatalogEntry{Name: "floe", Ref: floeRef}
+ mustRefresh(t, w)
+ w.flatpaksForm()
+ if w.preview.state != corePreviewNone || w.c.coreFlatpaks {
+ t.Fatalf("after switching to floe: state = %v, coreFlatpaks = %v, want none and false",
+ w.preview.state, w.c.coreFlatpaks)
+ }
+
+ w.c.entry = CatalogEntry{Name: "broken", Ref: brokenRef}
+ w.c.coreFlatpaks = true
+ mustRefresh(t, w)
+ w.flatpaksForm()
+ if w.preview.state != corePreviewInvalid || w.c.coreFlatpaks {
+ t.Fatalf("after switching to a malformed label: state = %v, coreFlatpaks = %v",
+ w.preview.state, w.c.coreFlatpaks)
+ }
+
+ w.c.entry = CatalogEntry{Name: "sundog", Ref: sundogRef}
+ mustRefresh(t, w)
+ w.c.coreFlatpaks = true
+ w.flatpaksForm()
+ if w.preview.state != corePreviewAvailable || !w.c.coreFlatpaks {
+ t.Fatalf("an offered toggle must keep its answer: state = %v, coreFlatpaks = %v",
+ w.preview.state, w.c.coreFlatpaks)
+ }
+}
+
+func TestFlatpaksFormRendersEachPreviewState(t *testing.T) {
+ for _, tc := range []struct {
+ ref string
+ want []string
+ notWant []string
+ }{
+ {ref: snowRef, want: []string{"core app set?", "Image Viewer, Firefox"}},
+ {ref: floeRef, want: []string{"No core app set", "publishes no core Flatpak apps"}, notWant: []string{"core app set?"}},
+ {ref: brokenRef, want: []string{"Core app set unavailable", "malformed"}, notWant: []string{"core app set?"}},
+ {ref: "ghcr.io/frostyard/offline:latest", want: []string{"core app set?", "read", "install time"}},
+ } {
+ var inspected int
+ w := previewWizard(&inspected)
+ w.c.entry = CatalogEntry{Name: "x", Ref: tc.ref}
+ mustRefresh(t, w)
+ m := newPageModel(w, w.flatpaksForm())
+ pumpKeys(t, m, tea.WindowSizeMsg{Width: 100, Height: 40})
+ view := m.View()
+ for _, s := range tc.want {
+ if !strings.Contains(view, s) {
+ t.Errorf("%s: view lacks %q:\n%s", tc.ref, s, view)
+ }
+ }
+ for _, s := range tc.notWant {
+ if strings.Contains(view, s) {
+ t.Errorf("%s: view shows %q:\n%s", tc.ref, s, view)
+ }
+ }
+ }
+}
+
+// With no core set the page still completes: Enter on the explicit-apps
+// field submits it.
+func TestFlatpaksFormWithoutCoreSetCompletes(t *testing.T) {
+ var inspected int
+ w := previewWizard(&inspected)
+ w.c.entry = CatalogEntry{Name: "floe", Ref: floeRef}
+ mustRefresh(t, w)
+ m := newPageModel(w, w.flatpaksForm())
+ pumpKeys(t, m, keyEnter, keyEnter)
+ if m.form.State != huh.StateCompleted {
+ t.Fatalf("form state = %v, want completed; view:\n%s", m.form.State, m.View())
+ }
+}
+
+// An inspection that succeeds without readable metadata is unknown, not
+// "no core set": the toggle stays and the next visit retries.
+func TestCorePreviewUnreadableInspectionKeepsToggle(t *testing.T) {
+ r := runner.NewFake(
+ func(_ context.Context, name string, args ...string) ([]byte, error) {
+ if strings.HasPrefix(args[1], "docker://") {
+ return []byte("not json"), nil
+ }
+ return nil, errors.New("not cached")
+ },
+ func(name string) (string, error) { return "/usr/bin/" + name, nil },
+ )
+ w := &wizard{opts: WizardOpts{Runner: r}}
+ w.c.entry = CatalogEntry{Name: "snow", Ref: snowRef}
+ w.c.coreFlatpaks = true
+ mustRefresh(t, w)
+ w.flatpaksForm()
+ if w.preview.state != corePreviewUnavailable || w.preview.ref != "" || !w.c.coreFlatpaks {
+ t.Fatalf("state = %v, ref = %q, coreFlatpaks = %v; want unavailable, retry, answer kept",
+ w.preview.state, w.preview.ref, w.c.coreFlatpaks)
+ }
+}
+
+func TestWrapNames(t *testing.T) {
+ apps := []flatpak.CoreApp{{Name: "Alpha"}, {Name: "Beta"}, {Name: "Gamma"}}
+ if got, want := wrapNames(apps, 12), "Alpha, Beta,\nGamma"; got != want {
+ t.Fatalf("wrapNames = %q, want %q", got, want)
+ }
+ // Width counts display cells, not bytes: "Éditeur" is 7 cells, 8 bytes.
+ accented := []flatpak.CoreApp{{Name: "Éditeur"}, {Name: "Beta"}}
+ if got, want := wrapNames(accented, 13), "Éditeur, Beta"; got != want {
+ t.Fatalf("wrapNames(non-ASCII) = %q, want %q", got, want)
+ }
+}
+
+func TestFirstLineStripsControlCharacters(t *testing.T) {
+ if got, want := firstLine("bad \x1b[2Jregistry\tsays\nsecond line"), "bad ?[2Jregistry?says"; got != want {
+ t.Fatalf("firstLine = %q, want %q", got, want)
+ }
+}
+
+func mustRefresh(t *testing.T, w *wizard) {
+ t.Helper()
+ if quit, err := w.refreshCorePreview(context.Background()); quit || err != nil {
+ t.Fatalf("refreshCorePreview = quit %v, err %v", quit, err)
+ }
+}
+
+// The wizard shows progress through showProgress, and an abort there quits
+// without touching the previous preview.
+func TestRefreshCorePreviewUsesProgressAndPropagatesQuit(t *testing.T) {
+ var inspected int
+ w := previewWizard(&inspected)
+ w.c.entry = CatalogEntry{Name: "snow", Ref: snowRef}
+ var titles []string
+ w.showProgress = func(ctx context.Context, title string, inspect func(context.Context) corePreview) (corePreview, bool, error) {
+ titles = append(titles, title)
+ return inspect(ctx), false, nil
+ }
+ mustRefresh(t, w)
+ if len(titles) != 1 || !strings.Contains(titles[0], "snow") || w.preview.state != corePreviewAvailable {
+ t.Fatalf("titles = %v, state = %v", titles, w.preview.state)
+ }
+
+ w.c.entry = CatalogEntry{Name: "floe", Ref: floeRef}
+ w.showProgress = func(context.Context, string, func(context.Context) corePreview) (corePreview, bool, error) {
+ return corePreview{}, true, nil
+ }
+ quit, err := w.refreshCorePreview(context.Background())
+ if !quit || err != nil {
+ t.Fatalf("aborted refresh = quit %v, err %v; want quit", quit, err)
+ }
+ if w.preview.ref != snowRef {
+ t.Fatalf("an aborted refresh replaced the preview: %+v", w.preview)
+ }
+}
+
+func TestInspectingModel(t *testing.T) {
+ m := &inspectingModel{spin: spinner.New(), title: "Reading snow's core app list…",
+ inspect: func() corePreview { return corePreview{ref: snowRef, state: corePreviewNone} }}
+ if !strings.Contains(m.View(), "Reading snow's core app list") {
+ t.Fatalf("view = %q", m.View())
+ }
+ // Keys typed while waiting are swallowed, not queued for the next page.
+ if _, cmd := m.Update(keyEnter); cmd != nil {
+ t.Fatalf("Enter while inspecting returned a command")
+ }
+ if _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}); cmd == nil {
+ t.Fatal("Ctrl-C while inspecting did not interrupt")
+ } else if _, ok := cmd().(tea.InterruptMsg); !ok {
+ t.Fatalf("Ctrl-C produced %T, want tea.InterruptMsg", cmd())
+ }
+ msg := previewDoneMsg{m.inspect()}
+ _, cmd := m.Update(msg)
+ if !m.done || m.result.state != corePreviewNone || m.View() != "" {
+ t.Fatalf("after completion: done=%v result=%+v view=%q", m.done, m.result, m.View())
+ }
+ if _, ok := cmd().(tea.QuitMsg); !ok {
+ t.Fatalf("completion produced %T, want tea.QuitMsg", cmd())
+ }
+}
diff --git a/internal/tui/wizard.go b/internal/tui/wizard.go
index fdae091..fc26f72 100644
--- a/internal/tui/wizard.go
+++ b/internal/tui/wizard.go
@@ -65,6 +65,8 @@ func RunWizard(ctx context.Context, o WizardOpts) ([]byte, error) {
catalog: catalog,
theme: huh.ThemeBase16(), // ANSI 16-color: safe on consoles and serial terminals
secretsDir: o.SessionDir,
+
+ showProgress: showInspecting,
}
return w.run(ctx)
}
@@ -76,6 +78,12 @@ type wizard struct {
theme *huh.Theme
secretsDir string
c wizardChoices
+ // preview is the chosen image's core Flatpak set as last inspected by
+ // the flatpaks page; it is display-only and never enters the recipe.
+ preview corePreview
+ // showProgress draws progress while the preview inspection runs; nil
+ // (as in unit tests) runs the inspection inline.
+ showProgress progressFunc
// backward is true while the user is navigating backward (Shift-Tab);
// validators wrapped by skipWhenBacking then pass. It is set and cleared
// by wizardPageModel.
@@ -185,7 +193,9 @@ func (w *wizard) run(ctx context.Context) ([]byte, error) {
case pageUser:
quit, err = w.page(ctx, w.userForm())
case pageFlatpaks:
- quit, err = w.page(ctx, w.flatpaksForm())
+ if quit, err = w.refreshCorePreview(ctx); !quit && err == nil {
+ quit, err = w.page(ctx, w.flatpaksForm())
+ }
case pageReview:
var startOver bool
var reviewed []byte
diff --git a/internal/tui/wizard_pages.go b/internal/tui/wizard_pages.go
index 9727d30..3c82a69 100644
--- a/internal/tui/wizard_pages.go
+++ b/internal/tui/wizard_pages.go
@@ -459,12 +459,41 @@ func (w *wizard) userForm() *huh.Form {
)
}
+// flatpaksForm offers the chosen image's core app set according to
+// w.preview (refreshed by the run loop before this page), plus extra apps.
+// An image with no set, or a malformed one, gets an explanation instead of
+// the toggle, and the toggle's value is cleared so the recipe never asks
+// for a set the wizard did not offer.
func (w *wizard) flatpaksForm() *huh.Form {
- return huh.NewForm(huh.NewGroup(
- huh.NewConfirm().
+ var core huh.Field
+ switch w.preview.state {
+ case corePreviewNone:
+ w.c.coreFlatpaks = false
+ core = huh.NewNote().
+ Title("No core app set").
+ Description("This image publishes no core Flatpak apps.")
+ case corePreviewInvalid:
+ w.c.coreFlatpaks = false
+ core = huh.NewNote().
+ Title("Core app set unavailable").
+ Description(escapeMarkdown("This image's core app list is malformed, so it cannot be installed.\n" + w.preview.detail))
+ case corePreviewAvailable:
+ core = huh.NewConfirm().
+ Title("Install this image's core app set?").
+ Description("Initially No. This image publishes these Flatpak apps:\n" + wrapNames(w.preview.apps, 64)).
+ Value(&w.c.coreFlatpaks)
+ default:
+ desc := "Initially No. This image's list could not be read now; it is read\nagain at install time."
+ if w.preview.detail != "" {
+ desc += "\n(" + w.preview.detail + ")"
+ }
+ core = huh.NewConfirm().
Title("Install this image's core app set?").
- Description("Initially No. The curated Flatpak set published for this image,\ninstalled offline from the installer medium where available.").
- Value(&w.c.coreFlatpaks),
+ Description(desc).
+ Value(&w.c.coreFlatpaks)
+ }
+ return huh.NewForm(huh.NewGroup(
+ core,
huh.NewText().
Title("Extra Flatpak apps (optional)").
Description("Application IDs, e.g. org.mozilla.firefox — comma, space, or\nnewline separated.").
@@ -473,6 +502,12 @@ func (w *wizard) flatpaksForm() *huh.Form {
))
}
+// escapeMarkdown keeps note text literal: huh renders note descriptions as
+// markdown, so paired underscores (core_flatpaks) would vanish.
+func escapeMarkdown(s string) string {
+ return strings.NewReplacer("_", "\\_", "*", "\\*").Replace(s)
+}
+
func (w *wizard) reviewForm(recipeTOML string, issues []recipe.Issue, action *string) *huh.Form {
desc := recipeTOML
if txt := renderIssues(issues); txt != "" {
@@ -483,7 +518,7 @@ func (w *wizard) reviewForm(recipeTOML string, issues []recipe.Issue, action *st
// the characters VANISH from the "review this exact recipe" screen
// (observed live in the TUI E2E). Escape markdown emphasis so the
// TOML shows byte-exact.
- desc = strings.NewReplacer("_", "\\_", "*", "\\*").Replace(desc)
+ desc = escapeMarkdown(desc)
return huh.NewForm(huh.NewGroup(
huh.NewNote().
Title("Review — this exact recipe will be installed").
diff --git a/test/e2e-tui.sh b/test/e2e-tui.sh
index 18bcab8..b195dfd 100755
--- a/test/e2e-tui.sh
+++ b/test/e2e-tui.sh
@@ -265,9 +265,13 @@ type_input 'Confirm password' 'firn-e2e-pw'
accept_field '^.*Groups' # sudo preselected
accept_field 'Additional groups'
accept_field 'User SSH authorized key' # empty huh.NewText
-# Flatpaks: core-set confirm + IDs. The schema and TUI both default this
-# optional feature to false, so Enter preserves the focused No answer.
-accept_field 'core app set' # No (floe has no runtime)
+# Flatpaks: the page inspects the chosen image's org.frostyard.core-flatpaks
+# label (ADR-0018) before rendering, bounded by a 30s timeout. floe publishes
+# no core set, so the wizard explains that instead of offering the toggle,
+# and the explicit-apps field is the only input.
+expect_screen 'publishes no core Flatpak apps' 90
+cap | grep -Eq "Install this image's core app set" &&
+ fail "core app toggle offered for floe, which publishes no core set"
accept_field 'Extra Flatpak apps' # empty huh.NewText
# Review: with a long recipe the page TITLE scrolls off the 24-row pane;
# gate on the action list, which is always visible at the bottom.