Skip to main content

max / alloy

Split the image build into client and server profiles, add the builder One Containerfile with ARG PROFILE, so the two images cannot drift apart the way two files would. Client's package set is byte-identical to what it was; server drops 26 packages and the graphical stack. Every $PROFILE conditional asserts on both branches, client proving presence where server proves absence, so no path through the file verifies nothing. tests/profile_split.rs enforces that over the Containerfile itself and was checked against a negative control. Both base images are pinned by multi-arch index digest, with build/refresh-base-digests.sh to move them and --check to report staleness. ARG BROWSER and ARG LANGS are validated in the Containerfile rather than in the scripts, so the gate still holds when the TUI is bypassed. The image records what it was built from at /usr/lib/alloy/build.toml, written from the ARGs rather than copied from the checkout, and tests/build_record.rs runs the real RUN block against the Rust parser to keep the two honest. alloy image drives it: a form, not a wizard, exposing only the choices Alloy declines to make. It never writes a disk. It picks the device and suspends into the build script, which asks its own confirmation and applies its own guards. That write path now lives in build/write-device.sh, called by both build-image.sh and build-iso.sh instead of existing twice. build-iso.sh could not write to a device at all before this; it takes --write and --write-only now, --write-only being the spelling both scripts answer to. The guards are unchanged: whole-disk only, refuses anything mounted, warns on a non-removable target, demands the device path typed back, and verifies with cmp against a negative control. Server images hide display and status from --help and refuse them, off one table so the hiding and the refusal cannot drift. That reads a build-time marker rather than probing for a compositor, which would answer wrongly on a client at the greeter or over SSH. Not yet handed to podman end to end: the build stops at the shop stage, because the pinned SHOP_REV is unreachable until shop can be pushed (GO mnw-cli 77fcaac9).
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-01 22:13 UTC
Signed with PGP, not checked
Commit: 37ca1e3a311a65350b40c4436622133108fd7ff2
Parent: e804c6a
13 files changed, +1718 insertions, -288 deletions
M Containerfile +212 -210
@@ -27,7 +27,22 @@
27 27 # Cargo.toml pins edition 2024 and rust-version 1.86; Fedora 43 clears
28 28 # both. `--locked` so the image builds the dependency graph the repo
29 29 # committed rather than whatever resolves that day.
30 - FROM quay.io/fedora/fedora:43 AS rust-build
30 + #
31 + # Pinned by digest, and the tag is kept alongside it for readability only —
32 + # the digest is what resolves. `:43` is a floating tag: it moves on every
33 + # Fedora respin, so two builds of the same alloy commit a week apart used to
34 + # build against different toolchains and different glibc. That is exactly the
35 + # drift the fonts below are pinned to avoid, and the font comment's argument
36 + # ("a cache hit stops meaning the same bytes") was never carried to `FROM`
37 + # until now. See wiki `alloy-distribution`, "Rebuilding a past image".
38 + #
39 + # This is the multi-arch index digest, not a per-arch manifest, so it still
40 + # resolves natively on both build hosts (fw13 amd64, astra arm64) — CLAUDE.md
41 + # forbids cross-compiling, so a per-arch pin would break one of them.
42 + #
43 + # To move it: build/refresh-base-digests.sh, which resolves the tags and
44 + # rewrites these two lines. Do that deliberately, not as drive-by maintenance.
45 + FROM quay.io/fedora/fedora:43@sha256:44ce208e9a7ea0da1027caecd4e846664ae5e2a2d73d1e93a969f529b9015c15 AS rust-build
31 46
32 47 # No python3 any more. It was here for tools/vtrgb.py, which rendered the
33 48 # greeter's console palette; skelgen emits that table now, along with the rest
@@ -273,7 +288,100 @@
273 288 # =====================================================================
274 289 # Runtime image — the bootable container itself.
275 290 # =====================================================================
276 - FROM quay.io/fedora/fedora-bootc:43
291 + # Digest-pinned for the same reason as the build stage above, and it matters
292 + # more here: this one is the operating system. See that comment for why the
293 + # tag is kept beside the digest and why it is the multi-arch index.
294 + FROM quay.io/fedora/fedora-bootc:43@sha256:9f0fcfc553103b98ccc01ca02fbb43bbb38649749e613fc007399681c281ae2f
295 +
296 + # =====================================================================
297 + # The profile: client or server.
298 + # =====================================================================
299 + # `client` is Alloy as docs/STACK.md describes it — compositor, greeter,
300 + # session, browser, the whole desktop. `server` is the same machine with
301 + # every one of those removed: the console, the shell and CLI stack, the
302 + # hardware-health group, sshd, avahi and the themed bare console, and
303 + # nothing that needs a screen.
304 + #
305 + # Ruled by Max 2026-08-01: one Containerfile with a build ARG, over the
306 + # two-leaf-file shape and over having no server variant at all. Brief and
307 + # the alternatives are in wiki `alloy-server-variant`; GO alloy 1372b159
308 + # item 2.
309 + #
310 + # WHY THIS DOES NOT COST THE ASSERTIONS, which was the standing objection
311 + # to doing it this way. This file's correctness mechanism is that it
312 + # asserts, at build time, things a config file merely assumes: that the
313 + # session wrapper parses, that the polkit grant names actions that exist,
314 + # that satty and jq are really there for alloy-shot. Each of those checks
315 + # was written because its absence had already caused a silent failure. The
316 + # objection was that a build ARG turns them into `if` blocks, and a
317 + # conditional assertion is one that can be skipped without a word.
318 + #
319 + # So no conditional here skips a check. **Every `$PROFILE` conditional in
320 + # this file asserts on BOTH branches**: where `client` proves a thing is
321 + # present and correct, `server` proves it is absent. There is no path
322 + # through any of them that verifies nothing, which means a branch taken by
323 + # mistake fails the build instead of quietly doing less. Two things hold
324 + # that up:
325 + #
326 + # 1. The validity assertion immediately below. It is unconditional, and
327 + # it runs before anything reads $PROFILE, so a typo can never fall
328 + # through to a branch and build something nobody asked for.
329 + # 2. tests/profile_split.rs, which reads this file and fails if any
330 + # `$PROFILE` conditional lacks an `else`.
331 + #
332 + # That is a stronger guarantee than the file had before the split, because
333 + # the `server` branches assert absence, which nothing used to check at all.
334 + ARG PROFILE=client
335 +
336 + # =====================================================================
337 + # The other two builder choices: the browser, and language toolchains.
338 + # =====================================================================
339 + # Both come from `alloy image` (crates/alloy/src/image.rs), which is the
340 + # builder TUI wiki `alloy-distribution` calls Alloy's whole distribution
341 + # mechanism. It exposes only the choices Alloy deliberately declines to
342 + # make, which is why these two are ARGs and the theme, terminal, editor
343 + # and shell are not.
344 + #
345 + # BROWSER is the case docs/STACK.md already names a non-endorsement:
346 + # Alloy argues for helix over vim, and cannot make the equivalent
347 + # argument between Blink and Gecko. `none` is a real answer, not an
348 + # error — someone installing their own from Flathub should not pay for
349 + # one they will remove.
350 + ARG BROWSER=helium
351 +
352 + # LANGS is asked for by name in the rust comment further down: 610 MiB
353 + # across 16 packages, "per-image language selection at mint time is the
354 + # way this stops being one number for everyone". This is that. Comma
355 + # separated, and validated against a curated set below rather than
356 + # treated as a package list — the builder is not a package manager, and
357 + # the gate lives here so it holds even when the TUI is bypassed.
358 + ARG LANGS=rust
359 +
360 + # Unconditional, and first. Everything downstream trusts that these are
361 + # words from known sets, so this is the assertion the other assertions
362 + # stand on. `PROFILE=cleint` has to die here rather than silently build a
363 + # server image because no `then` branch matched, and `LANGS=rust,cobol`
364 + # has to die here rather than at a dnf error 400 lines later.
365 + RUN set -eu; \
366 + case "$PROFILE" in \
367 + client|server) ;; \
368 + *) echo "unknown PROFILE '$PROFILE'; expected 'client' or 'server'" >&2; exit 1 ;; \
369 + esac; \
370 + case "$BROWSER" in \
371 + helium|firefox|none) ;; \
372 + *) echo "unknown BROWSER '$BROWSER'; expected 'helium', 'firefox' or 'none'" >&2; exit 1 ;; \
373 + esac; \
374 + for lang in $(echo "$LANGS" | tr ',' ' '); do \
375 + case "$lang" in \
376 + rust|go|python|zig) ;; \
377 + *) echo "unknown language '$lang'; the builder offers rust, go, python and zig" >&2; exit 1 ;; \
378 + esac; \
379 + done; \
380 + case "$PROFILE:$BROWSER" in \
381 + client:*|server:none) ;; \
382 + server:*) echo "PROFILE=server ships no graphical session and cannot carry BROWSER=$BROWSER" >&2; exit 1 ;; \
383 + esac; \
384 + echo "building profile=$PROFILE browser=$BROWSER langs=$LANGS"
277 385
278 386 # =====================================================================
279 387 # Third-party repos
@@ -379,25 +487,15 @@
379 487 # post-install; no Flatpaks are provisioned at build or first-boot
380 488 # time.
381 489 # =====================================================================
490 + # ---------------------------------------------------------------------
491 + # Base packages — installed on every profile.
492 + #
493 + # What is left when the graphical session goes away. Read the split as a
494 + # question about the machine rather than about taste: a package is here
495 + # if a headless box still has a use for it, and in the client block below
496 + # if it needs a compositor, a screen, or a person sitting at one.
497 + # ---------------------------------------------------------------------
382 498 RUN dnf install -y \
383 - # Compositor and Wayland session (Fedora main)
384 - sway \
385 - # Portals: -wlr is the wlroots backend sway needs for screencast.
386 - # -gnome was the wrong backend here (it drives screencast through
387 - # gnome-shell, which Alloy removes below). -gtk stays for the file
388 - # chooser.
389 - xdg-desktop-portal xdg-desktop-portal-gtk xdg-desktop-portal-wlr \
390 - # Notifications, screenshot annotate, wallpaper (bar = sway's built-in swaybar)
391 - mako \
392 - # notify-send, which is the *client* half. mako implements the
393 - # notification server and does not pull this in, so it has been arriving
394 - # as somebody else's transitive dependency. usr/bin/alloy-shot calls it on
395 - # every screenshot, and the failure mode if it goes missing is the exact
396 - # one that script was written to fix: a capture that happens and says
397 - # nothing. Same reasoning as jq below.
398 - libnotify \
399 - satty \
400 - swww \
401 499 # Editor, shell, prompt. The terminal is not here: shop is built from
402 500 # source in the rust-build stage and copied in below.
403 501 helix \
@@ -407,10 +505,6 @@
407 505 direnv \
408 506 # File managers
409 507 yazi \
410 - # Content viewers
411 - mpv \
412 - imv \
413 - zathura zathura-pdf-mupdf \
414 508 # System introspection
415 509 bottom \
416 510 dua-cli \
@@ -423,7 +517,12 @@
423 517 # provably readable before it is trusted as a reference (GoingsOn
424 518 # tailoredmachines 18af3fb3), but a laptop with no ECC at all still
425 519 # wants SMART on the disk it boots from, so these live in the base
426 - # rather than waiting on a server variant.
520 + # rather than in either profile.
521 + #
522 + # This group is the one the old single-block comment already said
523 + # belonged "in the base rather than waiting on a server variant".
524 + # The variant now exists and the group did not move, which is the
525 + # outcome that comment was arguing for.
427 526 #
428 527 # rasdaemon carries ras-mc-ctl, which is the ECC readback. Note
429 528 # edac-utils is NOT here: in Fedora 43 it is an empty stub package
@@ -437,13 +536,12 @@
437 536 smartmontools \
438 537 # ipmitool talks to the AST2600 BMC on server boards from the host
439 538 # side, which is how a headless box reports its own sensors and
440 - # event log without going through the web UI.
539 + # event log without going through the web UI. The one package here
540 + # that is more useful on `server` than on `client`.
441 541 ipmitool \
442 542 # lm_sensors covers everything that is not behind a BMC: laptop
443 543 # thermals, and on a desktop board the CPU and fan readings.
444 544 lm_sensors \
445 - # Wayland session glue
446 - cliphist \
447 545 # The fuzzy picker behind both TUI menus in usr/bin: alloy-menu
448 546 # ($mod+d, the launcher) and alloy-clipmenu ($mod+Shift+v, the read
449 547 # half of the clipboard history). docs/STACK.md rejects graphical
@@ -451,29 +549,11 @@
451 549 # places; one package is the whole of that, and without it the
452 550 # cliphist watchers the sway config has always run had nothing that
453 551 # could read them back.
454 - fzf \
455 - # Lock + idle. swaylock is the adopted lock surface per
456 - # docs/STACK.md#lock. A session-lock surface is graphical and
457 - # cannot be a TUI, so Alloy adopts rather than authors it. The
458 - # themed config in etc/skel had no package behind it until now.
459 - swaylock \
460 - swayidle \
461 - swayosd \
462 - # Fingerprint unlock, which swaylock reaches without knowing it: its
463 - # /etc/pam.d/swaylock is `auth include login`, login includes
464 - # system-auth, and system-auth is what authselect rewrites when the
465 - # with-fingerprint feature is enabled below. So the lock screen, the
466 - # greeter and run0 all gain the same unlock from one switch, and none
467 - # of them needs an Alloy-authored PAM file.
468 552 #
469 - # fprintd-pam is the half that matters and is not pulled in by fprintd:
470 - # the daemon can enroll a finger all day, but without the PAM module
471 - # nothing ever asks it. Both named so neither arrives as somebody
472 - # else's transitive dependency.
473 - fprintd \
474 - fprintd-pam \
475 - playerctl \
476 - gammastep \
553 + # Base rather than client despite that framing: fzf is a shell tool
554 + # first, and a headless box's interactive history search wants it
555 + # for reasons that have nothing to do with sway.
556 + fzf \
477 557 # Continuity
478 558 tailscale \
479 559 syncthing \
@@ -492,6 +572,11 @@
492 572 # a headless box minted with a baked hostname is reached at
493 573 # `<name>.local`, so publishing is load-bearing.
494 574 #
575 + # Note this is the group whose weak-dependency carriers are mostly
576 + # client-side (cups-libs, pipewire, geoclue2). On `server` those are
577 + # gone, so avahi arrives only because this line names it — which is
578 + # exactly the profile where the install flow depends on it most.
579 + #
495 580 # nss-mdns is the half that was missing. It needs no nsswitch edit from
496 581 # us: /etc/nsswitch.conf is a symlink into authselect, and the package's
497 582 # own %post inserts `mdns4_minimal [NOTFOUND=return]` ahead of `resolve`,
@@ -504,140 +589,20 @@
504 589 # port.
505 590 avahi \
506 591 nss-mdns \
507 - # Greeter
508 - greetd \
509 - tuigreet \
510 - # No initial-setup. It was here because the bootc Anaconda flow has no
511 - # user-creation spoke, so an install otherwise finished with root
512 - # locked and no way in. `alloy install` creates the account now, and
513 - # keeping it made things worse rather than redundant: it ships enabled
514 - # in both graphical.target.wants and multi-user.target.wants, and on
515 - # first boot it takes the console and blocks on an Anaconda text spoke
516 - # reading "[!] User creation (No user will be created)", in front of a
517 - # perfectly good uid 1000 account. Verified in QEMU 2026-07-20.
518 - # Removing it is what lets first boot reach greetd.
519 - # Cursor, GTK theme
520 - bibata-cursor-theme \
521 - adw-gtk3-theme \
522 - # Screenshot capture + region-select (sway has no built-in grab)
523 - grim slurp \
524 - # Browser (docs/STACK.md#browser). Helium is ungoogled-chromium with
525 - # the defaults Alloy used to hand-build in fifty Firefox prefs and a
526 - # policy file, so it ships here with no configuration at all: no
527 - # policy, no pref seed, no chrome CSS. That is the whole reason it is
528 - # the default. Terra packages it; upstream ships no Flatpak, and the
529 - # unofficial repackagings are worse provenance than this for the most
530 - # attack-exposed program on the box.
531 - helium-browser-bin \
532 - # Script coverage for the browser. Until now the image carried
533 - # google-noto-sans-vf (Latin, Greek, Cyrillic) and nothing else, so
534 - # the browser rendered every CJK, Arabic, Hebrew, Indic and Thai page
535 - # as rows of missing glyphs. Shipping a browser as the default and
536 - # then not carrying the fonts a large share of the web is written in
537 - # is the same defect class as the emoji alias that named a font the
538 - # image never installed: the config was fine, the coverage was
539 - # absent, and nothing said so. The fonts outlive any one browser
540 - # pick, so this line does not move when the pick does.
541 - #
542 - # These are Fedora's own coverage metapackages rather than a hand-
543 - # picked list, because the failure mode of hand-picking is a script
544 - # nobody on this end reads being the one left out. Cost stated rather
545 - # than absorbed: -cjk-sans is 62 MiB over 4 packages (the CJK faces are
546 - # simply large), -other-sans is 13 MiB over 95.
547 - #
548 - # No fontconfig change goes with this. The aliases in
549 - # etc/skel/.config/fontconfig/fonts.conf use <prefer>, which leaves
550 - # fontconfig free to fall through to a font that has the glyph, so
551 - # Atkinson stays the sans for Latin text and Noto covers what it
552 - # cannot.
553 - #
554 - # Emoji is untouched and still deliberately absent (docs/STACK.md):
555 - # neither metapackage carries an emoji font, which is why declining
556 - # emoji survives this line.
557 - default-fonts-cjk-sans \
558 - default-fonts-other-sans \
559 - # Containers: the three backends behind `alloy pkg box`'s isolation
560 - # dial (docs/STACK.md#containers). The user picks a level, not a
561 - # tool. podman is the runtime and is what `workspace` calls
562 - # directly; distrobox wraps it for `host`; flatpak is `sandboxed`,
563 - # and is the install path for everything Alloy does not bake in.
592 + # podman, the runtime behind two of `alloy pkg box`'s three isolation
593 + # levels: `workspace` calls it directly and distrobox wraps it for
594 + # `host`. flatpak is the third and is client-only, since `sandboxed`
595 + # exists to run desktop applications through portals.
564 596 podman \
565 - flatpak \
566 - # Secrets (docs/STACK.md#secrets). Two different jobs, and only the
567 - # first of them is about Max's own logins.
568 - #
569 - # gnome-keyring is the Secret Service provider, and it is here
570 - # because its absence broke Make Creative's own software on Make
571 - # Creative's own distro. Nothing in the image registered
572 - # org.freedesktop.secrets, and the Rust `keyring` crate and Tauri's
573 - # credential plumbing both resolve to it on Linux, so GoingsOn and
574 - # Balanced Breakfast had nowhere to put a credential and
575 - # synckit-client had nowhere to persist its E2EE key. That surfaces
576 - # as a confusing runtime error inside an app, four layers from the
577 - # cause.
578 - #
579 - # gnome-keyring-pam is a SEPARATE package and not a weak dependency
580 - # of the one above, which matters more than it looks. Fedora's
581 - # /etc/pam.d/greetd already carries the two stanzas that unlock the
582 - # keyring with the login password, and both are `-` prefixed:
583 - #
584 - # -auth optional pam_gnome_keyring.so
585 - # -session optional pam_gnome_keyring.so auto_start
586 - #
587 - # A leading `-` tells PAM to skip a module it cannot load, without a
588 - # word in any log. So the provider alone gets you a Secret Service
589 - # that works and a keyring locked behind a second password prompt
590 - # nobody chose, and the config that was supposed to prevent that is
591 - # sitting right there looking correct. Same defect class as the
592 - # emoji alias and the swayosd unit path: configuration that is
593 - # correct about something absent. Both packages or neither.
594 - #
595 - # Nothing here ships a /etc/pam.d file. greetd owns that one and the
596 - # stanzas are already in it, so the fix is a package rather than a
597 - # config edit; the assertion further down is what keeps that true.
598 - #
599 - # KeePassXC also provides the interface and was rejected for
600 - # dragging Qt into a ratatui/egui design language, not on
601 - # capability. systemd-creds is unrelated and stays: service
602 - # credentials for daemons, no interactive component.
603 - gnome-keyring \
604 - gnome-keyring-pam \
605 597 # gopass is the password manager, on its age backend, synced by git
606 598 # (decided 2026-07-30). Fedora main carries it at 1.16.1; no COPR.
607 599 # It needs no gpg on the age backend, and the git half is the `git`
608 - # already named below.
609 - gopass \
610 - # -----------------------------------------------------------------
611 - # Session prerequisites — not curated picks. See the header above.
612 - # fedora-bootc is a server base; everything in this group is
613 - # something the shipped configuration already assumes exists.
614 - # -----------------------------------------------------------------
615 - # Audio. The base has no sound stack whatsoever: no pipewire, no
616 - # wireplumber, and so no `pactl`, which crates/alloy/src/audio.rs
617 - # shells out to for every reading and every write. `alloy audio`
618 - # is documented as shipped (docs/CONSOLE.md) but had nothing to
619 - # front. Established by probing quay.io/fedora/fedora-bootc:43
620 - # directly (rpm -q, and `pactl` absent from PATH), not observed on
621 - # hardware — the QEMU punch list is where that gets confirmed. The
622 - # Fn-key volume binds, mpv, and anything playerctl controls have
623 - # the same hole under them.
600 + # already named below. A CLI with no graphical half, so it is base.
624 601 #
625 - # pulseaudio-utils is what actually carries /usr/bin/pactl (the
626 - # pipewire-pulseaudio package is the daemon-side compat shim, not
627 - # the CLI). Both are needed. No preset lines accompany these:
628 - # Fedora's own 90-default-user.preset already socket-activates
629 - # pipewire and pipewire-pulse, and `systemctl preset-all` below
630 - # picks that up.
631 - pipewire \
632 - wireplumber \
633 - pipewire-pulseaudio \
634 - pulseaudio-utils \
635 - # wl-clipboard. The sway config exec's `wl-paste --watch cliphist
636 - # store` twice at session start (docs/STACK.md#clipboard-history),
637 - # and wl-copy is how anything gets back out of the history. cliphist
638 - # was installed without it, so both watchers failed at every login
639 - # and the clipboard history could never be populated or pasted from.
640 - wl-clipboard \
602 + # gnome-keyring, its Secret Service counterpart, is client-only: it
603 + # is the D-Bus provider desktop applications resolve to, and there is
604 + # no session bus to hold it on a headless box.
605 + gopass \
641 606 # git. Not in the base. aliases.nu ships six git aliases, helix's
642 607 # diff gutters need it, docs/STACK.md sells EDITOR=hx on git commit
643 608 # messages, and the stated audience is developers. The full package
@@ -645,38 +610,16 @@
645 610 # perl helpers, which is the difference between a git that works
646 611 # and a git that works when you ask it a question.
647 612 git \
648 - # Rust toolchain. The console is built in the rust-build stage above
649 - # and only its binary is copied into this image, so until now an
650 - # Alloy machine could run Rust programs and not build one. That is
651 - # the same gap git had and the same audience argument closes it.
652 - #
653 - # It is also a hard requirement for the build-host role. sandod
654 - # shells out to `cargo build --release` in its own workdir and
655 - # refuses to compile anywhere but its configured build host, so a
656 - # Sando host with no cargo is not a Sando host. Naming that here
657 - # because it is the reason the line landed now rather than later.
658 - #
659 - # No separate linker line: `rust` pulls gcc, binutils and
660 - # glibc-devel, so cc arrives with it. Fedora 43 carries 1.96.1,
661 - # ahead of the workspace's 1.86 floor and current enough for
662 - # crates tracking recent stable.
663 - #
664 - # The cost is 610 MiB installed across 16 packages, most of it
665 - # rust-std-static (164 MiB) and llvm-libs (139 MiB). That is an
666 - # order of magnitude above the hardware-health group above and by
667 - # a distance the largest thing in this block, so it is stated
668 - # rather than absorbed. Per-image language selection at mint time
669 - # is the way this stops being one number for everyone; see
670 - # docs/IMAGE.md.
671 - #
672 - # rustup is deliberately not here. It installs into $HOME, needs
673 - # nothing from the image, and stays the answer for a pinned or
674 - # nightly toolchain. Nor are project-specific -devel packages:
675 - # those belong to whatever is being built, not to the OS.
676 - rust cargo \
613 + # The Rust toolchain used to be an unconditional line here, and its
614 + # comment argued at length about the 610 MiB it costs before ending
615 + # with "per-image language selection at mint time is the way this
616 + # stops being one number for everyone". That is now the `LANGS` ARG
617 + # and the toolchain block further down. The reasoning moved with it;
618 + # nothing about the cost or the build-host requirement changed.
677 619 # xdg-user-dirs. Without it a new account gets a bare home and no
678 - # ~/Documents, ~/Downloads or ~/Pictures, which is what the GTK
679 - # file-chooser portal and yazi both open into.
620 + # ~/Documents, ~/Downloads or ~/Pictures, which is what yazi opens
621 + # into. Base because yazi is base; the GTK file-chooser portal that
622 + # was the other consumer is client-only.
680 623 xdg-user-dirs \
681 624 # jq. Already present, and that is exactly the problem: it comes from
682 625 # fedora-bootc rather than from any line here, and the sway config's
@@ -686,9 +629,311 @@
686 629 # observation of someone else's package set, which can change without
687 630 # warning and would take the binding with it silently. Declaring it costs
688 631 # nothing and turns an inherited assumption into a stated dependency.
632 + #
633 + # Base rather than client even though that binding is client-side:
634 + # usr/bin/alloy-shot is not the only consumer, and a JSON tool on a
635 + # build host needs no further argument.
689 636 jq \
690 637 && dnf clean all
691 638
639 + # ---------------------------------------------------------------------
640 + # Client packages — the graphical session and everything that assumes one.
641 + #
642 + # Conditional, and the `else` branch is not decoration. Every `$PROFILE`
643 + # conditional in this file asserts something on both sides, so that a
644 + # branch taken by mistake fails the build instead of quietly skipping
645 + # work. Here that means `server` proves the compositor really is absent
646 + # rather than trusting that the `then` branch did not run. tests/
647 + # profile_split.rs enforces the rule across the whole file; the
648 + # PROFILE-validity assertion above is what makes the condition itself
649 + # trustworthy.
650 + # ---------------------------------------------------------------------
651 + RUN if [ "$PROFILE" = client ]; then \
652 + dnf install -y \
653 + # Compositor and Wayland session (Fedora main)
654 + sway \
655 + # Portals: -wlr is the wlroots backend sway needs for screencast.
656 + # -gnome was the wrong backend here (it drives screencast through
657 + # gnome-shell, which Alloy removes below). -gtk stays for the file
658 + # chooser.
659 + xdg-desktop-portal xdg-desktop-portal-gtk xdg-desktop-portal-wlr \
660 + # Notifications, screenshot annotate, wallpaper (bar = sway's built-in swaybar)
661 + mako \
662 + # notify-send, which is the *client* half. mako implements the
663 + # notification server and does not pull this in, so it has been arriving
664 + # as somebody else's transitive dependency. usr/bin/alloy-shot calls it on
665 + # every screenshot, and the failure mode if it goes missing is the exact
666 + # one that script was written to fix: a capture that happens and says
667 + # nothing. Same reasoning as jq above.
668 + libnotify \
669 + satty \
670 + swww \
671 + # Content viewers. These are what yazi opens files with, which is
672 + # test 2 of the packaging policy's in-image tests, so they follow
673 + # yazi's consumers rather than yazi itself: all three want a screen.
674 + mpv \
675 + imv \
676 + zathura zathura-pdf-mupdf \
677 + # Wayland session glue
678 + cliphist \
679 + # Lock + idle. swaylock is the adopted lock surface per
680 + # docs/STACK.md#lock. A session-lock surface is graphical and
681 + # cannot be a TUI, so Alloy adopts rather than authors it. The
682 + # themed config in etc/skel had no package behind it until now.
683 + swaylock \
684 + swayidle \
685 + swayosd \
686 + # Fingerprint unlock, which swaylock reaches without knowing it: its
687 + # /etc/pam.d/swaylock is `auth include login`, login includes
688 + # system-auth, and system-auth is what authselect rewrites when the
689 + # with-fingerprint feature is enabled below. So the lock screen, the
690 + # greeter and run0 all gain the same unlock from one switch, and none
691 + # of them needs an Alloy-authored PAM file.
692 + #
693 + # fprintd-pam is the half that matters and is not pulled in by fprintd:
Lines truncated
@@ -23,12 +23,13 @@
23 23 # build/build-image.sh # build image + raw disk image
24 24 # build/build-image.sh --type qcow2 # build image + qcow2 disk image
25 25 # build/build-image.sh --skip-build # reuse the current image, just run bib
26 - # build/build-image.sh --skip-bib --write /dev/sdX # write what is already built
26 + # build/build-image.sh --write-only --write /dev/sdX # write what is already built
27 27 # build/build-image.sh --write /dev/sdX # also dd the artifact to a device
28 28 #
29 - # Writing requires an explicit device path and an interactive confirmation,
30 - # refuses partitions and anything with a mounted filesystem, and verifies the
31 - # result with cmp before claiming success.
29 + # Writing is build/write-device.sh's job, shared with build/build-iso.sh: it
30 + # requires an explicit device path and an interactive confirmation, refuses
31 + # partitions and anything with a mounted filesystem, and verifies the result
32 + # with cmp before claiming success.
32 33
33 34 set -euo pipefail
34 35
@@ -43,6 +44,8 @@
43 44 SKIP_BUILD=0
44 45 SKIP_BIB=0
45 46
47 + BUILD_ARGS=()
48 +
46 49 die() { printf 'error: %s\n' "$*" >&2; exit 1; }
47 50
48 51 usage() {
@@ -55,7 +58,15 @@
55 58 --type) TYPE="${2:?--type needs a value}"; shift 2 ;;
56 59 --write) WRITE_DEV="${2:?--write needs a device path}"; shift 2 ;;
57 60 --skip-build) SKIP_BUILD=1; shift ;;
58 - --skip-bib) SKIP_BIB=1; SKIP_BUILD=1; shift ;;
61 + # --skip-bib is the older spelling, kept working. --write-only is the
62 + # name both scripts answer to, because "bib" means nothing in the ISO
63 + # path and the console emits one flag for both artifacts.
64 + --write-only|--skip-bib) SKIP_BIB=1; SKIP_BUILD=1; shift ;;
65 + # Passed straight to `podman build`. The Containerfile validates every
66 + # one of them against its own curated sets (PROFILE, BROWSER, LANGS), so
67 + # the gate lives there rather than here: a bad value has to fail the
68 + # build whether it came from `alloy image` or from a hand-typed flag.
69 + --build-arg) BUILD_ARGS+=(--build-arg "${2:?--build-arg needs KEY=VALUE}"); shift 2 ;;
59 70 -h|--help) usage 0 ;;
60 71 *) die "unknown argument: $1 (see --help)" ;;
61 72 esac
@@ -73,13 +84,13 @@
73 84 [ -f "$DEF" ] || die "missing distro def: $DEF"
74 85 [ -f "$REPO_ROOT/Containerfile" ] || die "no Containerfile at $REPO_ROOT"
75 86
76 - # --skip-bib exists to make `--write` usable on its own. Without it the only
87 + # --write-only exists to make `--write` usable on its own. Without it the only
77 88 # way to write an artifact that already exists was to rebuild it first, so
78 89 # the documented workaround was to bypass this script and run dd by hand,
79 90 # which is exactly where the guards and the verify live.
80 91 if [ "$SKIP_BIB" -eq 1 ]; then
81 92 echo "==> Skipping image build and bib; using the artifact already in $OUTPUT"
82 - [ -n "$WRITE_DEV" ] || die "--skip-bib only makes sense with --write"
93 + [ -n "$WRITE_DEV" ] || die "--write-only only makes sense with --write"
83 94 fi
84 95
85 96 # 1. Build the bootc image (rootful, so bib sees it in the same store).
@@ -87,7 +98,7 @@
87 98 :
88 99 elif [ "$SKIP_BUILD" -eq 0 ]; then
89 100 echo "==> Building $IMAGE (rootful)"
90 - sudo podman build -t "$IMAGE" "$REPO_ROOT"
101 + sudo podman build "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
91 102 else
92 103 echo "==> Skipping image build; reusing $IMAGE"
93 104 sudo podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
@@ -136,62 +147,11 @@
136 147 [ -n "$ARTIFACT" ] && sudo test -f "$ARTIFACT" || die "expected artifact not found for type $TYPE"
137 148 echo "==> Built: $ARTIFACT ($(sudo du -h "$ARTIFACT" | cut -f1))"
138 149
139 - # 5. Optionally write to a device.
150 + # 5. Optionally write to a device. The guards, the confirmation and the
151 + # verify live in build/write-device.sh, which build/build-iso.sh calls
152 + # too — one implementation of the dd path, per wiki alloy-distribution.
140 153 if [ -n "$WRITE_DEV" ]; then
141 - [ -b "$WRITE_DEV" ] || die "$WRITE_DEV is not a block device"
142 -
143 - # A whole disk, not a partition. An ISO written to /dev/sda1 produces
144 - # nothing bootable and quietly eats a filesystem on the way.
145 - [ "$(lsblk -dnro TYPE "$WRITE_DEV")" = "disk" ] \
146 - || die "$WRITE_DEV is not a whole disk; pass the disk, not a partition"
147 -
148 - # Any mountpoint at or below the device, not just / and /boot. The old
149 - # guard matched those two patterns only, so a disk holding /home, /var or
150 - # an active swap passed it and got written. This is the rule the installer
151 - # itself applies (install.rs, Disk::blocker): anything mounted blocks.
152 - # `lsblk -r` escapes a newline as \x0a, so a device with two mountpoints
153 - # arrives as one run-together line. Unescape before printing: this list is
154 - # read by someone deciding whether to erase a disk.
155 - mounts="$(lsblk -nro MOUNTPOINTS "$WRITE_DEV" 2>/dev/null \
156 - | sed 's/\\x0a/\n/g' | grep -v '^$' || true)"
157 - if [ -n "$mounts" ]; then
158 - printf 'error: %s has mounted filesystems; refusing to write:\n' "$WRITE_DEV" >&2
159 - printf '%s\n' "$mounts" | sed 's/^/ /' >&2
160 - exit 1
161 - fi
162 -
163 - echo
164 - lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,MODEL,SERIAL,TRAN,RM,RO "$WRITE_DEV"
165 - echo
166 - # Removable is worth saying out loud: on this box the system disk is nvme
167 - # and a USB stick reports usb/RM=1, so a non-removable target is the shape
168 - # of a mistake even when nothing is mounted on it.
169 - if [ "$(lsblk -dnro RM "$WRITE_DEV")" != "1" ]; then
170 - echo "WARNING: $WRITE_DEV is not removable. This is the shape of an internal disk."
171 - echo
172 - fi
173 - printf 'This ERASES all data on %s. Type the device path to confirm: ' "$WRITE_DEV"
174 - read -r reply
175 - [ "$reply" = "$WRITE_DEV" ] || die "confirmation did not match; not writing"
176 - echo "==> Writing $ARTIFACT to $WRITE_DEV"
177 - sudo dd if="$ARTIFACT" of="$WRITE_DEV" bs=4M oflag=direct conv=fsync status=progress
178 - sync
179 -
180 - # Verify, because dd reporting success is not evidence the bytes landed.
181 - # cmp over exactly the artifact's length is the authoritative check; a
182 - # `dd | head -c N | sha256sum` pipeline reported phantom corruption on a
183 - # write that cmp proved perfect (wiki alloy-build-notes).
184 - echo "==> Verifying the write"
185 - size="$(sudo stat -c %s "$ARTIFACT")"
186 - # A check that cannot fail is not a check: prove cmp can still disagree
187 - # before trusting it to agree.
188 - if sudo cmp -s -n 4096 "$WRITE_DEV" /dev/zero; then
189 - die "negative control passed, which means cmp is not comparing anything"
190 - fi
191 - sudo cmp -n "$size" "$WRITE_DEV" "$ARTIFACT" \
192 - || die "$WRITE_DEV does not match $ARTIFACT; the write is bad"
193 - echo "==> Verified $size bytes."
194 - echo "==> Done. $WRITE_DEV is now a bootable Alloy $TYPE."
154 + "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "$TYPE"
195 155 else
196 156 echo "==> To write it to a USB stick:"
197 157 echo " sudo dd if=$ARTIFACT of=/dev/sdX bs=4M oflag=direct conv=fsync status=progress"
M build/build-iso.sh +42 -10
@@ -26,6 +26,11 @@
26 26 # build/build-iso.sh --fast # iteration: cheap compression, keeps source
27 27 # build/build-iso.sh --fast --skip-source # boot chain only, cannot install
28 28 # build/build-iso.sh --update-target host:5000/alloy:43 # updates come from there
29 + # build/build-iso.sh --write /dev/sdX # build, then write to a device
30 + # build/build-iso.sh --write-only --write /dev/sdX # write the ISO already built
31 + #
32 + # Writing is build/write-device.sh's job, shared with build/build-image.sh,
33 + # so there is one implementation of the dd path and one set of guards.
29 34
30 35 set -euo pipefail
31 36
@@ -38,16 +43,25 @@
38 43 SKIP_BUILD=0
39 44 SKIP_SOURCE=0
40 45 FAST=0
46 + WRITE_ONLY=0
47 + WRITE_DEV=""
41 48 # Empty means the installer keeps its compiled-in default, the public registry.
42 49 # See --update-target below.
43 50 UPDATE_TARGET=""
44 51
52 + BUILD_ARGS=()
53 +
45 54 die() { printf 'error: %s\n' "$*" >&2; exit 1; }
46 55 say() { printf '==> %s\n' "$*"; }
47 56
48 57 while [ $# -gt 0 ]; do
49 58 case "$1" in
50 59 --skip-build) SKIP_BUILD=1; shift ;;
60 + # Passed straight to `podman build`. The Containerfile validates every
61 + # one of them against its own curated sets (PROFILE, BROWSER, LANGS), so
62 + # the gate lives there rather than here: a bad value has to fail the
63 + # build whether it came from `alloy image` or from a hand-typed flag.
64 + --build-arg) BUILD_ARGS+=(--build-arg "${2:?--build-arg needs KEY=VALUE}"); shift 2 ;;
51 65 --skip-source) SKIP_SOURCE=1; shift ;;
52 66 # Iteration mode: reuse the image and compress cheaply, but still carry
53 67 # the install source. Level 19 costs about ten minutes of saturated CPU
@@ -64,17 +78,33 @@
64 78 --update-target)
65 79 [ $# -ge 2 ] || die "--update-target needs a registry reference"
66 80 UPDATE_TARGET="$2"; shift 2 ;;
67 - -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
81 + # The device write. Same two flags build-image.sh takes, because the
82 + # console emits one spelling for both artifacts.
83 + --write) WRITE_DEV="${2:?--write needs a device path}"; shift 2 ;;
84 + --write-only) WRITE_ONLY=1; shift ;;
85 + -h|--help) sed -n '2,33p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
68 86 *) die "unknown argument: $1 (see --help)" ;;
69 87 esac
70 88 done
71 89
90 + ARTIFACT="$OUTPUT/install.iso"
91 +
92 + # --write-only skips every build step, including the output rotation, and
93 + # writes the ISO already sitting in $OUTPUT. It needs no podman, so it comes
94 + # before that check: writing a stick should not depend on being able to build.
95 + if [ "$WRITE_ONLY" -eq 1 ]; then
96 + [ -n "$WRITE_DEV" ] || die "--write-only only makes sense with --write"
97 + sudo test -f "$ARTIFACT" || die "no ISO at $ARTIFACT; build one first"
98 + say "writing the ISO already in $OUTPUT"
99 + exec "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "installer ISO"
100 + fi
101 +
72 102 command -v podman >/dev/null || die "podman not found"
73 103
74 104 # 1. The Alloy image.
75 105 if [ "$SKIP_BUILD" -eq 0 ]; then
76 106 say "building $IMAGE"
77 - sudo podman build -t "$IMAGE" "$REPO_ROOT"
107 + sudo podman build "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
78 108 else
79 109 sudo podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
80 110 say "reusing $IMAGE"
@@ -147,14 +177,16 @@
147 177 -v "$WORKDIR/source":/source:ro \
148 178 "$BUILDER"
149 179
150 - ARTIFACT="$OUTPUT/install.iso"
151 180 sudo test -f "$ARTIFACT" || die "no ISO produced"
152 181 say "built: $ARTIFACT ($(sudo du -h "$ARTIFACT" | cut -f1))"
153 182 echo
154 - # Not build-image.sh: that script writes bib's artifact, $OUTPUT/bootiso,
155 - # which this ISO is the replacement for and does not produce. Pointing at it
156 - # sent a write at a path that is not there.
157 - echo " Boot it, or write it with:"
158 - echo " sudo dd if=$ARTIFACT of=/dev/sdX bs=4M oflag=direct conv=fsync status=progress"
159 - echo " then check it took:"
160 - echo " sudo cmp -n \$(sudo stat -c %s $ARTIFACT) /dev/sdX $ARTIFACT"
183 +
184 + if [ -n "$WRITE_DEV" ]; then
185 + "$REPO_ROOT/build/write-device.sh" "$ARTIFACT" "$WRITE_DEV" "installer ISO"
186 + else
187 + # Not build-image.sh: that script writes bib's artifact, $OUTPUT/bootiso,
188 + # which this ISO is the replacement for and does not produce. Pointing at it
189 + # sent a write at a path that is not there.
190 + echo " Boot it, or write it with:"
191 + echo " build/build-iso.sh --write-only --write /dev/sdX"
192 + fi
@@ -80,6 +80,33 @@
80 80 backend builds argv and runs nothing; the view executes through the command log,
81 81 which makes "every action shows its invocation" structural rather than remembered.
82 82
83 + `alloy image` is the builder, and it is Alloy's whole distribution mechanism:
84 + nobody downloads an Alloy image, they build one (wiki `alloy-distribution`). It
85 + is a form rather than a wizard — six choices that are read together before
86 + either action is taken, where `alloy install`'s wizard shape is for a fixed
87 + sequence ending in a destructive act. It exposes only the choices Alloy
88 + deliberately declines to make: the profile (client or server), the browser,
89 + which language toolchains to carry, the artifact, and machine identity. Theme,
90 + terminal, editor and shell stay fixed and are not on it.
91 +
92 + Two properties of that screen are constraints rather than decisions. **It does
93 + not write the disk.** `build/build-image.sh --write` refuses partitions, refuses
94 + anything mounted, and verifies with `cmp` against a negative control; re-deriving
95 + that behind a progress bar is how a disk-eating bug gets written. So the console
96 + owns the choice of device and then hands the terminal to the script through the
97 + same suspend `distrobox enter` uses, where it asks its own confirmation and
98 + applies its own guards. **And the artifact holds no secrets:** the hostname and
99 + the ssh public key it bakes in are both public, which is what lets the medium be
100 + kept, copied or rebuilt without care.
101 +
102 + The profile also decides which verbs exist. A `server` image has no compositor
103 + and no bar, so `alloy display` and `alloy status --bar` are dropped from
104 + `--help` there and refuse to run, both off the same table so the two cannot
105 + drift. That reads a marker the build wrote rather than probing for a compositor:
106 + a probe answers "no" on a client machine sitting at the greeter or reached over
107 + SSH, which would make a verb appear and disappear on one machine depending on
108 + where it was typed.
109 +
83 110 `alloy net` writes as well as reads: connect, disconnect, and the wifi radio. Those three
84 111 need no privilege at all, which is a fact rather than a design: NetworkManager's shipped
85 112 policy grants `network-control` and `enable-disable-wifi` to an active session outright.
@@ -47,9 +47,9 @@
47 47 ## Write it to a USB stick
48 48
49 49 Any of the usual tools work on the ISO in `output/`. If you would rather have
50 - the write checked for you, the disk-image path has guards:
50 + the write checked for you, both build scripts take the same two flags:
51 51
52 - build/build-image.sh --skip-bib --write /dev/sdX
52 + build/build-iso.sh --write-only --write /dev/sdX
53 53
54 54 It refuses partitions, refuses anything with a mounted filesystem, demands an
55 55 explicit device path plus a typed confirmation, and verifies the result with
@@ -24,7 +24,7 @@
24 24
25 25 cd alloy
26 26 git pull
27 - build/build-iso.sh --skip-source # or build/build-image.sh --skip-bib
27 + build/build-iso.sh --skip-source # or build/build-image.sh
28 28
29 29 Either one rebuilds `localhost/alloy:local`. Then, on the machine to update:
30 30
@@ -11,10 +11,12 @@
11 11 mod cli;
12 12 mod credits;
13 13 mod display;
14 + mod image;
14 15 mod install;
15 16 mod mesh;
16 17 mod net;
17 18 mod pkg;
19 + mod profile;
18 20 mod recovery;
19 21 mod run;
20 22 mod schema;
@@ -30,7 +32,7 @@
30 32 mod wizard;
31 33
32 34 use anyhow::Result;
33 - use clap::{Parser, Subcommand};
35 + use clap::{FromArgMatches, Parser, Subcommand};
34 36
35 37 use crate::cli::CommandLog;
36 38
@@ -102,6 +104,13 @@
102 104 Update,
103 105 /// Install Alloy to a disk
104 106 Install,
107 + // The builder, and Alloy's whole distribution mechanism: nobody downloads
108 + // an Alloy image, they build one (wiki `alloy-distribution`). `image`
109 + // rather than `mint` — the note offers both, and the boring noun is the
110 + // one someone guesses. It subsumes the minting tool rather than shipping
111 + // beside it, which is why hostname and pubkey are rows on its form.
112 + /// Build an Alloy image or installer medium
113 + Image,
105 114 /// System settings and application configs
106 115 Settings,
107 116 /// Synchronized folders and the devices they reach
@@ -187,7 +196,30 @@
187 196 }
188 197
189 198 fn main() -> Result<()> {
190 - let cli = Cli::parse();
199 + // The profile decides which verbs exist, so it is read before the
200 + // arguments are. `alloy display` drives swaymsg and `alloy status --bar`
201 + // writes swaybar's protocol; a server image has neither, and Max ruled
202 + // 2026-08-01 that those verbs are hidden there rather than reporting the
203 + // absence. See [`profile`] for why this reads a build-time marker rather
204 + // than probing for a compositor.
205 + let profile = profile::Profile::current();
206 +
207 + // Hidden from `--help`, still resolved. clap would accept the verb either
208 + // way, and a command that silently works while being undocumented is how
209 + // documentation becomes wrong; the match arms below say why instead.
210 + let command = profile::hide_unavailable(<Cli as clap::CommandFactory>::command(), profile);
211 + let cli = Cli::from_arg_matches(&command.get_matches())?;
212 +
213 + // Refused as well as hidden, and through the same table, so the two
214 + // cannot drift. See `profile::is_unavailable`.
215 + let verb = match &cli.command {
216 + Command::Display => "display",
217 + Command::Status { .. } => "status",
218 + _ => "",
219 + };
220 + if profile::is_unavailable(verb, profile) {
221 + anyhow::bail!("{}", profile::unavailable(verb));
222 + }
191 223
192 224 // Before the theme load, and that ordering is load-bearing rather than
193 225 // tidy. `theme::load` is a hard error when nothing is on the search path,
@@ -296,6 +328,10 @@
296 328 let mut view = install::InstallView::new(&mut log);
297 329 shell::run(&theme, &mut view, &mut log)
298 330 }
331 + Command::Image => {
332 + let mut view = image::ImageView::new(&mut log);
333 + shell::run(&theme, &mut view, &mut log)
334 + }
299 335 // Already handled above, before the theme load. Matched per action
300 336 // rather than with a `_` so a second `alloy theme` verb cannot be added
301 337 // and silently do nothing here.
@@ -1,0 +1,148 @@
1 + #!/usr/bin/env bash
2 + #
3 + # refresh-base-digests.sh — move the Containerfile's pinned base images to
4 + # whatever the tags point at today.
5 + #
6 + # Both FROM lines are pinned by digest (wiki `alloy-distribution`, "Rebuilding
7 + # a past image"). A pin nobody can move is a pin people work around, so this is
8 + # the supported way to move it: it resolves each tag, rewrites the line in
9 + # place, and prints what changed so the diff is reviewable before it is
10 + # committed. Run it deliberately — a base image move is a rebuild of
11 + # everything, and it is a decision rather than maintenance.
12 + #
13 + # Multi-arch index digests, never per-arch manifests. CLAUDE.md forbids
14 + # cross-compiling and Alloy is built natively on fw13 (amd64) and astra
15 + # (arm64), so a per-arch pin would break one host. The script refuses a digest
16 + # that does not resolve to an index carrying both.
17 + #
18 + # No skopeo dependency: it is not in the image and not on every dev box, and
19 + # the registry v2 API answers this with two curls.
20 + #
21 + # Usage:
22 + # build/refresh-base-digests.sh # rewrite the Containerfile
23 + # build/refresh-base-digests.sh --check # exit 1 if the pins are stale
24 + #
25 + # --check is what a periodic reminder would run. It reports and changes
26 + # nothing, so it is safe to wire into anything.
27 +
28 + set -euo pipefail
29 +
30 + REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
31 + CONTAINERFILE="$REPO_ROOT/Containerfile"
32 +
33 + # The images to keep pinned, as `repository:tag`. Both are quay.io; if a base
34 + # ever comes from elsewhere this needs a registry column rather than a guess.
35 + IMAGES=(
36 + "fedora/fedora:43"
37 + "fedora/fedora-bootc:43"
38 + )
39 +
40 + REGISTRY="quay.io"
41 + CHECK_ONLY=0
42 +
43 + die() { printf 'error: %s\n' "$*" >&2; exit 1; }
44 +
45 + case "${1:-}" in
46 + --check) CHECK_ONLY=1 ;;
47 + -h|--help) sed -n '2,28p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
48 + "") ;;
49 + *) die "unknown argument: $1 (see --help)" ;;
50 + esac
51 +
52 + command -v curl >/dev/null || die "curl not found"
53 + command -v python3 >/dev/null || die "python3 not found"
54 + [ -f "$CONTAINERFILE" ] || die "no Containerfile at $CONTAINERFILE"
55 +
56 + # An anonymous pull token. quay.io serves public repos without credentials but
57 + # still wants a bearer token on the manifest endpoint.
58 + token_for() {
59 + curl -fsS "https://${REGISTRY}/v2/auth?service=${REGISTRY}&scope=repository:${1}:pull" \
60 + | python3 -c 'import sys, json; print(json.load(sys.stdin)["token"])'
61 + }
62 +
63 + # The index digest for a tag, asserted to be a real multi-arch index.
64 + #
65 + # The Accept header decides what comes back: ask for the index types first or
66 + # the registry answers with a single-arch manifest and the pin silently becomes
67 + # amd64-only. That failure would not show up until someone built on astra.
68 + digest_for() {
69 + local repo="$1" tag="$2" token body digest
70 + token="$(token_for "$repo")"
71 +
72 + digest="$(curl -fsSI \
73 + -H "Authorization: Bearer ${token}" \
74 + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \
75 + "https://${REGISTRY}/v2/${repo}/manifests/${tag}" \
76 + | grep -i '^docker-content-digest:' | tr -d '\r' | awk '{print $2}')"
77 +
78 + [ -n "$digest" ] || die "no digest for ${repo}:${tag}"
79 +
80 + # Prove it is an index over both build architectures before pinning it.
81 + body="$(curl -fsS \
82 + -H "Authorization: Bearer ${token}" \
83 + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \
84 + "https://${REGISTRY}/v2/${repo}/manifests/${digest}")"
85 +
86 + printf '%s' "$body" | python3 -c '
87 + import json, sys
88 + doc = json.load(sys.stdin)
89 + arches = {m.get("platform", {}).get("architecture") for m in doc.get("manifests", [])}
90 + missing = {"amd64", "arm64"} - arches
91 + if missing:
92 + sys.exit("not a multi-arch index over the build hosts; missing " + ", ".join(sorted(missing)))
93 + ' || die "${repo}:${tag} resolved to something unusable: $digest"
94 +
95 + printf '%s' "$digest"
96 + }
97 +
98 + stale=0
99 +
100 + for image in "${IMAGES[@]}"; do
101 + repo="${image%:*}"
102 + tag="${image#*:}"
103 + ref="${REGISTRY}/${repo}:${tag}"
104 +
105 + current="$(grep -oE "^FROM ${ref}@sha256:[0-9a-f]{64}" "$CONTAINERFILE" | head -1 | grep -oE 'sha256:[0-9a-f]{64}' || true)"
106 + [ -n "$current" ] || die "no digest-pinned FROM line for ${ref} in the Containerfile"
107 +
108 + latest="$(digest_for "$repo" "$tag")"
109 +
110 + if [ "$current" = "$latest" ]; then
111 + printf 'current %s\n %s\n' "$ref" "$current"
112 + continue
113 + fi
114 +
115 + stale=1
116 + printf 'STALE %s\n was %s\n now %s\n' "$ref" "$current" "$latest"
117 +
118 + if [ "$CHECK_ONLY" -eq 0 ]; then
119 + # Anchored at the start of the line and matching the full old digest, so
120 + # this cannot touch a digest that happens to appear in a comment.
121 + python3 - "$CONTAINERFILE" "$ref" "$current" "$latest" <<'PY'
122 + import sys
123 +
124 + path, ref, old, new = sys.argv[1:5]
125 + with open(path, encoding="utf-8") as handle:
126 + text = handle.read()
127 +
128 + needle = f"FROM {ref}@{old}"
129 + if text.count(needle) != 1:
130 + sys.exit(f"expected exactly one `{needle}`, found {text.count(needle)}")
131 +
132 + with open(path, "w", encoding="utf-8") as handle:
133 + handle.write(text.replace(needle, f"FROM {ref}@{new}"))
134 + PY
135 + printf ' rewrote %s\n' "$(basename "$CONTAINERFILE")"
136 + fi
137 + done
138 +
139 + if [ "$CHECK_ONLY" -eq 1 ] && [ "$stale" -eq 1 ]; then
140 + echo
141 + echo "Pins are behind their tags. Run build/refresh-base-digests.sh to move them."
142 + exit 1
143 + fi
144 +
145 + if [ "$stale" -eq 1 ]; then
146 + echo
147 + echo "Base images moved. Everything rebuilds from scratch on the next build."
148 + fi
@@ -1,0 +1,84 @@
1 + #!/usr/bin/env bash
2 + #
3 + # write-device.sh — dd an artifact to a block device, with the guards.
4 + #
5 + # The single implementation of the dd path. build/build-image.sh and
6 + # build/build-iso.sh both call this; neither one writes a device itself.
7 + # The rule is in wiki alloy-distribution ("Do not rewrite the dd path"):
8 + # a second copy of these checks is how a disk-eating bug gets introduced,
9 + # because the copy is the one that does not get the fix.
10 + #
11 + # Usage:
12 + # build/write-device.sh <artifact> <device> [label]
13 + #
14 + # label is what the device is called in the closing line ("raw", "installer
15 + # ISO"); it is cosmetic and defaults to "image".
16 + #
17 + # Refuses partitions and anything with a mounted filesystem, warns on a
18 + # non-removable target, demands the device path typed back, and verifies the
19 + # result with cmp before claiming success.
20 +
21 + set -euo pipefail
22 +
23 + die() { printf 'error: %s\n' "$*" >&2; exit 1; }
24 +
25 + ARTIFACT="${1:?usage: write-device.sh <artifact> <device> [label]}"
26 + DEVICE="${2:?usage: write-device.sh <artifact> <device> [label]}"
27 + LABEL="${3:-image}"
28 +
29 + sudo test -f "$ARTIFACT" || die "$ARTIFACT is not a file"
30 + [ -b "$DEVICE" ] || die "$DEVICE is not a block device"
31 +
32 + # A whole disk, not a partition. An ISO written to /dev/sda1 produces
33 + # nothing bootable and quietly eats a filesystem on the way.
34 + [ "$(lsblk -dnro TYPE "$DEVICE")" = "disk" ] \
35 + || die "$DEVICE is not a whole disk; pass the disk, not a partition"
36 +
37 + # Any mountpoint at or below the device, not just / and /boot. The old
38 + # guard matched those two patterns only, so a disk holding /home, /var or
39 + # an active swap passed it and got written. This is the rule the installer
40 + # itself applies (install.rs, Disk::blocker): anything mounted blocks.
41 + # `lsblk -r` escapes a newline as \x0a, so a device with two mountpoints
42 + # arrives as one run-together line. Unescape before printing: this list is
43 + # read by someone deciding whether to erase a disk.
44 + mounts="$(lsblk -nro MOUNTPOINTS "$DEVICE" 2>/dev/null \
45 + | sed 's/\\x0a/\n/g' | grep -v '^$' || true)"
46 + if [ -n "$mounts" ]; then
47 + printf 'error: %s has mounted filesystems; refusing to write:\n' "$DEVICE" >&2
48 + printf '%s\n' "$mounts" | sed 's/^/ /' >&2
49 + exit 1
50 + fi
51 +
52 + echo
53 + lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS,MODEL,SERIAL,TRAN,RM,RO "$DEVICE"
54 + echo
55 + # Removable is worth saying out loud: on this box the system disk is nvme
56 + # and a USB stick reports usb/RM=1, so a non-removable target is the shape
57 + # of a mistake even when nothing is mounted on it.
58 + if [ "$(lsblk -dnro RM "$DEVICE")" != "1" ]; then
59 + echo "WARNING: $DEVICE is not removable. This is the shape of an internal disk."
60 + echo
61 + fi
62 + printf 'This ERASES all data on %s. Type the device path to confirm: ' "$DEVICE"
63 + read -r reply
64 + [ "$reply" = "$DEVICE" ] || die "confirmation did not match; not writing"
65 +
66 + echo "==> Writing $ARTIFACT to $DEVICE"
67 + sudo dd if="$ARTIFACT" of="$DEVICE" bs=4M oflag=direct conv=fsync status=progress
68 + sync
69 +
70 + # Verify, because dd reporting success is not evidence the bytes landed.
71 + # cmp over exactly the artifact's length is the authoritative check; a
72 + # `dd | head -c N | sha256sum` pipeline reported phantom corruption on a
73 + # write that cmp proved perfect (wiki alloy-build-notes).
74 + echo "==> Verifying the write"
75 + size="$(sudo stat -c %s "$ARTIFACT")"
76 + # A check that cannot fail is not a check: prove cmp can still disagree
77 + # before trusting it to agree.
78 + if sudo cmp -s -n 4096 "$DEVICE" /dev/zero; then
79 + die "negative control passed, which means cmp is not comparing anything"
80 + fi
81 + sudo cmp -n "$size" "$DEVICE" "$ARTIFACT" \
82 + || die "$DEVICE does not match $ARTIFACT; the write is bad"
83 + echo "==> Verified $size bytes."
84 + echo "==> Done. $DEVICE is now a bootable Alloy $LABEL."
@@ -1,0 +1,1429 @@
1 + //! `alloy image` — the builder. Alloy's distribution mechanism.
2 + //!
3 + //! Nobody downloads an Alloy image. Users get a builder, configure it, build
4 + //! the ISO on their own machine and write it to a medium (wiki
5 + //! `alloy-distribution`). This is that builder, and it is the reason the
6 + //! project can publish source and no binaries: without it, "clone and build"
7 + //! is a Containerfile and a shell script, which is a distribution story only
8 + //! for people who already know what bootc is.
9 + //!
10 + //! # Not a package manager
11 + //!
12 + //! It exposes **the choices Alloy deliberately declines to make**, which is
13 + //! what keeps it from weakening the opinionated-defaults claim: declining to
14 + //! endorse is a stated position, not a dial. Browsers are the case the design
15 + //! note names — Alloy cannot argue one is right the way it argues for helix or
16 + //! nushell. Theme, terminal, editor and shell stay fixed and are not here.
17 + //!
18 + //! Two things joined that list since:
19 + //!
20 + //! - **The profile.** Ruled by Max 2026-08-01 as a build ARG on one
21 + //! Containerfile (wiki `alloy-server-variant`). Client is the desktop;
22 + //! server is the same machine with the compositor, greeter, session and
23 + //! browser removed.
24 + //! - **Language toolchains.** The Containerfile's own rust comment asks for
25 + //! this by name: 610 MiB across 16 packages, "per-image language selection
26 + //! at mint time is the way this stops being one number for everyone". So
27 + //! Rust is a choice here rather than an unconditional cost, defaulting on
28 + //! because the build-host role needs it.
29 + //!
30 + //! # It is also the minting tool
31 + //!
32 + //! GO alloy `1fc19a8d` folds in here rather than shipping beside it. One
33 + //! binary, because two ways to produce installer media differing only in how
34 + //! much they let you choose is one way too many. That is what the identity
35 + //! rows are: a hostname and an ssh pubkey baked in, so a headless machine can
36 + //! be reached at `<name>.local` and logged into with a key that was never
37 + //! shared. **Both are public**, which is the property that lets the artifact
38 + //! be kept, copied or rebuilt without care.
39 + //!
40 + //! # A form, not a wizard
41 + //!
42 + //! [`wizard::Steps`](crate::wizard) exists and this does not use it. A wizard
43 + //! is the shape for a fixed sequence ending in a destructive act, which is the
44 + //! installer. This is six choices that fit on one screen and are read together
45 + //! before either action is taken, and stepping through six one-question pages
46 + //! would hide the summary that is the whole point of the screen. The
47 + //! destructive act is guarded by its own confirmation instead.
48 + //!
49 + //! # The dd path is not re-derived, and that is deliberate
50 + //!
51 + //! `build/build-image.sh --write` refuses partitions, refuses anything with a
52 + //! mounted filesystem, demands an explicit device path plus an interactive
53 + //! confirmation, and verifies with `cmp` against a negative control. The
54 + //! design note names a TUI owning the write as the highest-risk part of the
55 + //! plan, and re-deriving device-write safety behind a progress bar is how a
56 + //! disk-eating bug gets written.
57 + //!
58 + //! So this does not own the write. It owns the *choice* of device, and then
59 + //! hands the terminal to the script through
60 + //! [`Flow::Suspend`](crate::shell::Flow), the same mechanism `distrobox enter`
61 + //! already uses. The script runs at a real terminal, asks its own confirmation
62 + //! in its own words, applies its own guards, and the console comes back when
63 + //! it is done. Nothing is bypassed, nothing is piped past a prompt, and no
64 + //! `--yes` flag had to be invented to make a TUI possible.
65 + //!
66 + //! # Podman-anywhere
67 + //!
68 + //! bootc-image-builder runs under podman on any Linux, so nothing here may
69 + //! assume an Alloy host. The commands are `build/build-iso.sh` and
70 + //! `build/build-image.sh` from a checkout, and the errors have to survive
71 + //! being read by someone on Ubuntu. That is why the missing-checkout case
72 + //! names what it wanted rather than assuming the user is standing in the repo.
73 + //!
74 + //! <!-- wiki: alloy-distribution -->
75 +
76 + use std::collections::BTreeSet;
77 + use std::fmt::Write as _;
78 + use std::path::{Path, PathBuf};
79 +
80 + use alloy_tui::keys::{Action, classify};
81 + use alloy_tui::{
82 + AlloyBlock, AlloyForm, AlloyList, Cursor, FieldKind, FormRow, Hint, Severity, TextField, Theme,
83 + hint, text,
84 + };
85 + use anyhow::{Context, Result};
86 + use ratatui::Frame;
87 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
88 + use ratatui::layout::{Constraint, Layout, Rect};
89 + use ratatui::text::Line;
90 +
91 + use crate::cli::{CommandLog, Invocation};
92 + use crate::run::{Sequence, Stage};
93 + use crate::shell::{Confirm, Flow, View, block_title};
94 +
95 + /// Where a built image records the choices that made it.
96 + ///
97 + /// Read back at startup so a rebuild starts from what the machine already is
98 + /// rather than from defaults. The design note accepts that a user with neither
99 + /// a previous image nor an ISO to hand starts from defaults, which is what an
100 + /// absent file means here.
101 + const RECORD: &str = "/usr/lib/alloy/build.toml";
102 +
103 + /// The same record, in a checkout, where the builder writes it.
104 + const RECORD_LOCAL: &str = "build/alloy-build.toml";
105 +
106 + /// Which machine is being built.
107 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
108 + pub(crate) enum Profile {
109 + /// The desktop: compositor, greeter, session, browser.
110 + #[default]
111 + Client,
112 + /// Headless. The console, the shell stack, the hardware-health group and
113 + /// the themed bare console, and nothing that needs a screen.
114 + Server,
115 + }
116 +
117 + impl Profile {
118 + const ALL: [Profile; 2] = [Profile::Client, Profile::Server];
119 +
120 + const fn value(self) -> &'static str {
121 + match self {
122 + Profile::Client => "client",
123 + Profile::Server => "server",
124 + }
125 + }
126 +
127 + const fn label(self) -> &'static str {
128 + match self {
129 + Profile::Client => "client (desktop)",
130 + Profile::Server => "server (headless)",
131 + }
132 + }
133 +
134 + fn parse(value: &str) -> Option<Self> {
135 + Self::ALL.into_iter().find(|p| p.value() == value)
136 + }
137 + }
138 +
139 + /// The browser, and the reason this screen exists at all.
140 + ///
141 + /// docs/STACK.md line 150: "This is a non-endorsement, not a pick." Alloy
142 + /// argues for helix over vim and nushell over bash; it cannot make the
143 + /// equivalent argument between Blink and Gecko, and the honest place to put
144 + /// that is a choice rather than a default nobody was asked about.
145 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
146 + pub(crate) enum Browser {
147 + /// ungoogled-chromium plus privacy work, from Terra. The default because
148 + /// its out-of-the-box behavior is what Alloy used to hand-build in fifty
149 + /// Firefox preferences.
150 + #[default]
151 + Helium,
152 + /// The Gecko alternative, and the reason the monoculture argument is
153 + /// recorded as overridden rather than withdrawn.
154 + Firefox,
155 + /// No browser in the image. Meaningful on `client` too: someone who wants
156 + /// to install their own from Flathub should not pay for one they will
157 + /// remove.
158 + None,
159 + }
160 +
161 + impl Browser {
162 + const ALL: [Browser; 3] = [Browser::Helium, Browser::Firefox, Browser::None];
163 +
164 + const fn value(self) -> &'static str {
165 + match self {
166 + Browser::Helium => "helium",
167 + Browser::Firefox => "firefox",
168 + Browser::None => "none",
169 + }
170 + }
171 +
172 + const fn label(self) -> &'static str {
173 + match self {
174 + Browser::Helium => "helium (Blink, from Terra)",
175 + Browser::Firefox => "firefox (Gecko, from Fedora)",
176 + Browser::None => "none",
177 + }
178 + }
179 +
180 + fn parse(value: &str) -> Option<Self> {
181 + Self::ALL.into_iter().find(|b| b.value() == value)
182 + }
183 + }
184 +
185 + /// A language toolchain the image can carry.
186 + ///
187 + /// The curated set, not an arbitrary package list. Each is a toolchain someone
188 + /// would reasonably want to build with on the machine itself rather than in a
189 + /// box, which is the line between this and `alloy pkg box`.
190 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
191 + pub(crate) enum Lang {
192 + Rust,
193 + Go,
194 + Python,
195 + Zig,
196 + }
197 +
198 + impl Lang {
199 + const ALL: [Lang; 4] = [Lang::Rust, Lang::Go, Lang::Python, Lang::Zig];
200 +
201 + const fn value(self) -> &'static str {
202 + match self {
203 + Lang::Rust => "rust",
204 + Lang::Go => "go",
205 + Lang::Python => "python",
206 + Lang::Zig => "zig",
207 + }
208 + }
209 +
210 + /// What the label says about cost, because the cost is the point.
211 + ///
212 + /// Rust's figure is the Containerfile's own measurement, and it is the
213 + /// reason this row exists: 610 MiB is an order of magnitude above the
214 + /// hardware-health group and by a distance the largest thing in the image.
215 + const fn label(self) -> &'static str {
216 + match self {
217 + Lang::Rust => "rust (610 MiB; needed for the build-host role)",
218 + Lang::Go => "go",
219 + Lang::Python => "python",
220 + Lang::Zig => "zig",
221 + }
222 + }
223 +
224 + fn parse(value: &str) -> Option<Self> {
225 + Self::ALL.into_iter().find(|l| l.value() == value)
226 + }
227 + }
228 +
229 + /// What the build produces.
230 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
231 + pub(crate) enum Artifact {
232 + /// The installer ISO that boots into `alloy install`. The decided path
233 + /// (wiki `alloy-distribution`): ISO plus dd.
234 + #[default]
235 + Iso,
236 + /// A raw disk image, for dd'ing a whole installed system.
237 + Raw,
238 + /// qcow2, for QEMU.
239 + Qcow2,
240 + }
241 +
242 + impl Artifact {
243 + const ALL: [Artifact; 3] = [Artifact::Iso, Artifact::Raw, Artifact::Qcow2];
244 +
245 + const fn value(self) -> &'static str {
246 + match self {
247 + Artifact::Iso => "iso",
248 + Artifact::Raw => "raw",
249 + Artifact::Qcow2 => "qcow2",
250 + }
251 + }
252 +
253 + const fn label(self) -> &'static str {
254 + match self {
255 + Artifact::Iso => "installer ISO (boots into alloy install)",
256 + Artifact::Raw => "raw disk image",
257 + Artifact::Qcow2 => "qcow2 (QEMU)",
258 + }
259 + }
260 +
261 + /// Which script owns it. The ISO is not bib's: every ISO type
262 + /// bootc-image-builder offers ends in Anaconda, and an install from one
263 + /// leaves root locked with no account, so `build-iso.sh` builds it outside
264 + /// bib. `build-image.sh` refuses the ISO types at the argument for the
265 + /// same reason.
266 + const fn script(self) -> &'static str {
267 + match self {
268 + Artifact::Iso => "build/build-iso.sh",
269 + Artifact::Raw | Artifact::Qcow2 => "build/build-image.sh",
270 + }
271 + }
272 +
273 + fn parse(value: &str) -> Option<Self> {
274 + Self::ALL.into_iter().find(|a| a.value() == value)
275 + }
276 + }
277 +
278 + /// Everything the builder decides.
279 + #[derive(Debug, Clone)]
280 + pub(crate) struct Choices {
281 + pub(crate) profile: Profile,
282 + pub(crate) browser: Browser,
283 + pub(crate) langs: BTreeSet<Lang>,
284 + pub(crate) hostname: String,
285 + /// Path to a public key on the building machine, not the key itself.
286 + ///
287 + /// The path is what the user picks and the bytes are read at build time,
288 + /// which keeps the record portable: a saved `build.toml` that embedded a
289 + /// key would be a different file on every machine for no reason. It is a
290 + /// *public* key, so nothing here is a secret either way — see the module
291 + /// docs on why that property is load-bearing.
292 + pub(crate) pubkey: String,
293 + pub(crate) artifact: Artifact,
294 + }
295 +
296 + impl Default for Choices {
297 + fn default() -> Self {
298 + Self {
299 + profile: Profile::default(),
300 + browser: Browser::default(),
301 + // Rust on by default: the build-host role is a hard requirement
302 + // (sandod refuses to compile anywhere but its configured host), and
303 + // an image that silently stopped being able to build Rust would
304 + // break Sando rather than merely save disk.
305 + langs: BTreeSet::from([Lang::Rust]),
306 + hostname: String::new(),
307 + pubkey: String::new(),
308 + artifact: Artifact::default(),
309 + }
310 + }
311 + }
312 +
313 + impl Choices {
314 + /// The `--build-arg` pairs, in a fixed order so the displayed command is
315 + /// stable between frames and between runs.
316 + ///
317 + /// `LANGS` is a comma-joined list rather than one ARG per language: the
318 + /// Containerfile validates the whole list against its own curated set, so
319 + /// the gate lives there rather than in the number of arguments. That is
320 + /// the same layering `PROFILE` uses, and it means the build refuses an
321 + /// unknown language even if this screen is bypassed entirely.
322 + pub(crate) fn build_args(&self) -> Vec<(String, String)> {
323 + let mut args = vec![
324 + ("PROFILE".to_string(), self.profile.value().to_string()),
325 + ("BROWSER".to_string(), self.browser.value().to_string()),
326 + (
327 + "LANGS".to_string(),
328 + self.langs
329 + .iter()
330 + .map(|lang| lang.value())
331 + .collect::<Vec<_>>()
332 + .join(","),
333 + ),
334 + ];
335 + if !self.hostname.is_empty() {
336 + args.push(("ALLOY_HOSTNAME".to_string(), self.hostname.clone()));
337 + }
338 + // The KEY, not the path to it. `pubkey` is a path on the building
339 + // machine and the build container cannot see it: the file is not in
340 + // the build context, and putting it there would mean writing a key
341 + // into the repo. So the bytes are read here and travel as the value,
342 + // which is also why the record on disk keeps the path instead — a
343 + // saved `build.toml` holding an embedded key would be a different
344 + // file on every machine for no reason.
345 + //
346 + // Unreadable is silently omitted rather than guessed at, because
347 + // `blockers` is the gate and has already refused to start a build
348 + // whose key cannot be read. Passing the path through as if it were a
349 + // key would make the Containerfile's own validation reject it, which
350 + // is a confusing way to learn the file is missing.
351 + if !self.pubkey.is_empty()
352 + && let Ok(contents) = std::fs::read_to_string(&self.pubkey)
353 + {
354 + let key = contents.trim();
355 + if !key.is_empty() {
356 + args.push(("ALLOY_SSH_KEY".to_string(), key.to_string()));
357 + }
358 + }
359 + args
360 + }
361 +
362 + /// The command that builds this.
363 + ///
364 + /// One `--build-arg` per pair rather than a packed string, so the log pane
365 + /// shows exactly what podman will receive and the line is copy-pasteable.
366 + pub(crate) fn invocation(&self) -> Invocation {
367 + let mut invocation = Invocation::new(self.artifact.script());
368 +
369 + // The disk-image script needs a type; the ISO script builds one thing
370 + // and takes no type at all, and passing one would be an error rather
371 + // than a no-op.
372 + if self.artifact != Artifact::Iso {
373 + invocation = invocation.args(["--type", self.artifact.value()]);
374 + }
375 +
376 + for (key, value) in self.build_args() {
377 + invocation = invocation.arg("--build-arg").arg(format!("{key}={value}"));
378 + }
379 + invocation
380 + }
381 +
382 + /// Reasons this cannot be built, in the order a user would hit them.
383 + ///
384 + /// Returned rather than rendered so the summary and the build gate read
385 + /// the same list. An empty vector is the only thing that starts a build.
386 + pub(crate) fn blockers(&self, repo: Option<&Path>) -> Vec<String> {
387 + let mut blockers = Vec::new();
388 +
389 + match repo {
390 + None => blockers.push(
391 + "no Alloy checkout here: run this from a clone, or pass one with --repo. \
392 + The builder drives build/build-iso.sh from the source tree."
393 + .to_string(),
394 + ),
395 + Some(root) => {
396 + let script = root.join(self.artifact.script());
397 + if !script.is_file() {
398 + blockers.push(format!("{} is not in this checkout", script.display()));
399 + }
400 + }
401 + }
402 +
403 + if !self.hostname.is_empty() && !valid_hostname(&self.hostname) {
404 + blockers.push(format!(
405 + "`{}` is not a hostname: letters, digits and hyphens, not starting or ending with one, 63 characters or fewer",
406 + self.hostname
407 + ));
408 + }
409 +
410 + if !self.pubkey.is_empty() {
411 + let path = PathBuf::from(&self.pubkey);
412 + if !path.is_file() {
413 + blockers.push(format!("no public key at {}", path.display()));
414 + } else if std::fs::read_to_string(&path)
415 + .is_ok_and(|contents| !looks_like_pubkey(&contents))
416 + {
417 + blockers.push(format!(
418 + "{} does not look like an SSH public key. If this is a PRIVATE key, do not \
419 + bake it in: the artifact is meant to be copyable without care.",
420 + path.display()
421 + ));
422 + }
423 + }
424 +
425 + if self.profile == Profile::Server && self.browser != Browser::None {
426 + blockers.push(
427 + "the server profile ships no graphical session, so it cannot carry a browser"
428 + .to_string(),
429 + );
430 + }
431 +
432 + blockers
433 + }
434 +
435 + /// The record, as TOML.
436 + ///
437 + /// Hand-written rather than serialized, for one reason: the file is read
438 + /// by a person deciding whether to rebuild, so it carries the commentary
439 + /// that makes it answerable. A derived `Serialize` would emit the same
440 + /// keys and none of the why.
441 + pub(crate) fn to_toml(&self) -> String {
442 + let mut out = String::new();
443 + out.push_str("# Alloy build record — the choices that made this image.\n");
444 + out.push_str("# Written by `alloy image`, and read back by it so a rebuild starts\n");
445 + out.push_str("# from what this machine already is rather than from defaults.\n");
446 + out.push_str("#\n");
447 + out.push_str("# This is the CHOICES half. It is not a lockfile: it does not pin the\n");
448 + out.push_str("# resolved RPM set, so rebuilding from it gives you the same decisions\n");
449 + out.push_str("# against today's packages, not the same image. See wiki\n");
450 + out.push_str("# `alloy-distribution` for why the resolutions half is still open.\n\n");
451 +
452 + let _ = writeln!(out, "profile = {:?}", self.profile.value());
453 + let _ = writeln!(out, "browser = {:?}", self.browser.value());
454 + let langs: Vec<String> = self
455 + .langs
456 + .iter()
457 + .map(|lang| format!("{:?}", lang.value()))
458 + .collect();
459 + let _ = writeln!(out, "langs = [{}]", langs.join(", "));
460 + let _ = writeln!(out, "artifact = {:?}", self.artifact.value());
461 + let _ = writeln!(out, "hostname = {:?}", self.hostname);
462 + let _ = writeln!(out, "pubkey = {:?}", self.pubkey);
463 + out
464 + }
465 +
466 + /// Parse a record back.
467 + ///
468 + /// An unknown value for an enum is an error rather than a silent fall back
469 + /// to the default. A record naming a browser this build does not offer is
470 + /// a record from a different version of Alloy, and quietly building
471 + /// something else is how a user ends up with an image they did not ask
472 + /// for and cannot explain.
473 + pub(crate) fn from_toml(raw: &str) -> Result<Self> {
474 + let doc: toml::Value = toml::from_str(raw).context("the build record is not valid TOML")?;
475 +
476 + let word = |key: &str| -> Option<&str> { doc.get(key).and_then(toml::Value::as_str) };
477 +
478 + let mut choices = Self::default();
479 +
480 + if let Some(value) = word("profile") {
481 + choices.profile =
482 + Profile::parse(value).with_context(|| format!("unknown profile `{value}`"))?;
483 + }
484 + if let Some(value) = word("browser") {
485 + choices.browser =
486 + Browser::parse(value).with_context(|| format!("unknown browser `{value}`"))?;
487 + }
488 + if let Some(value) = word("artifact") {
489 + choices.artifact =
490 + Artifact::parse(value).with_context(|| format!("unknown artifact `{value}`"))?;
491 + }
492 + if let Some(value) = word("hostname") {
493 + choices.hostname = value.to_string();
494 + }
495 + if let Some(value) = word("pubkey") {
496 + choices.pubkey = value.to_string();
497 + }
498 + if let Some(values) = doc.get("langs").and_then(toml::Value::as_array) {
499 + let mut langs = BTreeSet::new();
500 + for value in values {
Lines truncated
@@ -1,0 +1,221 @@
1 + //! Which image this is, and which verbs that leaves.
2 + //!
3 + //! The build writes `client` or `server` to [`MARKER`] (Containerfile, the
4 + //! profile block). This reads it back so `alloy --help` offers only the verbs
5 + //! that can do something on this machine: `alloy display` drives swaymsg and
6 + //! `alloy status --bar` writes swaybar's protocol, and a `server` image has
7 + //! neither a compositor nor a bar.
8 + //!
9 + //! # Why a marker and not a probe
10 + //!
11 + //! Max, 2026-08-01, choosing "hide the verbs entirely" over reporting the
12 + //! absence. Hiding has to be decided before `--help` renders, and a probe
13 + //! answers the wrong question at that moment: "is a compositor running right
14 + //! now" is false on a client machine sitting at the greeter, or in a TTY, or
15 + //! over SSH into a laptop. That would make a verb appear and disappear on one
16 + //! machine depending on where it was typed, which is worse than either
17 + //! consistent answer.
18 + //!
19 + //! The image already knows what it is. Reading what the build recorded is the
20 + //! only source that is stable for the life of the machine.
21 + //!
22 + //! # Absent means client
23 + //!
24 + //! A `cargo run` in a checkout has no marker, and so does anything built
25 + //! before this landed. Defaulting to `client` keeps every existing invocation
26 + //! working and keeps the failure direction right: an offered verb that reports
27 + //! no compositor is a worse outcome than a hidden one, but a *hidden* verb on
28 + //! a machine that could have run it is unexplainable. So absence offers
29 + //! everything.
30 + //!
31 + //! # Hidden is not disabled
32 + //!
33 + //! A verb dropped from `--help` still exists, because clap resolves it either
34 + //! way and a hidden-but-silently-working command is the sort of thing that
35 + //! makes documentation wrong. Invoking one on `server` says which profile this
36 + //! is and why the verb is not for it, which is the same "a front that did not
37 + //! answer" rule `alloy settings` applies to a row whose tool is missing.
38 +
39 + use std::fmt;
40 +
41 + /// Where the build records the profile. `/usr/lib` rather than `/etc`: it
42 + /// describes the image, not the machine's configuration, and nothing should
43 + /// edit it after the build.
44 + pub(crate) const MARKER: &str = "/usr/lib/alloy/profile";
45 +
46 + /// The verbs a `server` image does not offer.
47 + ///
48 + /// Named here rather than derived from anything, because the list is a
49 + /// judgment about what needs a compositor and there is nothing in the argument
50 + /// parser that knows that.
51 + pub(crate) const HEADLESS_HIDES: [&str; 2] = ["display", "status"];
52 +
53 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54 + pub(crate) enum Profile {
55 + #[default]
56 + Client,
57 + Server,
58 + }
59 +
60 + impl Profile {
61 + /// What this machine is, from the marker the build wrote.
62 + ///
63 + /// An unreadable or unrecognized marker reads as `Client`, for the reason
64 + /// in the module docs: offering a verb that then explains itself beats
65 + /// hiding one that would have worked.
66 + pub(crate) fn current() -> Self {
67 + Self::parse(std::fs::read_to_string(MARKER).ok().as_deref())
68 + }
69 +
70 + /// Split out from [`current`](Self::current) so the mapping is testable
71 + /// without a filesystem.
72 + pub(crate) fn parse(marker: Option<&str>) -> Self {
73 + match marker.map(str::trim) {
74 + Some("server") => Profile::Server,
75 + _ => Profile::Client,
76 + }
77 + }
78 +
79 + /// Whether this profile has a graphical session at all.
80 + pub(crate) const fn graphical(self) -> bool {
81 + matches!(self, Profile::Client)
82 + }
83 +
84 + pub(crate) const fn as_str(self) -> &'static str {
85 + match self {
86 + Profile::Client => "client",
87 + Profile::Server => "server",
88 + }
89 + }
90 + }
91 +
92 + impl fmt::Display for Profile {
93 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 + f.write_str(self.as_str())
95 + }
96 + }
97 +
98 + /// Hide the verbs this profile cannot carry.
99 + ///
100 + /// Takes and returns the command rather than reading the marker itself, so the
101 + /// mapping from profile to visible verbs is testable without a filesystem —
102 + /// which matters, because "which verbs exist" is the whole of what was ruled
103 + /// on and a wrong answer is invisible until someone runs `--help` on the one
104 + /// machine that has the other profile.
105 + pub(crate) fn hide_unavailable(mut command: clap::Command, profile: Profile) -> clap::Command {
106 + if profile.graphical() {
107 + return command;
108 + }
109 + for verb in HEADLESS_HIDES {
110 + command = command.mut_subcommand(verb, |sub| sub.hide(true));
111 + }
112 + command
113 + }
114 +
115 + /// Whether this verb is one the profile does not carry.
116 + ///
117 + /// Paired with [`hide_unavailable`] so the two cannot drift: hiding a verb
118 + /// from `--help` while still running it is the failure this exists to prevent.
119 + pub(crate) fn is_unavailable(verb: &str, profile: Profile) -> bool {
120 + !profile.graphical() && HEADLESS_HIDES.contains(&verb)
121 + }
122 +
123 + /// What to print when a verb is invoked on a profile that does not carry it.
124 + ///
125 + /// Names the profile rather than the symptom. "no compositor" invites the user
126 + /// to go looking for one; "this is a server image" is the fact that explains
127 + /// every other verb they are about to find missing.
128 + pub(crate) fn unavailable(verb: &str) -> String {
129 + format!(
130 + "`alloy {verb}` needs a graphical session, and this is a server image \
131 + ({MARKER} says server). The verb is not offered on this profile."
132 + )
133 + }
134 +
135 + #[cfg(test)]
136 + mod tests {
137 + use super::*;
138 +
139 + #[test]
140 + fn the_marker_decides() {
141 + assert_eq!(Profile::parse(Some("server")), Profile::Server);
142 + assert_eq!(Profile::parse(Some("client")), Profile::Client);
143 + }
144 +
145 + /// Trailing newline is what `printf '%s\n'` writes, which is what the
146 + /// Containerfile uses.
147 + #[test]
148 + fn a_trailing_newline_is_not_a_different_profile() {
149 + assert_eq!(Profile::parse(Some("server\n")), Profile::Server);
150 + }
151 +
152 + /// The failure direction. Anything unreadable offers every verb, because a
153 + /// hidden verb on a machine that could run it is the unexplainable case.
154 + #[test]
155 + fn anything_unrecognized_is_a_client() {
156 + assert_eq!(Profile::parse(None), Profile::Client);
157 + assert_eq!(Profile::parse(Some("")), Profile::Client);
158 + assert_eq!(Profile::parse(Some("desktop")), Profile::Client);
159 + assert_eq!(Profile::parse(Some("SERVER")), Profile::Client);
160 + }
161 +
162 + #[test]
163 + fn only_the_client_is_graphical() {
164 + assert!(Profile::Client.graphical());
165 + assert!(!Profile::Server.graphical());
166 + }
167 +
168 + /// The message names the profile, not the symptom, and says where the
169 + /// answer came from so it can be checked.
170 + #[test]
171 + fn the_unavailable_message_explains_rather_than_reports() {
172 + let message = unavailable("display");
173 + assert!(message.contains("server image"));
174 + assert!(message.contains(MARKER));
175 + }
176 +
177 + /// A stand-in for the real CLI, so this tests the mapping rather than the
178 + /// verb roster. Both the hidden verbs and one that must survive.
179 + fn command() -> clap::Command {
180 + clap::Command::new("alloy")
181 + .subcommand(clap::Command::new("display"))
182 + .subcommand(clap::Command::new("status"))
183 + .subcommand(clap::Command::new("net"))
184 + }
185 +
186 + fn visible(profile: Profile) -> Vec<String> {
187 + hide_unavailable(command(), profile)
188 + .get_subcommands()
189 + .filter(|sub| !sub.is_hide_set())
190 + .map(|sub| sub.get_name().to_string())
191 + .collect()
192 + }
193 +
194 + #[test]
195 + fn a_client_is_offered_everything() {
196 + assert_eq!(visible(Profile::Client), ["display", "status", "net"]);
197 + }
198 +
199 + /// The ruling: hidden entirely, not reported as absent.
200 + #[test]
201 + fn a_server_is_not_offered_the_graphical_verbs() {
202 + assert_eq!(visible(Profile::Server), ["net"]);
203 + }
204 +
205 + /// Hiding and refusing have to agree. A verb dropped from `--help` that
206 + /// still ran would make the documentation wrong in the one direction
207 + /// nobody checks.
208 + #[test]
209 + fn what_is_hidden_is_exactly_what_is_refused() {
210 + for profile in [Profile::Client, Profile::Server] {
211 + let shown = visible(profile);
212 + for verb in ["display", "status", "net"] {
213 + assert_eq!(
214 + !shown.iter().any(|s| s == verb),
215 + is_unavailable(verb, profile),
216 + "`{verb}` on {profile}: hidden and refused disagree",
217 + );
218 + }
219 + }
220 + }
221 + }
@@ -1,0 +1,243 @@
1 + //! The build record is written by shell and read by Rust, so the two are
2 + //! checked against each other rather than against each other's authors.
3 + //!
4 + //! `alloy image` records its choices into the image it builds, and reads them
5 + //! back on the next machine so a rebuild starts from what that machine already
6 + //! is (wiki `alloy-distribution`: "choices recorded into the built image, read
7 + //! back from the previous one"). The writing half is a `RUN` block of `echo`
8 + //! lines in the Containerfile; the reading half is `Choices::from_toml`. Those
9 + //! are different languages in different files, and nothing but this connects
10 + //! them.
11 + //!
12 + //! The drift is silent in the direction that matters. A record the parser
13 + //! cannot read does not fail a build or an install — it fails a *read*, and
14 + //! `Choices::load` falls back to defaults, so the symptom is a builder that
15 + //! quietly forgets the machine's configuration. That is exactly the class of
16 + //! failure the Containerfile's other assertions exist to catch, arrived at
17 + //! from the one direction the build itself cannot check.
18 + //!
19 + //! So this extracts the document the Containerfile actually emits, for every
20 + //! combination of ARGs that matters, and parses it with the console's own
21 + //! parser.
22 +
23 + use std::path::PathBuf;
24 + use std::process::Command;
25 +
26 + fn repo_root() -> PathBuf {
27 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
28 + }
29 +
30 + /// The record-writing `RUN` block, as a shell script.
31 + ///
32 + /// Pulled out of the Containerfile rather than duplicated here, which is the
33 + /// whole point: a copy would pass this test forever while the real block
34 + /// changed underneath it. Continuations are joined and comment lines dropped,
35 + /// the way the image parser does it before the shell ever sees the text.
36 + fn writer_script() -> String {
37 + let path = repo_root().join("Containerfile");
38 + let text = std::fs::read_to_string(&path)
39 + .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()));
40 +
41 + let mut instruction: Option<String> = None;
42 + for raw in text.lines() {
43 + let line = raw.trim_end();
44 + let trimmed = line.trim_start();
45 + if trimmed.starts_with('#') {
46 + continue;
47 + }
48 +
49 + match &mut instruction {
50 + None => {
51 + let Some(body) = trimmed.strip_prefix("RUN ") else {
52 + continue;
53 + };
54 + instruction = Some(body.to_string());
55 + }
56 + Some(current) => {
57 + current.push(' ');
58 + current.push_str(trimmed);
59 + }
60 + }
61 +
62 + if line.ends_with('\\') {
63 + let current = instruction.as_mut().expect("inside an instruction");
64 + current.pop();
65 + continue;
66 + }
67 +
68 + let finished = instruction.take().expect("just built one");
69 + if finished.contains("/usr/lib/alloy/build.toml") {
70 + return finished;
71 + }
72 + }
73 +
74 + panic!("no RUN block in the Containerfile writes /usr/lib/alloy/build.toml");
75 + }
76 +
77 + /// Run the extracted block with the given ARGs and return what it wrote.
78 + ///
79 + /// The block writes to an absolute path, so it runs against a redirected root:
80 + /// `HOME` is not enough because the path is `/usr/lib/alloy`. Rewriting the
81 + /// path for the test rather than mocking it keeps the script byte-identical to
82 + /// the one in the image apart from its prefix.
83 + fn record_for(profile: &str, browser: &str, langs: &str, hostname: &str) -> String {
84 + // Unique per call, not per set of arguments. Tests run concurrently and
85 + // three of them ask for the same combination, so a name derived from the
86 + // arguments had two threads sharing one directory and removing it from
87 + // under each other.
88 + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
89 + let dir = std::env::temp_dir().join(format!(
90 + "alloy-build-record-{}-{}",
91 + std::process::id(),
92 + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
93 + ));
94 + let _ = std::fs::remove_dir_all(&dir);
95 + std::fs::create_dir_all(&dir).expect("scratch dir");
96 +
97 + let script = writer_script().replace(
98 + "/usr/lib/alloy",
99 + &dir.join("usr/lib/alloy").to_string_lossy(),
100 + );
101 +
102 + let output = Command::new("sh")
103 + .arg("-c")
104 + .arg(&script)
105 + .env("PROFILE", profile)
106 + .env("BROWSER", browser)
107 + .env("LANGS", langs)
108 + .env("ALLOY_HOSTNAME", hostname)
109 + .env("ALLOY_SSH_KEY", "")
110 + .output()
111 + .expect("running the extracted writer");
112 +
113 + assert!(
114 + output.status.success(),
115 + "the record writer failed for profile={profile} browser={browser} langs={langs}:\n{}",
116 + String::from_utf8_lossy(&output.stderr),
117 + );
118 +
119 + let written = std::fs::read_to_string(dir.join("usr/lib/alloy/build.toml"))
120 + .expect("the writer produced no file");
121 + let _ = std::fs::remove_dir_all(&dir);
122 + written
123 + }
124 +
125 + /// The contract, over the combinations that differ structurally: both
126 + /// profiles, every browser, an empty and a multi-entry language list, and a
127 + /// hostname both set and unset.
128 + #[test]
129 + fn every_record_the_build_can_write_parses() {
130 + let cases = [
131 + ("client", "helium", "rust", "bench"),
132 + ("client", "firefox", "rust,go,python,zig", ""),
133 + ("client", "none", "", "laptop"),
134 + ("server", "none", "rust", "build-host-2"),
135 + ("server", "none", "", ""),
136 + ];
137 +
138 + for (profile, browser, langs, hostname) in cases {
139 + let record = record_for(profile, browser, langs, hostname);
140 +
141 + let parsed = alloy_build_record::parse(&record).unwrap_or_else(|err| {
142 + panic!(
143 + "the console cannot read the record the build writes for \
144 + profile={profile} browser={browser} langs={langs:?}: {err}\n\n{record}"
145 + );
146 + });
147 +
148 + assert_eq!(parsed.profile, profile, "profile round trip\n{record}");
149 + assert_eq!(parsed.browser, browser, "browser round trip\n{record}");
150 + assert_eq!(parsed.hostname, hostname, "hostname round trip\n{record}");
151 +
152 + let expected: Vec<&str> = if langs.is_empty() {
153 + Vec::new()
154 + } else {
155 + langs.split(',').collect()
156 + };
157 + assert_eq!(parsed.langs, expected, "langs round trip\n{record}");
158 + }
159 + }
160 +
161 + /// An empty language list has to be `[]` and not `[""]`, which is the shape
162 + /// the shell's `${langs#, }` trim produces if the loop body runs once on an
163 + /// empty string. It parses either way as TOML; only one of them is true.
164 + #[test]
165 + fn no_languages_is_an_empty_list_rather_than_one_empty_entry() {
166 + let record = record_for("server", "none", "", "");
167 + assert!(
168 + record.contains("langs = []"),
169 + "an empty LANGS must write an empty array:\n{record}"
170 + );
171 + }
172 +
173 + /// The record must say what it is not. A file called a record invites being
174 + /// read as a lockfile, and the difference is the whole of why rebuilding from
175 + /// it reproduces decisions rather than an image.
176 + #[test]
177 + fn the_record_disclaims_being_a_lockfile() {
178 + let record = record_for("client", "helium", "rust", "bench");
179 + assert!(
180 + record.contains("not a lockfile"),
181 + "the record must say it pins choices and not resolutions:\n{record}"
182 + );
183 + }
184 +
185 + /// `pubkey` is a path on whichever machine ran the builder, so it means
186 + /// nothing in the image. Empty rather than absent, so the document has one
187 + /// shape and nobody reads a missing key as a key that was lost.
188 + #[test]
189 + fn the_image_record_carries_no_pubkey_path() {
190 + let record = record_for("client", "helium", "rust", "bench");
191 + assert!(record.contains("pubkey = \"\""), "{record}");
192 + }
193 +
194 + /// A minimal stand-in for the console's parser.
195 + ///
196 + /// The real one is `crate::image::Choices::from_toml`, which is `pub(crate)`
197 + /// in a binary and therefore unreachable from an integration test. Rather
198 + /// than widen its visibility for a test — which would make an internal type
199 + /// part of the surface for no other reason — this asserts the same things
200 + /// through the same TOML crate at the same version, which is what the parse
201 + /// actually rests on. The enum-validation half is unit-tested next to the
202 + /// real parser in `image.rs`.
203 + mod alloy_build_record {
204 + pub(crate) struct Record {
205 + pub(crate) profile: String,
206 + pub(crate) browser: String,
207 + pub(crate) langs: Vec<String>,
208 + pub(crate) hostname: String,
209 + }
210 +
211 + pub(crate) fn parse(raw: &str) -> Result<Record, String> {
212 + let doc: toml::Value = toml::from_str(raw).map_err(|err| err.to_string())?;
213 +
214 + let word = |key: &str| -> Result<String, String> {
215 + doc.get(key)
216 + .ok_or_else(|| format!("no `{key}` in the record"))?
217 + .as_str()
218 + .ok_or_else(|| format!("`{key}` is not a string"))
219 + .map(str::to_string)
220 + };
221 +
222 + let langs = doc
223 + .get("langs")
224 + .ok_or("no `langs` in the record")?
225 + .as_array()
226 + .ok_or("`langs` is not an array")?
227 + .iter()
228 + .map(|value| {
229 + value
230 + .as_str()
231 + .map(str::to_string)
232 + .ok_or_else(|| "a language is not a string".to_string())
233 + })
234 + .collect::<Result<Vec<String>, String>>()?;
235 +
236 + Ok(Record {
237 + profile: word("profile")?,
238 + browser: word("browser")?,
239 + langs,
240 + hostname: word("hostname")?,
241 + })
242 + }
243 + }
@@ -1,0 +1,177 @@
1 + //! The rule that makes `ARG PROFILE` affordable: no conditional skips a check.
2 + //!
3 + //! The Containerfile's correctness mechanism is that it asserts at build time
4 + //! what a config file merely assumes — that the session wrapper parses, that
5 + //! the polkit grant names actions that exist, that alloy-shot's tools are
6 + //! really in the image. Every one of those checks exists because its absence
7 + //! had already caused a silent failure.
8 + //!
9 + //! Splitting the image into `client` and `server` profiles put those checks
10 + //! behind `if [ "$PROFILE" = client ]`, and that is exactly the move the
11 + //! design brief (wiki `alloy-server-variant`) rejected the ARG shape for: a
12 + //! conditional assertion is one that can be skipped without a word, which is
13 + //! the failure mode the assertions were written to catch, reintroduced one
14 + //! level up.
15 + //!
16 + //! The answer is not to avoid conditionals but to forbid the silent half of
17 + //! one. **Every `$PROFILE` conditional asserts on both branches**: where
18 + //! `client` proves a thing is present and correct, `server` proves it is
19 + //! absent. No path through any of them verifies nothing. This file is what
20 + //! keeps that true, because it is a discipline nobody would notice breaking.
21 + //!
22 + //! It is a text check over the Containerfile, in the same spirit as
23 + //! `polkit_rules.rs` and `session_entry.rs`: cheap, runs on every `cargo
24 + //! test`, and catches the edit that a build would only catch on the profile
25 + //! nobody built that day.
26 +
27 + use std::path::PathBuf;
28 +
29 + fn containerfile() -> String {
30 + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../Containerfile");
31 + std::fs::read_to_string(&path)
32 + .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()))
33 + }
34 +
35 + /// Instructions, with line continuations joined and comments dropped.
36 + ///
37 + /// The Containerfile parser strips a `#` line inside a continuation before the
38 + /// shell ever sees it, so this has to do the same or the `else` it is looking
39 + /// for could be one that only exists in a comment.
40 + fn instructions(text: &str) -> Vec<String> {
41 + let mut out: Vec<String> = Vec::new();
42 + let mut current = String::new();
43 +
44 + for raw in text.lines() {
45 + let line = raw.trim_end();
46 + let trimmed = line.trim_start();
47 +
48 + // A comment line inside a continuation is removed by the parser and
49 + // does not end the continuation.
50 + if trimmed.starts_with('#') {
51 + continue;
52 + }
53 +
54 + if current.is_empty() && trimmed.is_empty() {
55 + continue;
56 + }
57 +
58 + if let Some(head) = line.strip_suffix('\\') {
59 + current.push_str(head.trim_start());
60 + current.push(' ');
61 + continue;
62 + }
63 +
64 + current.push_str(trimmed);
65 + if !current.trim().is_empty() {
66 + out.push(current.trim().to_string());
67 + }
68 + current = String::new();
69 + }
70 +
71 + if !current.trim().is_empty() {
72 + out.push(current.trim().to_string());
73 + }
74 + out
75 + }
76 +
77 + /// The rule. A `$PROFILE` test with no `else` is a check that silently does
78 + /// nothing on the other profile.
79 + #[test]
80 + fn every_profile_conditional_asserts_on_both_branches() {
81 + let text = containerfile();
82 + let offenders: Vec<String> = instructions(&text)
83 + .into_iter()
84 + .filter(|instruction| instruction.contains("$PROFILE\" ="))
85 + .filter(|instruction| !instruction.contains("else"))
86 + .collect();
87 +
88 + let listed = offenders.iter().fold(String::new(), |mut out, offender| {
89 + out.push_str(" ");
90 + out.push_str(&offender[..offender.len().min(160)]);
91 + out.push('\n');
92 + out
93 + });
94 +
95 + assert!(
96 + offenders.is_empty(),
97 + "these $PROFILE conditionals have no else branch, so one profile skips the check entirely:\n{listed}",
98 + );
99 + }
100 +
101 + /// The conditionals are only trustworthy if the value they test is. An
102 + /// unvalidated `$PROFILE` means a typo falls through to `else` and builds
103 + /// something nobody asked for, on every one of those blocks at once.
104 + #[test]
105 + fn the_profile_value_is_validated_before_anything_reads_it() {
106 + let text = containerfile();
107 + let instructions = instructions(&text);
108 +
109 + let validator = instructions
110 + .iter()
111 + .position(|i| i.contains("unknown PROFILE"))
112 + .expect("no assertion rejecting an unknown PROFILE value");
113 +
114 + let first_use = instructions
115 + .iter()
116 + .position(|i| i.contains("$PROFILE\" ="))
117 + .expect("nothing reads $PROFILE");
118 +
119 + assert!(
120 + validator < first_use,
121 + "the PROFILE validity check runs at instruction {validator}, after the first \
122 + conditional at {first_use}; a typo would reach a branch before being rejected",
123 + );
124 +
125 + // Both spellings have to be accepted, or one profile is unbuildable and
126 + // the validator is what would say so.
127 + let validating = &instructions[validator];
128 + for profile in ["client", "server"] {
129 + assert!(
130 + validating.contains(profile),
131 + "the PROFILE validator does not accept `{profile}`",
132 + );
133 + }
134 + }
135 +
136 + /// `ARG PROFILE` has to default, or every existing `podman build` invocation
137 + /// and both build scripts start producing something other than what they did.
138 + #[test]
139 + fn the_profile_defaults_to_client() {
140 + let text = containerfile();
141 + assert!(
142 + text.contains("ARG PROFILE=client"),
143 + "PROFILE must default to client so an unqualified build is the desktop, as it always was",
144 + );
145 + }
146 +
147 + /// The console reads this to decide whether to offer the verbs that need a
148 + /// compositor, so the build has to write it.
149 + #[test]
150 + fn the_image_records_its_own_profile() {
151 + let text = containerfile();
152 + assert!(
153 + text.contains("/usr/lib/alloy/profile"),
154 + "the build does not record the profile where the console can read it",
155 + );
156 + }
157 +
158 + /// The one thing the brief insists survives on a headless machine: the bare
159 + /// console palette is the only themed surface there, so a prune that took it
160 + /// would take the whole design system with it.
161 + #[test]
162 + fn the_server_prune_keeps_the_console_palette() {
163 + let text = containerfile();
164 + let pruning = instructions(&text)
165 + .into_iter()
166 + .find(|i| i.contains("/etc/skel/.config/sway") && i.contains("rm -rf"))
167 + .expect("no server prune of the desktop skeleton");
168 +
169 + assert!(
170 + pruning.contains("test -s /usr/share/alloy/vtrgb"),
171 + "the server prune does not assert the console palette survived it",
172 + );
173 + assert!(
174 + !pruning.contains("rm -rf /usr/share/alloy/vtrgb"),
175 + "the server prune removes the console palette",
176 + );
177 + }