max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+940 insertions,
-35 deletions
| @@ -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 |
| @@ -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