Skip to main content

max / alloy

Stop the server profile carrying fonts, and let a build seed its font bases Two things, both in the font half of the image build. PROFILE=server installed both house faces, fontconfig and a font cache that nothing on a headless box can draw: no compositor, no GTK, no terminal, and a TUI reached over ssh is rasterised by the client's font stack. The assertion that kept them there failed the build if the faces were absent, reasoning that the layers are unconditional and the profile has a console. That reasoning was wrong about where a TUI's glyphs come from. The font copy and the cache are client-only now, and the server branch proves absence, which is the pattern every other profile conditional in the file already follows. The Atkinson bases were fetched from raw.githubusercontent.com on every clean build, and a 429 there failed the whole build after the expensive layers had run. Build-it-yourself is Alloy's only install path, so that made the install path depend on somebody else's rate limiter. QUASI_BASES=sealed plus build/base-cache/ lets a build take pinned bases from the checkout instead. What this does not cover: the fetch inside shop's own build script, which runs above the seed and does its own cut. And the durable half, an archived copy of the bases on infrastructure we own, is a hosting decision and is not taken here. No image was built; every new guard is exercised against a fake root.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-19 17:00 UTC
Signed with PGP, not checked
Commit: 3df81c19b0253b95ea091e296a1cf75afcb13955
Parent: d3c7c95
9 files changed, +940 insertions, -35 deletions
M .containerignore +16 -3
@@ -44,13 +44,26 @@
44 44 # Not read by any COPY.
45 45 /.git
46 46 # Excluded except the few files a COPY reads: the script
47 - # build/Containerfile.iso copies in, and the two RPM specs the rust-build stage
48 - # packages the console and the terminal with. The negations have to follow the
49 - # exclusion; reversed, they do nothing.
47 + # build/Containerfile.iso copies in, the two RPM specs the rust-build stage
48 + # packages the console and the terminal with, and the seeded font bases. The
49 + # negations have to follow the exclusion; reversed, they do nothing.
50 + #
51 + # base-cache/ normally holds one README, so it costs the context nothing. What
52 + # it costs when it is seeded is the four base files, ~180 KB, which is the price
53 + # of a build that does not depend on somebody else's rate limiter answering.
54 + #
55 + # It is negated twice on purpose. Every other negation in this file names one
56 + # file, and that shape is proven by the builds that ship; this is the first
57 + # directory, and whether a matcher re-includes a directory's CONTENTS from a
58 + # directory negation alone was not measured here, since settling it means
59 + # running a build. So the second line negates the files, which is the shape
60 + # already known to work. Delete either one only with a build to show for it.
50 61 /build
51 62 !/build/make-iso.sh
52 63 !/build/rpm/alloy.spec
53 64 !/build/rpm/shop.spec
65 + !/build/base-cache
66 + !/build/base-cache/**
54 67 /builds.disabled
55 68 /docs
56 69 /tools
M Containerfile +280 -26
@@ -225,12 +225,158 @@
225 225 #
226 226 # Above the console's source for the same reason shop is: keyed on a revision
227 227 # that changes rarely, so a console edit does not recut two fonts.
228 + #
229 + # THE PART THAT IS NOT REPRODUCIBLE IS AVAILABILITY, and it took the build down
230 + # on 2026-08-17. The pins name files on raw.githubusercontent.com, which
231 + # rate-limits by IP, and one clean build asks it three times: the two cuts below
232 + # and shop's own build script, which cuts a third face into its OUT_DIR. The
233 + # host answered 429 and the build died at `cargo install shop`, an hour in,
234 + # behind every expensive layer. A sha256 makes the bytes the same bytes; it says
235 + # nothing about whether they arrive. "Build it yourself" is Alloy's only install
236 + # path (wiki `alloy-distribution`), so a fetch that fails is an install that
237 + # cannot happen, and that makes this a property of the distribution rather than
238 + # a flaky step.
239 + #
240 + # WHAT THE SEED COVERS IS THIS BLOCK'S TWO CUTS, AND NOTHING ELSE. Say it here
241 + # rather than let a reader infer a whole-build guarantee from the word `sealed`,
242 + # because the fetch it does not cover is the one that failed. `cargo install
243 + # shop` is 85 lines above, and moving the COPY above it would change nothing:
244 + # shop's `shop-font/build.rs` calls `quasi_type::cut("quasi-mono",
245 + # &out.join("bases"), false)`, with the cache directory hardcoded to its own
246 + # OUT_DIR and `offline` hardcoded false, so there is no path and no flag for
247 + # this file to aim at a carried copy. Read from shop at rev ${SHOP_REV} on
248 + # 2026-08-19.
249 + #
250 + # THE CONTRACT SHOP WOULD HAVE TO IMPLEMENT, so that this is a state to fix and
251 + # not a shrug: read the cache directory from an environment variable, falling
252 + # back to `OUT_DIR/bases` when it is unset so nothing about a plain `cargo
253 + # build` changes, and take `offline` from a second one. Two lines in that build
254 + # script. When they exist, the COPY moves above `cargo install shop`, this
255 + # stage exports both variables, and `sealed` becomes a claim about the build
256 + # rather than about the cut. It is shop's change to make, and it is not made
257 + # here. Until it is, a build host that wants the third fetch covered warms
258 + # cargo's target directory instead, which is what shop's own build script says
259 + # it is for.
260 + #
261 + # TWO THINGS STAND AGAINST THE OTHER TWO FETCHES, and neither is the durable
262 + # fix.
263 + #
264 + # 1. The retry is upstream, in quasi-type's own fetch: curl `--retry 5
265 + # --retry-delay 2 --retry-all-errors`, which covers 429, 408, the 5xx
266 + # family and the connection failures a shared network produces. It is in
267 + # the pinned revision above, so it is what this build already gets, and it
268 + # is what shop's fetch gets too, since that is the same code.
269 + # 2. The seed below, which is what a build can carry rather than ask for.
270 + # `/base-cache` is copied from `build/base-cache/` in the context, so the
271 + # supported way to use it is to put the files there; both wrapper scripts
272 + # forward `--build-arg` and nothing else, so the `podman build -v
273 + # <dir>:/base-cache:ro` route is a bare `podman build` away from them, and
274 + # that produces an image with no build stamp (docs/IMAGE.md). Seeded files
275 + # are copied into the checkout's own `bases/cache/`, which is the directory
276 + # quasi-type reads before it fetches anything. Seeding cannot forge a base:
277 + # every cached file is verified against the pin's sha256 exactly as a
278 + # downloaded one is, so a wrong or tampered seed fails the cut rather than
279 + # shipping.
280 + #
281 + # `QUASI_BASES=sealed` turns the seed into a guarantee about the cut. It passes
282 + # `--offline`, so a base the seed does not carry fails here rather than reaching
283 + # for a host that may not answer. It does not make the build offline: the clone
284 + # above and `cargo build` below still go out, and so does shop's own fetch. Use
285 + # it on a build host with a warm cache; `fetch`, the default, is what a stranger
286 + # cloning the repo gets and still works with an empty seed.
287 + #
288 + # THE SEALED GATE READS THE PINS RATHER THAN COUNTING FILES. A seed that is
289 + # present but wrong is the likely mistake, since the cache names are long and
290 + # percent-encoded, and a count cannot tell a complete seed from one file. So the
291 + # names are derived from the checkout's own `bases/pins.toml`, which is the same
292 + # file the tool reads: base id, base version and the leaf of each pinned url,
293 + # joined the way `Face::cache_name` joins them, plus the `-LICENSE.txt` a
294 + # file-pinned base caches and the `.zip` an archive-pinned one caches instead.
295 + # Derived rather than listed here because a list would be a second copy of the
296 + # pin, and it would go stale the first time QUASI_TYPE_REV moved to a base with
297 + # a new version in its file names.
298 + #
299 + # WHAT IS STILL MISSING is a copy of the bases on infrastructure we own,
300 + # addressed by the same sha256, so that an unseeded build has somewhere to reach
301 + # that is not somebody else's rate limiter. That is a hosting decision (astra or
302 + # makenot.work) and it is not made here. It is also the only one of these that
303 + # would cover shop's fetch without shop changing at all.
304 + #
305 + # THE CUT IS UNCONDITIONAL, and that is a decision rather than an omission. Only
306 + # the client installs these faces (see the profile block near the end of this
307 + # file), so cutting them on `server` looks like waste. What the conditional
308 + # would buy is the two cuts on a server build, and not the third fetch: `cargo
309 + # install shop` above runs on both profiles, since both carry the terminal's
310 + # RPM, and shop's build script cuts its own face from the same Atkinson base.
311 + # What it would cost is the property that makes this stage cheap. Nothing in it
312 + # reads `$PROFILE`, so both profiles share every layer of it, including the
313 + # console build below and the RPM packaging after that.
228 314 ARG QUASI_TYPE_REV=e40330602e68fbdd2a9d2917bdec33a7ce573a1f
229 - RUN git clone --no-checkout https://makenot.work/git/max/quasi-type.git /quasi-type && \
230 - git -C /quasi-type checkout --detach "${QUASI_TYPE_REV}" && \
231 - cargo build --release --locked --manifest-path /quasi-type/Cargo.toml && \
232 - /quasi-type/target/release/quasi-type build quasi-mono --out /faces && \
233 - /quasi-type/target/release/quasi-type build quasi-body --out /faces && \
315 + ARG QUASI_BASES=fetch
316 + COPY build/base-cache/ /base-cache/
317 + RUN set -eu; \
318 + case "$QUASI_BASES" in \
319 + fetch|sealed) ;; \
320 + *) echo "unknown QUASI_BASES '$QUASI_BASES'; expected 'fetch' or 'sealed'" >&2; exit 1 ;; \
321 + esac; \
322 + slots="quasi-mono quasi-body"; \
323 + git clone --no-checkout https://makenot.work/git/max/quasi-type.git /quasi-type; \
324 + git -C /quasi-type checkout --detach "${QUASI_TYPE_REV}"; \
325 + mkdir -p /quasi-type/bases/cache; \
326 + seeded=0; \
327 + for base in /base-cache/*; do \
328 + [ -f "$base" ] || continue; \
329 + case "${base##*/}" in *.md) continue ;; esac; \
330 + cp "$base" /quasi-type/bases/cache/; \
331 + seeded=$((seeded + 1)); \
332 + done; \
333 + wanted=$(awk -v want="$slots" ' \
334 + function leaf(s, n, p) { n = split(s, p, "/"); return p[n] } \
335 + function val(v) { sub(/^[^=]*= */, "", v); gsub(/"/, "", v); return v } \
336 + $1 == "[[base]]" { nb++; sec = "base"; next } \
337 + $1 == "[[base.face]]" { sec = "face"; next } \
338 + $1 == "[[slot]]" { ns++; sec = "slot"; next } \
339 + sec == "base" && $1 == "id" { bid[nb] = val($0); next } \
340 + sec == "base" && $1 == "version" { bver[nb] = val($0); next } \
341 + sec == "base" && $1 == "url" { archive[nb] = 1; next } \
342 + sec == "base" && $1 == "license_url" { license[nb] = 1; next } \
343 + sec == "face" && $1 == "url" { faces[nb] = faces[nb] " " leaf(val($0)); next } \
344 + sec == "face" && $1 == "path" { faces[nb] = faces[nb] " " leaf(val($0)); next } \
345 + sec == "slot" && $1 == "id" { sid[ns] = val($0); next } \
346 + sec == "slot" && $1 == "base" { sbase[ns] = val($0); next } \
347 + END { \
348 + n = split(want, w, " "); \
349 + for (i = 1; i <= n; i++) { \
350 + b = 0; \
351 + for (s = 1; s <= ns; s++) if (sid[s] == w[i]) for (k = 1; k <= nb; k++) if (bid[k] == sbase[s]) b = k; \
352 + if (b == 0) { print "unknown-slot:" w[i]; continue } \
353 + if (archive[b]) { print bid[b] "-" bver[b] ".zip"; continue } \
354 + m = split(faces[b], f, " "); \
355 + for (j = 1; j <= m; j++) print bid[b] "-" bver[b] "-" f[j]; \
356 + if (license[b]) print bid[b] "-" bver[b] "-LICENSE.txt"; \
357 + } \
358 + }' /quasi-type/bases/pins.toml); \
359 + [ -n "$wanted" ] \
360 + || { echo "derived no base file names from the pins; either bases/pins.toml moved or its shape changed, and the sealed gate cannot check a seed it cannot name" >&2; exit 1; }; \
361 + missing=; \
362 + for name in $wanted; do \
363 + case "$name" in \
364 + unknown-slot:*) echo "the pins name no slot '${name#unknown-slot:}'; this stage cuts slots the checkout does not have" >&2; exit 1 ;; \
365 + esac; \
366 + test -f /quasi-type/bases/cache/"$name" || missing="$missing $name"; \
367 + done; \
368 + echo "quasi-type bases: seeded $seeded file(s) from /base-cache, mode $QUASI_BASES, missing:${missing:- none}"; \
369 + if [ "$QUASI_BASES" = sealed ]; then \
370 + [ -z "$missing" ] \
371 + || { echo "QUASI_BASES=sealed and the seed is short of:$missing. The cut would fail on the first of them after building the tool. Copy them into build/base-cache/ (its README says where from) or build with QUASI_BASES=fetch" >&2; exit 1; }; \
372 + offline=--offline; \
373 + else \
374 + offline=; \
375 + fi; \
376 + cargo build --release --locked --manifest-path /quasi-type/Cargo.toml; \
377 + for slot in $slots; do \
378 + /quasi-type/target/release/quasi-type build "$slot" --out /faces ${offline}; \
379 + done; \
234 380 rm -rf /quasi-type/target
235 381
236 382 # Both faces, and the licence beside them. The cut asserts its own coverage —
@@ -876,8 +1022,41 @@
876 1022 # whenever any Rust source does above the 7 GB of package installs below.
877 1023 # So the copy moved to the other `--from=rust-build` staging near the end
878 1024 # of the file, and what is left here is the one package `fc-cache` needs.
1025 + #
1026 + # CLIENT ONLY, since 2026-08-19, and the reason is where glyphs are drawn
1027 + # rather than how much they weigh. Nothing on a headless box rasterises a
1028 + # character: there is no compositor, no GTK, and no terminal, since shop is
1029 + # client-only and the server carries only its uninstalled RPM. A TUI reached
1030 + # over ssh is drawn by the CLIENT's font stack out of the client's own faces,
1031 + # so the server end of that session never opens a font file. The image agreed
1032 + # with itself on this already, since the profile prune takes
1033 + # /etc/skel/.config/fontconfig, so `HOME=/etc/skel fc-match monospace` on a
1034 + # server image answers Adwaita Mono, the base's face, and not Alloy's.
1035 + #
1036 + # Measured on the built server image before removing anything (2026-08-19):
1037 + # the only binaries that link libfontconfig are fontconfig's own fc-* tools,
1038 + # and no installed package requires it. The base has none either, so declining
1039 + # to install it here is what actually removes it rather than a line that a
1040 + # dependency quietly puts back. It takes 784 KiB of tools, 120 KiB of cache and
1041 + # the 208 KiB of faces below with it.
1042 + #
1043 + # The `else` branch is the claim from the other side, and it is a real one: it
1044 + # holds that the base still ships no fontconfig, so this line is the only thing
1045 + # that could put one on a server image. The claim that matters more, that
1046 + # nothing installed later drags it back, is asserted at the end of the file,
1047 + # after every package install, where it can be true.
879 1048 # =====================================================================
880 - RUN dnf install -y fontconfig && dnf clean all
1049 + RUN set -eu; \
1050 + if [ "$PROFILE" = client ]; then \
1051 + dnf install -y fontconfig; \
1052 + dnf clean all; \
1053 + command -v fc-cache >/dev/null \
1054 + || { echo "fontconfig is installed and fc-cache is not on PATH; the face copy below would cache nothing and every app would rescan the font directories at startup" >&2; exit 1; }; \
1055 + else \
1056 + ! command -v fc-cache >/dev/null \
1057 + || { echo "profile=server already carries fontconfig, before any line here asked for it; the base changed, so measure what wants it rather than leaving this branch asserting something untrue" >&2; exit 1; }; \
1058 + echo "fontconfig: not installed; nothing headless rasterises a glyph"; \
1059 + fi
881 1060
882 1061 # =====================================================================
883 1062 # Package additions — full Alloy stack per docs/STACK.md
@@ -3109,6 +3288,17 @@
3109 3288 # regenerating metadata would want createrepo_c in the runtime image for
3110 3289 # the sake of deleting three megabytes. A server operator who wants the
3111 3290 # terminal can install it; nothing lays it down for them.
3291 + #
3292 + # THAT INSTALL RESOLVES OVER THE NETWORK, and it is worth saying because the
3293 + # carried repo is a file:// one and reads as though it were self-contained.
3294 + # `build/rpm/shop.spec` names its dependencies by package (`fontconfig`,
3295 + # `libxkbcommon`, `libwayland-client`, `libwayland-egl`, `vulkan-loader`,
3296 + # `libglvnd-egl`), and every one of those arrives on `client` through the
3297 + # graphical stack this profile does not install. `fontconfig` joined that list
3298 + # on 2026-08-19, when the font block above went client-only. So the affordance
3299 + # is "the package is here and dnf can resolve the rest", not "this installs
3300 + # offline". Which of the six the base itself already carries was not measured:
3301 + # it needs a built server image and this change was made without one.
3112 3302 RUN if [ "$PROFILE" = client ]; then \
3113 3303 printf 'alloy\nshop\n' > /usr/share/alloy/components; \
3114 3304 grep -qx shop /usr/share/alloy/components \
@@ -3173,8 +3363,38 @@
3173 3363 #
3174 3364 # The OFL text travels with the faces, in the same directory, because OFL 1.1
3175 3365 # requires it to travel with a modified build and both of these are one.
3176 - COPY --from=rust-build /faces/ /usr/share/fonts/quasi/
3177 - RUN fc-cache -fv
3366 + #
3367 + # STAGED FIRST, THEN INSTALLED ON CLIENT ONLY. A `COPY` cannot be conditional,
3368 + # so the faces land at a path outside /usr and the `RUN` below decides what
3369 + # becomes of them: installed and cached on `client`, deleted on `server`, which
3370 + # has nothing that can draw them (the fontconfig block above holds the whole
3371 + # argument). /faces-staged is removed on both branches, because bootc lints the
3372 + # root and a leftover directory there is a finding rather than a nuisance.
3373 + #
3374 + # Neither branch asserts the removal afterwards. Under `set -eu` a `test ! -e`
3375 + # on the line after `rm -rf` can only fail if `rm` returned 0 without removing
3376 + # anything, so it would be a line that cannot fail rather than a check. What
3377 + # does hold the removal in place is `font_profile.rs`, which runs both branches
3378 + # against a fake root and looks: delete the `rm` and the test says so.
3379 + #
3380 + # What the staging costs on `server` is 208 KiB in one layer of the image
3381 + # tarball. It costs nothing in the installed system: the deployment is built
3382 + # from the merged tree, and these files are not in it.
3383 + COPY --from=rust-build /faces/ /faces-staged/
3384 + RUN set -eu; \
3385 + if [ "$PROFILE" = client ]; then \
3386 + mkdir -p /usr/share/fonts/quasi; \
3387 + cp /faces-staged/* /usr/share/fonts/quasi/; \
3388 + rm -rf /faces-staged; \
3389 + fc-cache -fv; \
3390 + [ "$(fc-list ':family=Quasi Mono' family | wc -l)" -ge 1 ] \
3391 + || { echo "the faces were copied and fontconfig cannot see Quasi Mono; the cache did not take" >&2; exit 1; }; \
3392 + else \
3393 + rm -rf /faces-staged; \
3394 + test ! -e /usr/share/fonts/quasi \
3395 + || { echo "profile=server installed the house faces; nothing here can rasterise one, and a face a machine cannot draw is payload with no reader" >&2; exit 1; }; \
3396 + echo "fonts: the house faces are staged and discarded on this profile"; \
3397 + fi
3178 3398
3179 3399 # The schemas the settings view's Applications tab is built from. Straight
3180 3400 # from the build context: they are hand-authored TOML that nothing compiles,
@@ -3274,14 +3494,44 @@
3274 3494 # was written before the split and asserts them on both. `PROFILE=server`
3275 3495 # failed here on the first server build.
3276 3496 #
3277 - # The server branch is not a skip. It asserts the same claim from the other
3278 - # side — that the coverage really is client-only — plus the two that hold
3279 - # everywhere: Alloy's own faces are installed, since the font layers are
3280 - # deliberately unconditional, and no emoji font arrived. `fc-match` is not
3281 - # asked on server: the prune takes etc/skel/.config/fontconfig with the rest of
3282 - # the skeleton, so there are no Alloy aliases left to resolve and the answer
3283 - # would be the base's opinion, which is the exact mistake the HOME=/etc/skel
3284 - # note above warns about.
3497 + # The server branch is not a skip, and since 2026-08-19 it asserts the opposite
3498 + # of what it used to. It said "Quasi Mono is missing; the font layers are
3499 + # unconditional and this profile has a console", which was wrong about where a
3500 + # TUI's glyphs come from: a session reached over ssh is rasterised by the
3501 + # CLIENT's font stack, and the server end opens no font file at all. That
3502 + # sentence was also the thing holding the payload in place, since the faces
3503 + # could not be dropped while a check demanded them. So the branch now proves
3504 + # the faces are ABSENT, which is the pattern every other `$PROFILE` conditional
3505 + # in this file follows: client proves presence, server proves absence.
3506 + #
3507 + # It proves it without fontconfig, because there is none to ask. The install is
3508 + # client-only now (see the fontconfig block above), so `fc-list` is not on this
3509 + # profile's PATH and the queries are filesystem and rpm questions instead:
3510 + # no fc-list, no /usr/share/fonts/quasi, none of the font packages the client
3511 + # block installs, no package whose name says emoji.
3512 + #
3513 + # BOTH OF THOSE PACKAGE QUERIES ARE WEAKER THAN THE CHARSET QUERY THEY REPLACE,
3514 + # and the weakness is worth stating rather than conceding on the emoji half
3515 + # alone. `fc-list ':lang=ja'` answered for coverage arriving from any source,
3516 + # including a face dropped into /usr/share/fonts by something that is not a
3517 + # package at all. A name list answers only for the three packages the client
3518 + # block installs, and a name sweep for emoji answers only for a font that says
3519 + # so in its name. What makes the list a list rather than a guess is that it is
3520 + # checked against the client's own install lines by
3521 + # `crates/alloy/tests/font_profile.rs`, so a fourth font package added up there
3522 + # fails the test rather than sliding past this branch.
3523 + #
3524 + # What would settle it properly is installing fontconfig on `server` to ask the
3525 + # charset question, and that is the payload this whole change exists to drop,
3526 + # for 784 KiB of tools to answer a question about a machine that rasterises
3527 + # nothing. Not measured here, because it cannot be: the tools are gone. The
3528 + # claim these queries do support is the one that matters, that this profile
3529 + # installs no font package at all, so the only faces on it are the base's own.
3530 + #
3531 + # `fc-match` is not asked on server either, and was not before: the prune takes
3532 + # etc/skel/.config/fontconfig with the rest of the skeleton, so there are no
3533 + # Alloy aliases left to resolve and the answer would be the base's opinion,
3534 + # which is the exact mistake the HOME=/etc/skel note above warns about.
3285 3535 RUN set -eux; \
3286 3536 if [ "$PROFILE" = client ]; then \
3287 3537 for lang in ja zh-cn zh-tw ko ar he hi th bn ta; do \
@@ -3294,17 +3544,21 @@
3294 3544 || { echo "monospace is no longer Quasi Mono; every cell grid would draw its borders from a fallback face on its own baseline" >&2; exit 1; }; \
3295 3545 HOME=/etc/skel fc-match monospace | grep -q 'Regular' \
3296 3546 || { echo "monospace resolves to a weight other than Regular; the face is variable and its own default instance is ExtraLight, so this is what an unnamed weight gets" >&2; exit 1; }; \
3547 + [ "$(fc-list ':charset=1F600' family | wc -l)" -eq 0 ] \
3548 + || { echo "an emoji font arrived; docs/STACK.md says none is shipped, so update the decision or drop the font" >&2; exit 1; }; \
3297 3549 else \
3298 - [ "$(fc-list ':lang=ja' family | wc -l)" -eq 0 ] \
3299 - || { echo "profile=server carries the browser's script coverage; it ships no browser" >&2; exit 1; }; \
3300 - [ "$(fc-list ':family=Quasi Mono' family | wc -l)" -ge 1 ] \
3301 - || { echo "Quasi Mono is missing; the font layers are unconditional and this profile has a console" >&2; exit 1; }; \
3302 - [ "$(fc-list ':family=Quasi Body' family | wc -l)" -ge 1 ] \
3303 - || { echo "Quasi Body is missing; the font layers are unconditional" >&2; exit 1; }; \
3304 - echo "fonts: Alloy's own faces present, the browser's coverage correctly absent"; \
3305 - fi; \
3306 - [ "$(fc-list ':charset=1F600' family | wc -l)" -eq 0 ] \
3307 - || { echo "an emoji font arrived; docs/STACK.md says none is shipped, so update the decision or drop the font" >&2; exit 1; }
3550 + ! command -v fc-list >/dev/null \
3551 + || { echo "profile=server carries fontconfig after every install; something now requires it, so find out what and decide, rather than leaving this branch asserting something untrue" >&2; exit 1; }; \
3552 + test ! -e /usr/share/fonts/quasi \
3553 + || { echo "profile=server carries the house faces; nothing on it rasterises a glyph, and a TUI over ssh is drawn by the client's fonts" >&2; exit 1; }; \
3554 + for pkg in default-fonts-cjk-sans default-fonts-other-sans google-noto-sans-mono-cjk-vf-fonts; do \
3555 + ! rpm -q --quiet "$pkg" \
3556 + || { echo "profile=server carries $pkg, one of the font packages the client block installs; it ships no browser and no terminal, so nothing on it would read the face" >&2; exit 1; }; \
3557 + done; \
3558 + ! rpm -qa | grep -qi emoji \
3559 + || { echo "an emoji font package arrived on profile=server; docs/STACK.md says none is shipped, so update the decision or drop the font" >&2; exit 1; }; \
3560 + echo "fonts: no font stack on this profile, which is what a headless machine draws with"; \
3561 + fi
3308 3562
3309 3563 # =====================================================================
3310 3564 # bootc validation — fails the build if the image isn't a valid
@@ -158,6 +158,8 @@
158 158 | `serif` | Liberation Serif | yes |
159 159 | `emoji` | (deliberately absent) | no emoji font is installed |
160 160
161 + The column answers for the client profile. The server profile installs neither house face nor fontconfig, so nothing on it reads this file: a TUI reached over ssh is drawn by the client's font stack, and the profile prune takes `etc/skel/.config/fontconfig` with the rest of the skeleton.
162 +
161 163 **Both house faces are variable, and fontconfig enumerates their named instances**, so
162 164 `fc-match monospace` answers `Quasi Mono Regular` rather than the file's own default
163 165 instance — which is ExtraLight, because a cut keeps its base's default. Measured
@@ -623,6 +623,8 @@
623 623
624 624 What that replaced was a 60 MB IosevkaTerm Nerd Font zip plus an upstream Atkinson Hyperlegible tarball, and it retired the Private Use Area from the image entirely.
625 625
626 + **Both faces are client-only, and so is fontconfig itself** (2026-08-19). A headless machine rasterises nothing: no compositor, no GTK, and no terminal, since shop is client-only and the server carries only its uninstalled RPM. A TUI reached over ssh is drawn by the client's font stack out of the client's own faces, so the server end of that session never opens a font file. The server profile therefore installs neither face, no fontconfig and no font cache, and the build asserts their absence rather than skipping the check.
627 +
626 628 **The argument for cutting rather than picking.** A face chosen for the quality of its text comes from a designer who declined to ship UI symbols, so the two requirements pull apart: pick for legibility and you get no box drawing, pick for symbol coverage and you get a nerd-patched font carrying seventeen thousand codepoints to supply seven. Measured (FONTS.md), Alloy's own source and every config template it ships emit **zero** PUA codepoints; the one live PUA consumer was yazi's built-in filetype icon set, which is now off (`templates/etc/skel/.config/yazi/theme.toml.in`, an empty `[icon]` section). So the whole of what Iosevka was carrying is the box-drawing and block ranges plus a handful of marks, and those are drawings we can own.
627 629
628 630 **There is no separate terminal font.** One face answers the mono need everywhere: the terminal, the console, the installer, the pango surfaces. The Term-versus-Mono-versus-Propo family split went with the PUA it existed to constrain.
@@ -83,6 +83,38 @@
83 83 `crates/alloy/credits.toml`, which is what the installer's credits page
84 84 reads. It is hand-curated on purpose, so nothing adds itself.
85 85
86 + ## When the build cannot reach the fonts
87 +
88 + Alloy cuts its two faces during the build from a pinned upstream base, and the
89 + base is fetched from raw.githubusercontent.com, which rate-limits by IP. A busy
90 + network or a shared address can earn an HTTP 429. The fetch retries five times
91 + before it gives up, and curl retries a 429 like any other transient answer, so
92 + what gets through to you is a host that stayed unwilling.
93 +
94 + One build asks that host three times: the two faces the image cuts, and a third
95 + that the terminal's own build script cuts while `cargo install shop` runs. The
96 + seed below covers the first two. It does not cover the terminal's, which is the
97 + fetch that failed in August, because the terminal's build script names its own
98 + cache directory and nothing in the image build can point it elsewhere. Warming
99 + the cargo target directory is what covers that one today.
100 +
101 + To seed the two the image cuts, put the four files in `build/base-cache/` (its
102 + README names them and says where to copy them from). They are carried into the
103 + build and used in place of a download. To make the cut refuse to reach out at
104 + all:
105 +
106 + build/build-iso.sh --build-arg QUASI_BASES=sealed
107 +
108 + If the seed is short of a file the pins name, that fails while the tool is still
109 + building, listing what is missing, rather than at the cut. It seals the cut and
110 + nothing else: the build still clones two repositories, resolves crates, installs
111 + packages, and cuts the terminal's face.
112 +
113 + Keeping the bases outside the repo works too, with `podman build -v
114 + <dir>:/base-cache:ro`, but neither wrapper script forwards a `-v`, and a bare
115 + `podman build` produces an image with no build stamp (see docs/IMAGE.md). Copy
116 + the files in instead unless you know you want that.
117 +
86 118 ## What the base ships that you do not need
87 119
88 120 The Fedora base is a general-purpose server image, and three of the things it
@@ -59,11 +59,17 @@
59 59
60 60 /// Whether `.containerignore` drops `source` from the context.
61 61 ///
62 - /// Only the shape the file actually uses is modelled: whole top-level entries,
63 - /// with `!` negating an earlier exclusion (`/build` then `!/build/make-iso.sh`).
64 - /// A glob would need real pattern matching, and inventing that here would mean
65 - /// this test disagreeing with podman about what a pattern means, which is
66 - /// worse than not covering a shape the file does not use.
62 + /// Only the shapes the file actually uses are modelled: whole top-level
63 + /// entries, with `!` negating an earlier exclusion (`/build` then
64 + /// `!/build/make-iso.sh`), and one glob, a trailing `/**`.
65 + ///
66 + /// That glob is here because `!/build/base-cache/**` is: the seed directory is
67 + /// the first negation in the file that names a directory rather than a file,
68 + /// and it carries both forms until someone runs a build that says which one the
69 + /// matcher wants (`font_profile.rs` holds that note). `dir/**` means everything
70 + /// under `dir` in every matcher this file could meet, so modelling it is not
71 + /// this test inventing an opinion. Any other glob still fails the assertion
72 + /// below rather than being guessed at.
67 73 fn excluded_by(ignore: &str, source: &str) -> bool {
68 74 let mut excluded = false;
69 75 for rule in ignore
@@ -71,9 +77,13 @@
71 77 .map(str::trim)
72 78 .filter(|line| !line.is_empty() && !line.starts_with('#'))
73 79 {
80 + let rule = match rule.strip_suffix("/**") {
81 + Some(directory) => directory,
82 + None => rule,
83 + };
74 84 assert!(
75 85 !rule.contains('*'),
76 - "`{rule}` is a glob, and this test does not model globs. \
86 + "`{rule}` is a glob, and this test models only a trailing `/**`. \
77 87 Teach it the pattern or keep .containerignore literal."
78 88 );
79 89 let (negated, pattern) = match rule.strip_prefix('!') {
@@ -138,6 +148,16 @@
138 148 "!/build/make-iso.sh\n/build\n",
139 149 "build/make-iso.sh"
140 150 ));
151 + // The one glob shape, which the seed directory carries beside its literal
152 + // negation: `dir/**` re-includes what is under `dir` and nothing else.
153 + assert!(!excluded_by(
154 + "/build\n!/build/base-cache/**\n",
155 + "build/base-cache/atkinson-mono-2.001-LICENSE.txt"
156 + ));
157 + assert!(excluded_by(
158 + "/build\n!/build/base-cache/**\n",
159 + "build/rpm/shop.spec"
160 + ));
141 161 }
142 162
143 163 // The baked hostname has to go somewhere a `RUN` can actually reach, and no
@@ -1,0 +1,4 @@
1 + # The bases themselves are never committed. See README.md.
2 + *
3 + !README.md
4 + !.gitignore
@@ -1,0 +1,78 @@
1 + # Seeded font bases
2 +
3 + The image cuts its two house faces from pinned upstream bases (`quasi-type`,
4 + wiki `typography-standard`). The pins name files on raw.githubusercontent.com,
5 + which rate-limits by IP, and a 429 there fails the whole build an hour in,
6 + behind every expensive layer. Alloy's only install path is building the image
7 + yourself, so a fetch that cannot run is an install that cannot run.
8 +
9 + Anything dropped in this directory is copied into the `quasi-type` checkout's
10 + own `bases/cache/` before the cut runs, which is the directory the tool reads
11 + before it fetches anything. This `README.md` is skipped, and is what keeps the
12 + directory in git.
13 +
14 + ## What the seed covers
15 +
16 + The two cuts the image runs, and nothing else. A build makes three requests to
17 + that host: these two, and a third from shop's own build script, which cuts the
18 + terminal's bundled face while `cargo install shop` runs. That third one is the
19 + one that broke the build on 2026-08-17, and this directory does not cover it:
20 + shop's build script passes its own `OUT_DIR` as the cache directory, so there is
21 + no path for the image build to point at a carried copy. Covering it needs a
22 + change in shop (read the cache directory and the offline flag from the
23 + environment); until then a warm cargo target directory is what covers it.
24 +
25 + The rest of the build is not offline either, in any mode: it clones two
26 + repositories, resolves crates.io, and installs packages.
27 +
28 + ## The four files
29 +
30 + Named exactly as `quasi-type` caches them:
31 +
32 + atkinson-mono-2.001-AtkinsonHyperlegibleMono%5Bwght%5D.ttf
33 + atkinson-mono-2.001-LICENSE.txt
34 + atkinson-next-2.001-AtkinsonHyperlegibleNext%5Bwght%5D.ttf
35 + atkinson-next-2.001-LICENSE.txt
36 +
37 + Copy them from a warm checkout, `~/Code/Libraries/quasi-type/bases/cache/`, or
38 + from any machine that has built the image once. Do not commit them: a font in a
39 + repo is a second source of truth that nothing rebuilds, which is the same
40 + reason the built faces are not committed either. `.gitignore` here says so.
41 +
42 + The list above is what the pins name today. The build does not read it: it
43 + derives the names from the pinned checkout's own `bases/pins.toml`, so a
44 + `QUASI_TYPE_REV` bump that moves to a new base version asks for the new names
45 + and this list is what goes stale.
46 +
47 + Seeding cannot forge a base. Every cached file is checked against the pin's
48 + sha256 exactly as a downloaded one is, so a wrong or tampered copy fails the
49 + cut rather than shipping.
50 +
51 + ## Using it
52 +
53 + build/build-iso.sh --build-arg QUASI_BASES=sealed
54 +
55 + `sealed` passes `--offline` to the cut, so the cut may not reach the network. If
56 + the seed is short of a file the pins name, the build says which files while the
57 + tool is still compiling, rather than at the cut. Use it on a build host with a
58 + warm cache. The default, `fetch`, uses whatever is seeded and downloads the
59 + rest, which is what a stranger with an empty directory gets.
60 +
61 + Keeping the bases outside the repo is possible with
62 +
63 + podman build -v ~/Code/Libraries/quasi-type/bases/cache:/base-cache:ro .
64 +
65 + The mount shadows this directory for the length of the build. Note what that
66 + costs: `build/build-iso.sh` and `build/build-image.sh` forward `--build-arg` and
67 + nothing else, so this route means driving `podman build` yourself, and a build
68 + started that way carries no build stamp and reports its version as
69 + `0.1 (Fedora 43)` (docs/IMAGE.md). Copying the files in here is the route that
70 + works with the scripts.
71 +
72 + ## What none of this is
73 +
74 + A fix. The durable answer is a copy of the bases on infrastructure we own,
75 + addressed by the same sha256, so that an unseeded build has somewhere to reach
76 + that is not somebody else's rate limiter. That is a hosting decision and it has
77 + not been made. It is also the only one of these that would cover shop's fetch
78 + without shop changing.
@@ -1,0 +1,1157 @@
1 + //! The font stack is client-only, and the server branch has to prove it.
2 + //!
3 + //! A headless Alloy rasterises nothing. There is no compositor, no GTK and no
4 + //! terminal, since shop is client-only and the server carries only its
5 + //! uninstalled RPM, and a TUI reached over ssh is drawn by the CLIENT's font
6 + //! stack out of the client's own faces. So the two house faces, the fontconfig
7 + //! package and the cache built over them are payload no reader on that machine
8 + //! opens.
9 + //!
10 + //! What kept them there until 2026-08-19 was an assertion, not the bytes. The
11 + //! server branch of the font guard demanded Quasi Mono be present, on the
12 + //! reasoning that "the font layers are unconditional and this profile has a
13 + //! console". It is wrong about where a console's glyphs come from, and the sentence
14 + //! could not be acted on without dropping the check that stated it. The branch
15 + //! now proves the faces are ABSENT, which is the pattern the rest of the file
16 + //! follows: client proves presence, server proves absence.
17 + //!
18 + //! That inversion is worth a test on both axes, because both halves are silent
19 + //! when they break. A font tool called outside a `$PROFILE` conditional is a
20 + //! server build invoking a binary the profile no longer installs, and it fails
21 + //! an hour in on the profile nobody built that day. And a server branch that
22 + //! stopped asserting absence would let the payload back with nothing to say so.
23 + //!
24 + //! Two kinds of check here, and they cover different failures. The text checks
25 + //! read the Containerfile for the structural rule, in the spirit of
26 + //! `profile_split.rs`. The rest extracts the real `RUN` blocks and runs them
27 + //! against a fake root with stubbed font tools, the way `build_record.rs`
28 + //! exercises the record writer: it is the only way to find out whether the
29 + //! branch a build takes actually holds, short of a build.
30 +
31 + use std::path::{Path, PathBuf};
32 + use std::process::Command;
33 +
34 + fn repo_root() -> PathBuf {
35 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
36 + }
37 +
38 + fn containerfile() -> String {
39 + let path = repo_root().join("Containerfile");
40 + std::fs::read_to_string(&path)
41 + .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()))
42 + }
43 +
44 + /// Every `RUN` block, with continuations joined and comment lines dropped, the
45 + /// way the image parser hands them to the shell.
46 + ///
47 + /// Dropping the comments is not tidiness. The parser removes a `#` line inside
48 + /// a continuation before the shell sees it, so a check reading the raw text
49 + /// could match a command that only exists in a comment.
50 + fn run_blocks(text: &str) -> Vec<String> {
51 + let mut out = Vec::new();
52 + let mut instruction: Option<String> = None;
53 +
54 + for raw in text.lines() {
55 + let line = raw.trim_end();
56 + let trimmed = line.trim_start();
57 + if trimmed.starts_with('#') {
58 + continue;
59 + }
60 +
61 + match &mut instruction {
62 + None => {
63 + let Some(body) = trimmed.strip_prefix("RUN ") else {
64 + continue;
65 + };
66 + instruction = Some(body.to_string());
67 + }
68 + Some(current) => {
69 + current.push(' ');
70 + current.push_str(trimmed);
71 + }
72 + }
73 +
74 + if line.ends_with('\\') {
75 + let current = instruction.as_mut().expect("inside an instruction");
76 + current.pop();
77 + continue;
78 + }
79 +
80 + out.push(instruction.take().expect("just built one"));
81 + }
82 +
83 + if let Some(last) = instruction {
84 + out.push(last);
85 + }
86 + out
87 + }
88 +
89 + /// The one `RUN` block that mentions `marker`, as a shell script.
90 + ///
91 + /// Pulled out of the Containerfile rather than restated here: a copy would keep
92 + /// passing while the real block grew a line that only holds on one profile.
93 + fn block_with(marker: &str) -> String {
94 + let text = containerfile();
95 + let mut found: Vec<String> = run_blocks(&text)
96 + .into_iter()
97 + .filter(|block| block.contains(marker))
98 + .collect();
99 + assert_eq!(
100 + found.len(),
101 + 1,
102 + "expected exactly one RUN block mentioning `{marker}`, found {}",
103 + found.len(),
104 + );
105 + found.remove(0)
106 + }
107 +
108 + /// The two halves of a `$PROFILE` conditional.
109 + ///
110 + /// Split on the `else` that joins them rather than on the four letters that
111 + /// spell it. `split_once("else")` stood here first, and it matches `elsewhere`,
112 + /// `or else`, and any message text carrying the word, which would hand the
113 + /// server assertions a haystack cut at the wrong place and leave them passing
114 + /// against the client branch. The `else` that joins two branches is a word of
115 + /// its own, and the branch before it ends in `;`, so that is what is matched:
116 + /// whitespace either side, and a semicolon as the last thing that was not
117 + /// whitespace. Exactly one occurrence is required, since a second would mean a
118 + /// nested conditional this helper cannot split.
119 + fn if_branches(block: &str) -> (String, String) {
120 + let hits: Vec<usize> = block
121 + .match_indices("else")
122 + .filter(|(at, _)| {
123 + let before = &block[..*at];
124 + let after = &block[at + "else".len()..];
125 + before.ends_with(char::is_whitespace)
126 + && after.starts_with(char::is_whitespace)
127 + && before.trim_end().ends_with(';')
128 + })
129 + .map(|(at, _)| at)
130 + .collect();
131 + assert_eq!(
132 + hits.len(),
133 + 1,
134 + "expected exactly one branch-joining `else` in this block, found {}:\n{block}",
135 + hits.len(),
136 + );
137 + let at = hits[0];
138 + (
139 + block[..at].to_string(),
140 + block[at + "else".len()..].to_string(),
141 + )
142 + }
143 +
144 + /// The font tools, which all ship in the one package the client installs.
145 + const FONT_TOOLS: [&str; 3] = ["fc-cache", "fc-list", "fc-match"];
146 +
147 + /// A scratch directory that removes itself, so a failing assertion does not
148 + /// leave a tree behind and a passing one does not need a cleanup call the
149 + /// panic would skip.
150 + struct Scratch(PathBuf);
151 +
152 + impl Scratch {
153 + fn new(label: &str) -> Self {
154 + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
155 + let dir = std::env::temp_dir().join(format!(
156 + "alloy-font-{label}-{}-{}",
157 + std::process::id(),
158 + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
159 + ));
160 + let _ = std::fs::remove_dir_all(&dir);
161 + std::fs::create_dir_all(&dir).expect("scratch dir");
162 + Self(dir)
163 + }
164 +
165 + fn path(&self) -> &Path {
166 + &self.0
167 + }
168 +
169 + fn join(&self, relative: &str) -> PathBuf {
170 + self.0.join(relative)
171 + }
172 + }
173 +
174 + impl Drop for Scratch {
175 + fn drop(&mut self) {
176 + let _ = std::fs::remove_dir_all(&self.0);
177 + }
178 + }
179 +
180 + /// A `PATH` holding nothing but the stubs and the handful of real tools the
181 + /// blocks call.
182 + ///
183 + /// Built rather than inherited, because the interesting assertion on `server`
184 + /// is that `fc-list` is NOT reachable, and the machine running these tests has
185 + /// a fontconfig of its own. Inheriting the host's `PATH` would have that
186 + /// assertion answering about fw13 instead of about the image.
187 + fn fake_bin(scratch: &Scratch) -> PathBuf {
188 + let bin = scratch.join("bin");
189 + std::fs::create_dir_all(&bin).expect("bin");
190 + for tool in [
191 + "rm", "cp", "mkdir", "ls", "wc", "grep", "dirname", "cat", "chmod", "awk",
192 + ] {
193 + let Some(real) = ["/usr/bin", "/bin", "/usr/local/bin"]
194 + .iter()
195 + .map(|dir| Path::new(dir).join(tool))
196 + .find(|path| path.exists())
197 + else {
198 + panic!("the machine running these tests has no `{tool}`");
199 + };
200 + let link = bin.join(tool);
201 + if !link.exists() {
202 + #[cfg(unix)]
203 + std::os::unix::fs::symlink(&real, &link).expect("linking a real tool");
204 + }
205 + }
206 + bin
207 + }
208 +
209 + /// The shell, by absolute path.
210 + ///
211 + /// `Command::new(shell())` would resolve against the `PATH` these tests hand the
212 + /// child, and that `PATH` deliberately holds almost nothing: the point of it is
213 + /// that the host's own fontconfig cannot answer a question about the image.
214 + fn shell() -> &'static str {
215 + ["/bin/sh", "/usr/bin/sh"]
216 + .into_iter()
217 + .find(|path| Path::new(path).exists())
218 + .expect("the machine running these tests has no /bin/sh")
219 + }
220 +
221 + fn write_executable(path: &Path, body: &str) {
222 + if let Some(parent) = path.parent() {
223 + std::fs::create_dir_all(parent).expect("stub directory");
224 + }
225 + std::fs::write(path, body).expect("writing a stub");
226 + #[cfg(unix)]
227 + {
228 + use std::os::unix::fs::PermissionsExt;
229 + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
230 + }
231 + }
232 +
233 + /// The font tools, stubbed, answering out of the fake root rather than out of
234 + /// whatever the machine running the tests happens to have installed.
235 + ///
236 + /// `fc-list` reads the fake font directory, so it reports Quasi Mono exactly
237 + /// when the block under test has really put a file there. That is what makes
238 + /// the client branch a test of the copy rather than of the stub.
239 + fn font_tools(bin: &Path, fonts: &Path) {
240 + write_executable(
241 + &bin.join("fc-list"),
242 + &format!(
243 + "#!/bin/sh\ncase \"$1\" in\n *charset=1F600*) exit 0 ;;\n *family=Quasi*) \
244 + [ -n \"$(ls -A '{fonts}' 2>/dev/null)\" ] && echo 'Quasi Mono'; exit 0 ;;\n *) \
245 + echo 'Noto Sans'; exit 0 ;;\nesac\n",
246 + fonts = fonts.display(),
247 + ),
248 + );
249 + write_executable(&bin.join("fc-cache"), "#!/bin/sh\nexit 0\n");
250 + write_executable(
251 + &bin.join("fc-match"),
252 + "#!/bin/sh\ncase \"$1\" in\n sans-serif) echo 'QuasiBody[wght].ttf: \"Quasi Body\" \
253 + \"Regular\"' ;;\n *) echo 'QuasiMono[wght].ttf: \"Quasi Mono\" \"Regular\"' ;;\nesac\n",
254 + );
255 + }
256 +
257 + /// `rpm`, stubbed to answer for a machine carrying no font package at all,
258 + /// which is what the server profile installs.
259 + fn rpm_tool(bin: &Path, packages: &str) {
260 + write_executable(
261 + &bin.join("rpm"),
262 + &format!(
263 + "#!/bin/sh\ncase \"$1\" in\n -qa) printf '%s' '{packages}' ;;\n *) for arg in \
264 + \"$@\"; do case \"$arg\" in -*) continue ;; esac; printf '%s' '{packages}' | \
265 + grep -qx \"$arg\" && exit 0; done; exit 1 ;;\nesac\n",
266 + ),
267 + );
268 + }
269 +
270 + /// Run an extracted block against a fake root, with `$PROFILE` set and only the
271 + /// stubs on `PATH`.
272 + ///
273 + /// `PATH` is the fake root's bin directory and nothing else, which is the point
274 + /// on `server`: the branch asserts `fc-list` is not reachable, and that claim
275 + /// can only be tested where the host's own fontconfig cannot answer it.
276 + fn run_block(script: &str, profile: &str, bin: &Path) -> std::process::Output {
277 + Command::new(shell())
278 + .arg("-c")
279 + .arg(script)
280 + .env("PROFILE", profile)
281 + .env("PATH", format!("{}", bin.display()))
282 + .output()
283 + .expect("running the extracted block")
284 + }
285 +
286 + /// The block that stages the cut faces and decides what becomes of them, with
287 + /// its absolute paths redirected into `root`.
288 + fn face_install_script(root: &Path) -> String {
289 + block_with("/faces-staged")
290 + .replace(
291 + "/usr/share/fonts/quasi",
292 + &root.join("usr/share/fonts/quasi").to_string_lossy(),
293 + )
294 + .replace(
295 + "/faces-staged",
296 + &root.join("faces-staged").to_string_lossy(),
297 + )
298 + }
299 +
300 + /// The late guard, the one that reads the whole assembled image, redirected the
301 + /// same way.
302 + fn font_guard_script(root: &Path) -> String {
303 + block_with("no font covers").replace(
304 + "/usr/share/fonts/quasi",
305 + &root.join("usr/share/fonts/quasi").to_string_lossy(),
306 + )
307 + }
308 +
309 + // ---------------------------------------------------------------------------
310 + // The structural rule, read off the file.
311 + // ---------------------------------------------------------------------------
312 +
313 + /// Every font tool call sits in the client branch of a `$PROFILE` conditional.
314 + ///
315 + /// `fc-cache`, `fc-list` and `fc-match` all ship in the `fontconfig` package,
316 + /// which only the client installs. A call anywhere else is a command that does
317 + /// not exist on half the builds, and it fails at the end of the longest layer
318 + /// in the image rather than at the line that introduced it. This is the same
319 + /// shape as `profile_split.rs`'s rule about the server prune: a path only one
320 + /// profile has, read by an instruction that does not branch.
321 + ///
322 + /// The first version of this test asserted only that `$PROFILE" =` appeared
323 + /// somewhere in the block, which a call on the server side of the same block
324 + /// would have satisfied. So the branches are split and the halves are asked
325 + /// separately. The server half may still NAME a tool, in `command -v`, since
326 + /// proving the tool is unreachable is the whole of what that branch does.
327 + #[test]
328 + fn every_font_tool_call_sits_in_the_client_branch() {
329 + for block in run_blocks(&containerfile()) {
330 + if !FONT_TOOLS.iter().any(|tool| block.contains(tool)) {
331 + continue;
332 + }
333 + let (client, server) = if_branches(&block);
334 + assert!(
335 + FONT_TOOLS.iter().any(|tool| client.contains(tool)),
336 + "this block calls a font tool, and not in the client branch, which is the \
337 + only profile that has one:\n{block}",
338 + );
339 +
340 + let mut asked_for = server.clone();
341 + for tool in FONT_TOOLS {
342 + asked_for = asked_for.replace(&format!("command -v {tool}"), "");
343 + }
344 + assert!(
345 + !FONT_TOOLS.iter().any(|tool| asked_for.contains(tool)),
346 + "the server branch runs a font tool rather than proving it is absent, on a \
347 + profile that installs none:\n{server}",
348 + );
349 + }
350 + }
351 +
352 + /// Every font package the client installs is denied by name in the server
353 + /// guard.
354 + ///
355 + /// The guard used to ask `fc-list ':lang=ja'`, which answered for coverage
356 + /// arriving from any source. It cannot, now that the server has no fontconfig
357 + /// to ask, so it names packages instead, and a name list is exactly as good as
358 + /// its list is complete. It started at one name while the client installed
359 + /// three. This is what keeps the two ends together: a font package added to the
360 + /// client block and not to the guard fails here rather than shipping unwatched
361 + /// coverage on a headless image.
362 + #[test]
363 + fn the_server_guard_denies_every_font_package_the_client_installs() {
364 + let text = containerfile();
365 + let mut packages: Vec<&str> = text
366 + .lines()
367 + .map(str::trim)
368 + .filter(|line| !line.starts_with('#'))
369 + .flat_map(|line| line.trim_end_matches('\\').split_whitespace())
370 + .filter(|token| token.ends_with("-fonts") || token.starts_with("default-fonts-"))
371 + .collect();
372 + packages.sort_unstable();
373 + packages.dedup();
374 + assert!(
375 + packages.len() >= 3,
376 + "found {} font packages in the Containerfile, which is fewer than the client \
377 + block installs; the token rule stopped matching them:\n{packages:?}",
378 + packages.len(),
379 + );
380 +
381 + let (_, server) = if_branches(&block_with("no font covers"));
382 + for package in packages {
383 + assert!(
384 + server.contains(package),
385 + "the server guard does not deny `{package}`, which the client installs; on \
386 + a profile with no fontconfig the package name is the only question that \
387 + can be asked:\n{server}",
388 + );
389 + }
390 + }
391 +
392 + /// The install itself is conditional, and the package is named where the
393 + /// condition can be read.
394 + #[test]
395 + fn fontconfig_is_installed_on_the_client_only() {
396 + let block = block_with("dnf install -y fontconfig");
397 + assert!(
398 + block.contains("$PROFILE\" = client"),
399 + "the fontconfig install is not behind a client conditional:\n{block}",
400 + );
401 + let (client, server) = if_branches(&block);
402 + assert!(
403 + client.contains("dnf install -y fontconfig"),
404 + "the install moved out of the client branch:\n{block}",
405 + );
406 + assert!(
407 + !server.contains("dnf install"),
408 + "the server branch installs something; it exists to prove absence:\n{block}",
409 + );
410 + }
411 +
412 + /// The claim the fix rests on, kept where a reader of the assertion finds it.
413 + ///
414 + /// Asserted as text because the reasoning is the defect. The old branch was not
415 + /// wrong about a path or a package name, it was wrong about where a TUI's
416 + /// glyphs are rasterised, and it stood for months because nothing checked that
417 + /// a comment and an assertion said the same thing.
418 + #[test]
419 + fn the_server_branch_asserts_absence_rather_than_presence() {
420 + let guard = block_with("no font covers");
421 + let (_, server) = if_branches(&guard);
422 +
423 + assert!(
424 + server.contains("test ! -e") && server.contains("/usr/share/fonts/quasi"),
425 + "the server branch does not prove the house faces are absent:\n{server}",
426 + );
427 + assert!(
428 + !server.contains("fc-list ':family=Quasi"),
429 + "the server branch still asks fontconfig about the house faces, on a profile \
430 + that installs neither the faces nor fontconfig:\n{server}",
431 + );
432 + assert!(
433 + server.contains("! command -v fc-list"),
434 + "the server branch does not prove fontconfig itself is absent, so a package \
435 + dragging it back in would go unreported:\n{server}",
436 + );
437 + }
438 +
439 + // ---------------------------------------------------------------------------
440 + // The blocks themselves, against a fake root.
441 + // ---------------------------------------------------------------------------
442 +
443 + /// A client build installs the staged faces, caches them, and leaves nothing at
444 + /// the root for `bootc container lint` to find.
445 + #[test]
446 + fn the_client_installs_the_staged_faces() {
447 + let scratch = Scratch::new("client-install");
448 + let root = scratch.path();
449 + let bin = fake_bin(&scratch);
450 + font_tools(&bin, &root.join("usr/share/fonts/quasi"));
451 +
452 + std::fs::create_dir_all(root.join("faces-staged")).expect("staged faces");
453 + for face in [
454 + "QuasiMono[wght].ttf",
455 + "QuasiBody[wght].ttf",
456 + "OFL-QuasiMono.txt",
457 + ] {
458 + std::fs::write(root.join("faces-staged").join(face), "face").expect("staged face");
459 + }
460 +
461 + let output = run_block(&face_install_script(root), "client", &bin);
462 + assert!(
463 + output.status.success(),
464 + "the client branch failed:\n{}",
465 + String::from_utf8_lossy(&output.stderr),
466 + );
467 +
468 + let installed = std::fs::read_dir(root.join("usr/share/fonts/quasi"))
469 + .expect("the faces were not installed")
470 + .count();
471 + assert_eq!(installed, 3, "not every staged file was installed");
472 + assert!(
473 + !root.join("faces-staged").exists(),
474 + "the staging directory survived a client build; bootc lints the root",
475 + );
476 + }
477 +
478 + /// A server build discards them, and says so.
479 + #[test]
480 + fn the_server_discards_the_staged_faces() {
481 + let scratch = Scratch::new("server-install");
482 + let root = scratch.path();
483 + // No font tools at all: this is the profile that installs none, and the
484 + // block must not need one to reach its conclusion.
485 + let bin = fake_bin(&scratch);
486 +
487 + std::fs::create_dir_all(root.join("faces-staged")).expect("staged faces");
488 + std::fs::write(root.join("faces-staged/QuasiMono[wght].ttf"), "face").expect("staged face");
489 +
490 + let output = run_block(&face_install_script(root), "server", &bin);
491 + assert!(
492 + output.status.success(),
493 + "the server branch failed:\n{}",
494 + String::from_utf8_lossy(&output.stderr),
495 + );
496 + assert!(
497 + !root.join("faces-staged").exists(),
498 + "the staging directory survived a server build",
499 + );
500 + assert!(
Lines truncated