Skip to main content

max / alloy

60.8 KB · 1552 lines History Blame Raw
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 //! "The font layers are unconditional and this profile has a console" is the
11 //! reasoning to reject: it is wrong about where a console's glyphs come from.
12 //! The server branch proves the faces are ABSENT, which is the pattern the rest
13 //! of the file follows: client proves presence, server proves absence.
14 //!
15 //! That inversion is worth a test on both axes, because both halves are silent
16 //! when they break. A font tool called outside a `$PROFILE` conditional is a
17 //! server build invoking a binary the profile no longer installs, and it fails
18 //! an hour in on the profile nobody built that day. And a server branch that
19 //! stopped asserting absence would let the payload back with nothing to say so.
20 //!
21 //! Two kinds of check here, and they cover different failures. The text checks
22 //! read the Containerfile for the structural rule, in the spirit of
23 //! `profile_split.rs`. The rest extracts the real `RUN` blocks and runs them
24 //! against a fake root with stubbed font tools, the way `build_record.rs`
25 //! exercises the record writer: it is the only way to find out whether the
26 //! branch a build takes actually holds, short of a build.
27
28 use std::path::{Path, PathBuf};
29 use std::process::Command;
30
31 fn repo_root() -> PathBuf {
32 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
33 }
34
35 fn containerfile() -> String {
36 let path = repo_root().join("Containerfile");
37 std::fs::read_to_string(&path)
38 .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()))
39 }
40
41 /// Every `RUN` block, with continuations joined and comment lines dropped, the
42 /// way the image parser hands them to the shell.
43 ///
44 /// Dropping the comments is not tidiness. The parser removes a `#` line inside
45 /// a continuation before the shell sees it, so a check reading the raw text
46 /// could match a command that only exists in a comment.
47 fn run_blocks(text: &str) -> Vec<String> {
48 let mut out = Vec::new();
49 let mut instruction: Option<String> = None;
50
51 for raw in text.lines() {
52 let line = raw.trim_end();
53 let trimmed = line.trim_start();
54 if trimmed.starts_with('#') {
55 continue;
56 }
57
58 match &mut instruction {
59 None => {
60 let Some(body) = trimmed.strip_prefix("RUN ") else {
61 continue;
62 };
63 instruction = Some(body.to_string());
64 }
65 Some(current) => {
66 current.push(' ');
67 current.push_str(trimmed);
68 }
69 }
70
71 if line.ends_with('\\') {
72 let current = instruction.as_mut().expect("inside an instruction");
73 current.pop();
74 continue;
75 }
76
77 out.push(instruction.take().expect("just built one"));
78 }
79
80 if let Some(last) = instruction {
81 out.push(last);
82 }
83 out
84 }
85
86 /// The one `RUN` block that mentions `marker`, as a shell script.
87 ///
88 /// Pulled out of the Containerfile rather than restated here: a copy would keep
89 /// passing while the real block grew a line that only holds on one profile.
90 fn block_with(marker: &str) -> String {
91 let text = containerfile();
92 let mut found: Vec<String> = run_blocks(&text)
93 .into_iter()
94 .filter(|block| block.contains(marker))
95 .collect();
96 assert_eq!(
97 found.len(),
98 1,
99 "expected exactly one RUN block mentioning `{marker}`, found {}",
100 found.len(),
101 );
102 found.remove(0)
103 }
104
105 /// The two halves of a `$PROFILE` conditional.
106 ///
107 /// Split on the `else` that joins them rather than on the four letters that
108 /// spell it. `split_once("else")` stood here first, and it matches `elsewhere`,
109 /// `or else`, and any message text carrying the word, which would hand the
110 /// server assertions a haystack cut at the wrong place and leave them passing
111 /// against the client branch. The `else` that joins two branches is a word of
112 /// its own, and the branch before it ends in `;`, so that is what is matched:
113 /// whitespace either side, and a semicolon as the last thing that was not
114 /// whitespace. Exactly one occurrence is required, since a second would mean a
115 /// nested conditional this helper cannot split.
116 fn if_branches(block: &str) -> (String, String) {
117 let hits: Vec<usize> = block
118 .match_indices("else")
119 .filter(|(at, _)| {
120 let before = &block[..*at];
121 let after = &block[at + "else".len()..];
122 before.ends_with(char::is_whitespace)
123 && after.starts_with(char::is_whitespace)
124 && before.trim_end().ends_with(';')
125 })
126 .map(|(at, _)| at)
127 .collect();
128 assert_eq!(
129 hits.len(),
130 1,
131 "expected exactly one branch-joining `else` in this block, found {}:\n{block}",
132 hits.len(),
133 );
134 let at = hits[0];
135 (
136 block[..at].to_string(),
137 block[at + "else".len()..].to_string(),
138 )
139 }
140
141 /// The three arms of the font guard, which is `if PROFILE / elif GUI / else`.
142 ///
143 /// [`if_branches`] splits on the one branch-joining `else` and is right for
144 /// every two-armed conditional in this file. The guard at the end of the
145 /// Containerfile is not one: its server half splits again on `$GUI`, because
146 /// `PROFILE=server GUI=tauri` is a real machine (astra, which builds the arm64
147 /// Tauri releases natively) and it carries fontconfig underneath GTK and WebKit
148 /// while carrying no face at all. Handing that block to `if_branches` returns a
149 /// "server" half that is only the `GUI=tauri` arm, and the absence claims would
150 /// then be asserted against the branch that deliberately does not make them.
151 ///
152 /// Returns (client, server with GUI=none, server with any other GUI).
153 fn three_arms(block: &str) -> (String, String, String) {
154 let elif = block
155 .find("elif ")
156 .unwrap_or_else(|| panic!("expected an `elif` arm in this block:\n{block}"));
157 let client = block[..elif].to_string();
158 let rest = &block[elif..];
159 let (headless, gui) = if_branches(rest);
160 (client, headless, gui)
161 }
162
163 /// Whether the conditional the branches came from tests `$PROFILE`.
164 ///
165 /// [`if_branches`] splits on the `else` and says nothing about what was
166 /// branched on, so on its own it accepts any conditional at all: a block
167 /// reading `if [ -d /x ]; then fc-cache; else :; fi` has a first half holding a
168 /// font tool and passes a check that only looks at the halves. The condition is
169 /// the part between the block's `if` and its `then`, and it has to name the
170 /// variable the profiles differ in.
171 fn tests_the_profile(client: &str) -> bool {
172 let Some((head, _)) = client.split_once("; then") else {
173 return false;
174 };
175 let Some((_, condition)) = head.rsplit_once("if ") else {
176 return false;
177 };
178 condition.contains("$PROFILE")
179 }
180
181 /// The font tools, which all ship in the one package the client installs.
182 const FONT_TOOLS: [&str; 3] = ["fc-cache", "fc-list", "fc-match"];
183
184 /// A scratch directory that removes itself, so a failing assertion does not
185 /// leave a tree behind and a passing one does not need a cleanup call the
186 /// panic would skip.
187 struct Scratch(PathBuf);
188
189 impl Scratch {
190 fn new(label: &str) -> Self {
191 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
192 let dir = std::env::temp_dir().join(format!(
193 "alloy-font-{label}-{}-{}",
194 std::process::id(),
195 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
196 ));
197 let _ = std::fs::remove_dir_all(&dir);
198 std::fs::create_dir_all(&dir).expect("scratch dir");
199 Self(dir)
200 }
201
202 fn path(&self) -> &Path {
203 &self.0
204 }
205
206 fn join(&self, relative: &str) -> PathBuf {
207 self.0.join(relative)
208 }
209 }
210
211 impl Drop for Scratch {
212 fn drop(&mut self) {
213 let _ = std::fs::remove_dir_all(&self.0);
214 }
215 }
216
217 /// A `PATH` holding nothing but the stubs and the handful of real tools the
218 /// blocks call.
219 ///
220 /// Built rather than inherited, because the interesting assertion on `server`
221 /// is that `fc-list` is NOT reachable, and the machine running these tests has
222 /// a fontconfig of its own. Inheriting the host's `PATH` would have that
223 /// assertion answering about fw13 instead of about the image.
224 fn fake_bin(scratch: &Scratch) -> PathBuf {
225 let bin = scratch.join("bin");
226 std::fs::create_dir_all(&bin).expect("bin");
227 for tool in [
228 "rm", "cp", "mkdir", "ls", "wc", "grep", "dirname", "cat", "chmod", "awk", "sed",
229 ] {
230 let Some(real) = ["/usr/bin", "/bin", "/usr/local/bin"]
231 .iter()
232 .map(|dir| Path::new(dir).join(tool))
233 .find(|path| path.exists())
234 else {
235 panic!("the machine running these tests has no `{tool}`");
236 };
237 let link = bin.join(tool);
238 if !link.exists() {
239 #[cfg(unix)]
240 std::os::unix::fs::symlink(&real, &link).expect("linking a real tool");
241 }
242 }
243 bin
244 }
245
246 /// The shell, by absolute path.
247 ///
248 /// `Command::new(shell())` would resolve against the `PATH` these tests hand the
249 /// child, and that `PATH` deliberately holds almost nothing: the point of it is
250 /// that the host's own fontconfig cannot answer a question about the image.
251 fn shell() -> &'static str {
252 ["/bin/sh", "/usr/bin/sh"]
253 .into_iter()
254 .find(|path| Path::new(path).exists())
255 .expect("the machine running these tests has no /bin/sh")
256 }
257
258 fn write_executable(path: &Path, body: &str) {
259 if let Some(parent) = path.parent() {
260 std::fs::create_dir_all(parent).expect("stub directory");
261 }
262 std::fs::write(path, body).expect("writing a stub");
263 #[cfg(unix)]
264 {
265 use std::os::unix::fs::PermissionsExt;
266 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
267 }
268 }
269
270 /// The font tools, stubbed, answering out of the fake root rather than out of
271 /// whatever the machine running the tests happens to have installed.
272 ///
273 /// `fc-list` reads the fake font directory, so it reports Quasi Mono exactly
274 /// when the block under test has really put a file there. That is what makes
275 /// the client branch a test of the copy rather than of the stub.
276 fn font_tools(bin: &Path, fonts: &Path) {
277 write_executable(
278 &bin.join("fc-list"),
279 &format!(
280 "#!/bin/sh\ncase \"$1\" in\n *charset=1F600*) exit 0 ;;\n *family=Quasi*) \
281 [ -n \"$(ls -A '{fonts}' 2>/dev/null)\" ] && echo 'Quasi Mono'; exit 0 ;;\n *) \
282 echo 'Noto Sans'; exit 0 ;;\nesac\n",
283 fonts = fonts.display(),
284 ),
285 );
286 write_executable(&bin.join("fc-cache"), "#!/bin/sh\nexit 0\n");
287 write_executable(
288 &bin.join("fc-match"),
289 "#!/bin/sh\ncase \"$1\" in\n sans-serif) echo 'QuasiBody[wght].ttf: \"Quasi Body\" \
290 \"Regular\"' ;;\n *) echo 'QuasiMono[wght].ttf: \"Quasi Mono\" \"Regular\"' ;;\nesac\n",
291 );
292 }
293
294 /// `rpm`, stubbed to answer for a machine carrying no font package at all,
295 /// which is what the server profile installs.
296 fn rpm_tool(bin: &Path, packages: &str) {
297 write_executable(
298 &bin.join("rpm"),
299 &format!(
300 "#!/bin/sh\ncase \"$1\" in\n -qa) printf '%s' '{packages}' ;;\n *) for arg in \
301 \"$@\"; do case \"$arg\" in -*) continue ;; esac; printf '%s' '{packages}' | \
302 grep -qx \"$arg\" && exit 0; done; exit 1 ;;\nesac\n",
303 ),
304 );
305 }
306
307 /// Run an extracted block against a fake root, with `$PROFILE` and `$GUI` set
308 /// and only the stubs on `PATH`.
309 ///
310 /// `PATH` is the fake root's bin directory and nothing else, which is the point
311 /// on `server`: the branch asserts `fc-list` is not reachable, and that claim
312 /// can only be tested where the host's own fontconfig cannot answer it.
313 ///
314 /// `$GUI` is passed rather than defaulted because the server branch splits on
315 /// it, and a default here would decide which half of that split every test in
316 /// this file exercises without saying so.
317 fn run_block(script: &str, profile: &str, gui: &str, bin: &Path) -> std::process::Output {
318 Command::new(shell())
319 .arg("-c")
320 .arg(script)
321 .env("PROFILE", profile)
322 .env("GUI", gui)
323 .env("PATH", format!("{}", bin.display()))
324 .output()
325 .expect("running the extracted block")
326 }
327
328 /// The block that stages the cut faces and decides what becomes of them, with
329 /// its absolute paths redirected into `root`.
330 fn face_install_script(root: &Path) -> String {
331 block_with("/faces-staged")
332 .replace(
333 "/usr/share/fonts/quasi",
334 &root.join("usr/share/fonts/quasi").to_string_lossy(),
335 )
336 .replace(
337 "/faces-staged",
338 &root.join("faces-staged").to_string_lossy(),
339 )
340 }
341
342 /// The late guard, the one that reads the whole assembled image, redirected the
343 /// same way.
344 fn font_guard_script(root: &Path) -> String {
345 block_with("no font covers").replace(
346 "/usr/share/fonts/quasi",
347 &root.join("usr/share/fonts/quasi").to_string_lossy(),
348 )
349 }
350
351 // ---------------------------------------------------------------------------
352 // The structural rule, read off the file.
353 // ---------------------------------------------------------------------------
354
355 /// Every font tool call sits in the client branch of a `$PROFILE` conditional.
356 ///
357 /// `fc-cache`, `fc-list` and `fc-match` all ship in the `fontconfig` package,
358 /// which only the client installs. A call anywhere else is a command that does
359 /// not exist on half the builds, and it fails at the end of the longest layer
360 /// in the image rather than at the line that introduced it. This is the same
361 /// shape as `profile_split.rs`'s rule about the server prune: a path only one
362 /// profile has, read by an instruction that does not branch.
363 ///
364 /// The first version of this test asserted only that `$PROFILE" =` appeared
365 /// somewhere in the block, which a call on the server side of the same block
366 /// would have satisfied. So the branches are split and the halves are asked
367 /// separately. The server half may still NAME a tool, in `command -v`, since
368 /// proving the tool is unreachable is the whole of what that branch does.
369 ///
370 /// Splitting on the `else` says nothing about what was branched on, so the
371 /// condition is asked for separately ([`tests_the_profile`]): a font tool
372 /// guarded by anything other than `$PROFILE` runs on whichever builds that
373 /// other test happens to be true for.
374 #[test]
375 fn every_font_tool_call_sits_in_the_client_branch() {
376 for block in run_blocks(&containerfile()) {
377 if !FONT_TOOLS.iter().any(|tool| block.contains(tool)) {
378 continue;
379 }
380 let (client, server) = if_branches(&block);
381 assert!(
382 tests_the_profile(&client),
383 "this block calls a font tool inside a conditional that does not test \
384 $PROFILE, so which builds run it is decided by something else:\n{block}",
385 );
386 assert!(
387 FONT_TOOLS.iter().any(|tool| client.contains(tool)),
388 "this block calls a font tool, and not in the client branch, which is the \
389 only profile that has one:\n{block}",
390 );
391
392 let mut asked_for = server.clone();
393 for tool in FONT_TOOLS {
394 asked_for = asked_for.replace(&format!("command -v {tool}"), "");
395 }
396 assert!(
397 !FONT_TOOLS.iter().any(|tool| asked_for.contains(tool)),
398 "the server branch runs a font tool rather than proving it is absent, on a \
399 profile that installs none:\n{server}",
400 );
401 }
402 }
403
404 /// And the guard fails on the block it is there to catch.
405 ///
406 /// The real Containerfile passes every check above, which is also what a check
407 /// that has stopped looking does. This runs the condition half over blocks
408 /// written to be wrong.
409 #[test]
410 fn a_font_tool_guarded_by_something_other_than_the_profile_is_caught() {
411 let not_the_profile = "set -eu; if [ -d /usr/share/fonts/quasi ]; then fc-cache -fv; \
412 else echo nothing; fi";
413 let (client, _) = if_branches(not_the_profile);
414 assert!(
415 client.contains("fc-cache"),
416 "the fixture puts the tool in the first half, which is what makes it a fixture",
417 );
418 assert!(
419 !tests_the_profile(&client),
420 "a conditional on a directory is not a conditional on the profile",
421 );
422
423 let profile = "set -eu; if [ \"$PROFILE\" = client ]; then fc-cache -fv; else echo nothing; fi";
424 let (client, _) = if_branches(profile);
425 assert!(
426 tests_the_profile(&client),
427 "and the real shape still passes"
428 );
429 }
430
431 /// Every font package the client installs is denied by name in the server
432 /// guard.
433 ///
434 /// The guard cannot ask `fc-list ':lang=ja'`, because the server has no
435 /// fontconfig to ask, so it names packages instead, and a name list is exactly
436 /// as good as its list is complete. This is what keeps the two ends together: a
437 /// font package added to the
438 /// client block and not to the guard fails here rather than shipping unwatched
439 /// coverage on a headless image.
440 #[test]
441 fn the_server_guard_denies_every_font_package_the_client_installs() {
442 let text = containerfile();
443 let mut packages: Vec<&str> = text
444 .lines()
445 .map(str::trim)
446 .filter(|line| !line.starts_with('#'))
447 .flat_map(|line| line.trim_end_matches('\\').split_whitespace())
448 .filter(|token| token.ends_with("-fonts") || token.starts_with("default-fonts-"))
449 .collect();
450 packages.sort_unstable();
451 packages.dedup();
452 assert!(
453 packages.len() >= 3,
454 "found {} font packages in the Containerfile, which is fewer than the client \
455 block installs; the token rule stopped matching them:\n{packages:?}",
456 packages.len(),
457 );
458
459 let (_, server) = if_branches(&block_with("no font covers"));
460 for package in packages {
461 assert!(
462 server.contains(package),
463 "the server guard does not deny `{package}`, which the client installs; on \
464 a profile with no fontconfig the package name is the only question that \
465 can be asked:\n{server}",
466 );
467 }
468 }
469
470 /// The install itself is conditional, and the package is named where the
471 /// condition can be read.
472 #[test]
473 fn fontconfig_is_installed_on_the_client_only() {
474 let block = block_with("dnf install -y fontconfig");
475 assert!(
476 block.contains("$PROFILE\" = client"),
477 "the fontconfig install is not behind a client conditional:\n{block}",
478 );
479 let (client, server) = if_branches(&block);
480 assert!(
481 client.contains("dnf install -y fontconfig"),
482 "the install moved out of the client branch:\n{block}",
483 );
484 assert!(
485 !server.contains("dnf install"),
486 "the server branch installs something; it exists to prove absence:\n{block}",
487 );
488 }
489
490 /// The claim the fix rests on, kept where a reader of the assertion finds it.
491 ///
492 /// Asserted as text because the reasoning is where the defect lives: a branch
493 /// can name every path and package correctly and still be wrong about where a
494 /// TUI's glyphs are rasterised, and nothing else checks that a comment and an
495 /// assertion say the same thing.
496 #[test]
497 fn the_server_branch_asserts_absence_rather_than_presence() {
498 let guard = block_with("no font covers");
499 let (_, headless, gui) = three_arms(&guard);
500
501 for (label, arm) in [("GUI=none", &headless), ("GUI=tauri", &gui)] {
502 assert!(
503 arm.contains("test ! -e") && arm.contains("/usr/share/fonts/quasi"),
504 "the {label} server arm does not prove the house faces are absent:\n{arm}",
505 );
506 assert!(
507 !arm.contains("fc-list ':family=Quasi"),
508 "the {label} server arm asks fontconfig about the house faces, on a profile \
509 that installs no face whatever it carries to read one with:\n{arm}",
510 );
511 }
512 assert!(
513 headless.contains("! command -v fc-list"),
514 "the headless server arm does not prove fontconfig itself is absent, so a \
515 package dragging it back in would go unreported:\n{headless}",
516 );
517 }
518
519 /// The other direction, which is the half that is easy to get wrong.
520 ///
521 /// A server that builds Tauri apps has to HAVE fontconfig: gtk3-devel and
522 /// webkit2gtk4.1-devel bring the GTK and WebKit runtime and fontconfig sits
523 /// under it, and a build host missing it fails at link time rather than at boot.
524 /// So that arm asserts presence, and asserting absence there is what the first
525 /// astra mint hit at step 101 of 103 on 2026-09-04.
526 ///
527 /// What must NOT weaken with it is the claim that actually mattered: no face.
528 /// Tools that answer questions about faces are the price of building the apps;
529 /// a face on a machine that rasterises nothing is payload with no reader, and
530 /// that is still refused. The loop above covers the face half for both arms;
531 /// this covers the package half, which is the one a careless edit would drop.
532 #[test]
533 fn the_tauri_server_arm_asserts_presence_and_still_refuses_every_face() {
534 let guard = block_with("no font covers");
535 let (_, _, gui) = three_arms(&guard);
536
537 assert!(
538 gui.contains("command -v fc-list") && !gui.contains("! command -v fc-list"),
539 "the GUI-bearing server arm does not require fontconfig, so a mint that lost \
540 the toolkit packages would report nothing and fail later at a link:\n{gui}",
541 );
542 for pkg in [
543 "default-fonts-cjk-sans",
544 "default-fonts-other-sans",
545 "google-noto-sans-mono-cjk-vf-fonts",
546 ] {
547 assert!(
548 gui.contains(pkg),
549 "the GUI-bearing server arm stopped denying {pkg}; carrying fontconfig is \
550 not a reason to carry a font:\n{gui}",
551 );
552 }
553 assert!(
554 gui.contains("emoji"),
555 "the GUI-bearing server arm stopped denying an emoji font:\n{gui}",
556 );
557 }
558
559 // ---------------------------------------------------------------------------
560 // The blocks themselves, against a fake root.
561 // ---------------------------------------------------------------------------
562
563 /// A client build installs the staged faces, caches them, and leaves nothing at
564 /// the root for `bootc container lint` to find.
565 #[test]
566 fn the_client_installs_the_staged_faces() {
567 let scratch = Scratch::new("client-install");
568 let root = scratch.path();
569 let bin = fake_bin(&scratch);
570 font_tools(&bin, &root.join("usr/share/fonts/quasi"));
571
572 std::fs::create_dir_all(root.join("faces-staged")).expect("staged faces");
573 for face in [
574 "QuasiMono[wght].ttf",
575 "QuasiBody[wght].ttf",
576 "OFL-QuasiMono.txt",
577 ] {
578 std::fs::write(root.join("faces-staged").join(face), "face").expect("staged face");
579 }
580
581 let output = run_block(&face_install_script(root), "client", "tauri", &bin);
582 assert!(
583 output.status.success(),
584 "the client branch failed:\n{}",
585 String::from_utf8_lossy(&output.stderr),
586 );
587
588 let installed = std::fs::read_dir(root.join("usr/share/fonts/quasi"))
589 .expect("the faces were not installed")
590 .count();
591 assert_eq!(installed, 3, "not every staged file was installed");
592 assert!(
593 !root.join("faces-staged").exists(),
594 "the staging directory survived a client build; bootc lints the root",
595 );
596 }
597
598 /// A server build discards them, and says so.
599 #[test]
600 fn the_server_discards_the_staged_faces() {
601 let scratch = Scratch::new("server-install");
602 let root = scratch.path();
603 // No font tools at all: this is the profile that installs none, and the
604 // block must not need one to reach its conclusion.
605 let bin = fake_bin(&scratch);
606
607 std::fs::create_dir_all(root.join("faces-staged")).expect("staged faces");
608 std::fs::write(root.join("faces-staged/QuasiMono[wght].ttf"), "face").expect("staged face");
609
610 let output = run_block(&face_install_script(root), "server", "none", &bin);
611 assert!(
612 output.status.success(),
613 "the server branch failed:\n{}",
614 String::from_utf8_lossy(&output.stderr),
615 );
616 assert!(
617 !root.join("faces-staged").exists(),
618 "the staging directory survived a server build",
619 );
620 assert!(
621 !root.join("usr/share/fonts/quasi").exists(),
622 "a server build installed the house faces",
623 );
624 }
625
626 /// The server branch of the install has to be able to fail, or it is the same
627 /// decoration the old presence check was.
628 #[test]
629 fn a_server_build_that_installed_the_faces_fails() {
630 let scratch = Scratch::new("server-install-fails");
631 let root = scratch.path();
632 let bin = fake_bin(&scratch);
633 std::fs::create_dir_all(root.join("faces-staged")).expect("staged faces");
634 std::fs::create_dir_all(root.join("usr/share/fonts/quasi")).expect("an installed face dir");
635
636 let output = run_block(&face_install_script(root), "server", "none", &bin);
637 assert!(
638 !output.status.success(),
639 "a server root carrying the house faces passed the install block",
640 );
641 assert!(
642 String::from_utf8_lossy(&output.stderr).contains("rasterise"),
643 "the failure does not say why a headless machine has no use for a face",
644 );
645 }
646
647 /// The install block itself, on both profiles, with `dnf` stubbed.
648 ///
649 /// The client branch has to end with `fc-cache` reachable, since the face copy
650 /// further down calls it. The server branch has to be satisfied by a machine
651 /// where the package was never installed, which is the state declining to
652 /// install it produces: the base carries no fontconfig, measured against
653 /// fedora-bootc:43.
654 #[test]
655 fn the_fontconfig_install_holds_on_both_profiles() {
656 for profile in ["client", "server"] {
657 let scratch = Scratch::new(&format!("fontconfig-{profile}"));
658 let bin = fake_bin(&scratch);
659 // The stub installs what the real package would: the tools, on PATH.
660 write_executable(
661 &bin.join("dnf"),
662 "#!/bin/sh\ncase \"$1\" in\n install) printf '#!/bin/sh\\nexit 0\\n' > \
663 \"$(dirname \"$0\")/fc-cache\"; chmod 0755 \"$(dirname \"$0\")/fc-cache\" ;;\
664 \nesac\nexit 0\n",
665 );
666
667 let output = run_block(
668 &block_with("dnf install -y fontconfig"),
669 profile,
670 "none",
671 &bin,
672 );
673 assert!(
674 output.status.success(),
675 "the fontconfig install block failed on {profile}:\n{}",
676 String::from_utf8_lossy(&output.stderr),
677 );
678 assert_eq!(
679 bin.join("fc-cache").exists(),
680 profile == "client",
681 "{profile} ended with the wrong answer to whether fontconfig is installed",
682 );
683 }
684 }
685
686 /// And the server branch of it can fail: a base that started shipping
687 /// fontconfig has to be reported rather than absorbed, because the whole
688 /// argument for dropping the faces is that nothing on this profile reads them.
689 #[test]
690 fn a_server_that_already_has_fontconfig_is_reported() {
691 let scratch = Scratch::new("fontconfig-inherited");
692 let bin = fake_bin(&scratch);
693 write_executable(&bin.join("dnf"), "#!/bin/sh\nexit 0\n");
694 write_executable(&bin.join("fc-cache"), "#!/bin/sh\nexit 0\n");
695
696 let output = run_block(
697 &block_with("dnf install -y fontconfig"),
698 "server",
699 "none",
700 &bin,
701 );
702 assert!(
703 !output.status.success(),
704 "a server carrying fontconfig from its base passed the install block",
705 );
706 assert!(
707 String::from_utf8_lossy(&output.stderr).contains("already carries fontconfig"),
708 "the failure does not say what changed",
709 );
710 }
711
712 /// The late guard, on a server root shaped the way a server build leaves one:
713 /// no font tools, no house faces, no font package.
714 #[test]
715 fn the_font_guard_passes_on_a_headless_root() {
716 let scratch = Scratch::new("guard-server");
717 let root = scratch.path();
718 let bin = fake_bin(&scratch);
719 rpm_tool(&bin, "bash\nsystemd\npodman\nadwaita-mono-fonts");
720
721 let output = run_block(&font_guard_script(root), "server", "none", &bin);
722 assert!(
723 output.status.success(),
724 "the guard failed on a correctly headless root:\n{}",
725 String::from_utf8_lossy(&output.stderr),
726 );
727 }
728
729 /// The same guard on a build host: `PROFILE=server GUI=tauri`, which is astra.
730 ///
731 /// Both directions, because each is a real failure. With the font tools present
732 /// the root is correct and the guard has to pass -- asserting absence here is
733 /// what the first astra mint hit. With them missing the toolkit packages did not
734 /// land, and a mint that says nothing about that produces a build host that
735 /// fails at a link months later.
736 #[test]
737 fn the_font_guard_reads_a_tauri_build_host_both_ways() {
738 let scratch = Scratch::new("guard-server-tauri");
739 let root = scratch.path();
740 let bin = fake_bin(&scratch);
741 rpm_tool(
742 &bin,
743 "bash\nsystemd\npodman\ngtk3-devel\nwebkit2gtk4.1-devel",
744 );
745 font_tools(&bin, &root.join("usr/share/fonts/quasi"));
746
747 let output = run_block(&font_guard_script(root), "server", "tauri", &bin);
748 assert!(
749 output.status.success(),
750 "the guard failed on a build host that correctly carries fontconfig and no face:\n{}",
751 String::from_utf8_lossy(&output.stderr),
752 );
753
754 let bare = Scratch::new("guard-server-tauri-bare");
755 let bare_root = bare.path();
756 let bare_bin = fake_bin(&bare);
757 rpm_tool(&bare_bin, "bash\nsystemd");
758
759 let output = run_block(&font_guard_script(bare_root), "server", "tauri", &bare_bin);
760 assert!(
761 !output.status.success(),
762 "the guard passed a GUI=tauri host with no fontconfig, so a mint that lost the \
763 toolkit packages would report nothing",
764 );
765 let stderr = String::from_utf8_lossy(&output.stderr);
766 assert!(
767 stderr.contains("no fontconfig"),
768 "the failure does not say fontconfig is what is missing:\n{stderr}",
769 );
770 }
771
772 /// And fails on each way that root could be wrong: fontconfig back on the
773 /// PATH, the faces installed, the browser's coverage installed, an emoji font.
774 #[test]
775 fn the_font_guard_catches_a_server_root_that_grew_a_font_stack() {
776 let cases: [(&str, &str, bool, &str); 4] = [
777 (
778 "fontconfig-returns",
779 "bash\nsystemd",
780 true,
781 "carries fontconfig",
782 ),
783 ("faces-installed", "bash\nsystemd", false, "house faces"),
784 (
785 "coverage-installed",
786 "bash\ndefault-fonts-cjk-sans",
787 false,
788 "font packages the client block installs",
789 ),
790 (
791 "emoji-installed",
792 "bash\ngoogle-noto-emoji-color-fonts",
793 false,
794 "emoji font package",
795 ),
796 ];
797
798 for (label, packages, with_font_tools, expected) in cases {
799 let scratch = Scratch::new(label);
800 let root = scratch.path();
801 let bin = fake_bin(&scratch);
802 rpm_tool(&bin, packages);
803 if with_font_tools {
804 font_tools(&bin, &root.join("usr/share/fonts/quasi"));
805 }
806 if label == "faces-installed" {
807 std::fs::create_dir_all(root.join("usr/share/fonts/quasi")).expect("faces");
808 }
809
810 let output = run_block(&font_guard_script(root), "server", "none", &bin);
811 assert!(
812 !output.status.success(),
813 "the guard passed a server root with {label}",
814 );
815 let stderr = String::from_utf8_lossy(&output.stderr);
816 assert!(
817 stderr.contains(expected),
818 "the {label} failure does not say what is wrong:\n{stderr}",
819 );
820 }
821 }
822
823 /// The client branch of the same guard still holds, on a root with the faces
824 /// installed and the coverage present. It is the half that was already right,
825 /// and inverting the other half is exactly the kind of edit that breaks it.
826 #[test]
827 fn the_font_guard_passes_on_a_client_root() {
828 let scratch = Scratch::new("guard-client");
829 let root = scratch.path();
830 let bin = fake_bin(&scratch);
831 let fonts = root.join("usr/share/fonts/quasi");
832 std::fs::create_dir_all(&fonts).expect("faces");
833 std::fs::write(fonts.join("QuasiMono[wght].ttf"), "face").expect("face");
834 font_tools(&bin, &fonts);
835 rpm_tool(&bin, "bash\ndefault-fonts-cjk-sans");
836
837 let output = run_block(&font_guard_script(root), "client", "tauri", &bin);
838 assert!(
839 output.status.success(),
840 "the guard failed on a client root that has everything it asks for:\n{}",
841 String::from_utf8_lossy(&output.stderr),
842 );
843 }
844
845 // ---------------------------------------------------------------------------
846 // The seeded bases, which are the other half of a build that can run.
847 // ---------------------------------------------------------------------------
848
849 /// The cut block, redirected into a fake root, with `git` and `cargo` stubbed
850 /// and the tool itself replaced by a script that records how it was called.
851 fn cut_script(root: &Path) -> String {
852 block_with("bases/pins.toml")
853 .replace(
854 " /quasi-type",
855 &format!(" {}", root.join("quasi-type").display()),
856 )
857 .replace("/base-cache", &root.join("base-cache").to_string_lossy())
858 .replace("/faces", &root.join("faces").to_string_lossy())
859 }
860
861 /// The pins, in the shape `quasi-type` writes them: two bases pinned file by
862 /// file, each with one variable face and a separate licence url, and one slot
863 /// cut from each.
864 ///
865 /// A fixture rather than the real file, because the real one lives in another
866 /// repository at a revision this test cannot assume is checked out. What it is
867 /// a fixture OF is the shape the block parses, not the pin values: the file
868 /// names below are the ones the current pins produce, checked by hand against
869 /// `~/Code/Libraries/quasi-type/bases/cache/`, and a pins file
870 /// whose shape stopped parsing would fail the build at the block's own "derived
871 /// no base file names" check rather than here.
872 const PINS: &str = "\
873 [[base]]\n\
874 id = \"atkinson-mono\"\n\
875 family = \"Atkinson Hyperlegible Mono\"\n\
876 version = \"2.001\"\n\
877 license = \"OFL-1.1\"\n\
878 license_url = \"https://example.invalid/mono/OFL.txt\"\n\
879 license_sha256 = \"1111111111111111111111111111111111111111111111111111111111111111\"\n\
880 copyright = \"Braille Institute\"\n\
881 designer = \"Braille Institute\"\n\
882 \n\
883 [[base.face]]\n\
884 style = \"ExtraLight\"\n\
885 url = \"https://example.invalid/mono/AtkinsonHyperlegibleMono%5Bwght%5D.ttf\"\n\
886 sha256 = \"2222222222222222222222222222222222222222222222222222222222222222\"\n\
887 variable = true\n\
888 \n\
889 [[base]]\n\
890 id = \"atkinson-next\"\n\
891 family = \"Atkinson Hyperlegible Next\"\n\
892 version = \"2.001\"\n\
893 license = \"OFL-1.1\"\n\
894 license_url = \"https://example.invalid/next/OFL.txt\"\n\
895 license_sha256 = \"3333333333333333333333333333333333333333333333333333333333333333\"\n\
896 copyright = \"Braille Institute\"\n\
897 designer = \"Braille Institute\"\n\
898 \n\
899 [[base.face]]\n\
900 style = \"ExtraLight\"\n\
901 url = \"https://example.invalid/next/AtkinsonHyperlegibleNext%5Bwght%5D.ttf\"\n\
902 sha256 = \"4444444444444444444444444444444444444444444444444444444444444444\"\n\
903 variable = true\n\
904 \n\
905 [[slot]]\n\
906 id = \"quasi-mono\"\n\
907 family = \"Quasi Mono\"\n\
908 base = \"atkinson-mono\"\n\
909 \n\
910 [[slot]]\n\
911 id = \"quasi-body\"\n\
912 family = \"Quasi Body\"\n\
913 base = \"atkinson-next\"\n\
914 ";
915
916 /// Every file the fixture's pins name, which is what a complete seed carries.
917 const PINNED: [&str; 4] = [
918 "atkinson-mono-2.001-AtkinsonHyperlegibleMono%5Bwght%5D.ttf",
919 "atkinson-mono-2.001-LICENSE.txt",
920 "atkinson-next-2.001-AtkinsonHyperlegibleNext%5Bwght%5D.ttf",
921 "atkinson-next-2.001-LICENSE.txt",
922 ];
923
924 fn cut_root(label: &str, seed: &[&str]) -> Scratch {
925 let scratch = Scratch::new(label);
926 let root = scratch.path();
927 let bin = fake_bin(&scratch);
928 // `git` is stubbed, so the checkout is whatever this function writes. The
929 // pins have to be part of it: the block derives the names it wants from
930 // them, and a checkout with no pins is a build that cannot check a seed.
931 write_executable(&bin.join("git"), "#!/bin/sh\nexit 0\n");
932 write_executable(&bin.join("cargo"), "#!/bin/sh\nexit 0\n");
933 // Records every url the mirror probe asks for and answers 404 to all of
934 // them, which is the state of a mirror that has not been deployed yet. A
935 // test that wants the other answer overwrites this stub.
936 write_executable(
937 &bin.join("curl"),
938 "#!/bin/sh\nfor arg; do case \"$arg\" in http*) printf '%s\\n' \"$arg\" \
939 >> \"$(dirname \"$0\")/../curls\" ;; esac; done\nexit 22\n",
940 );
941 // Records the mirror alongside the arguments. The probe's answer is about
942 // a fetch this tool makes, so a stage that reports a live mirror and then
943 // runs the cut without naming it has proved nothing.
944 write_executable(
945 &root.join("quasi-type/target/release/quasi-type"),
946 "#!/bin/sh\nprintf '%s [mirror=%s]\\n' \"$*\" \"${QUASI_TYPE_MIRROR:-unset}\" \
947 >> \"$(dirname \"$0\")/../../calls\"\n",
948 );
949 std::fs::create_dir_all(root.join("quasi-type/bases")).expect("bases dir");
950 std::fs::write(root.join("quasi-type/bases/pins.toml"), PINS).expect("pins");
951
952 std::fs::create_dir_all(root.join("base-cache")).expect("base cache");
953 std::fs::write(root.join("base-cache/README.md"), "not a base").expect("readme");
954 for file in seed {
955 std::fs::write(root.join("base-cache").join(file), "base bytes").expect("seed file");
956 }
957 scratch
958 }
959
960 /// Run the cut block against a fake root, in one of the two modes, with no
961 /// mirror named.
962 fn run_cut(scratch: &Scratch, mode: &str) -> std::process::Output {
963 run_cut_with_mirror(scratch, mode, "")
964 }
965
966 /// [`run_cut`] against a named mirror.
967 ///
968 /// Separate rather than a parameter on every call site, because a mirror is not
969 /// what any of the seed tests are about and an empty one is what a build with
970 /// `--build-arg QUASI_TYPE_MIRROR=` gets.
971 fn run_cut_with_mirror(scratch: &Scratch, mode: &str, mirror: &str) -> std::process::Output {
972 Command::new(shell())
973 .arg("-c")
974 .arg(cut_script(scratch.path()))
975 .env("QUASI_BASES", mode)
976 .env("QUASI_TYPE_REV", "deadbeef")
977 .env("QUASI_TYPE_MIRROR", mirror)
978 .env("PATH", scratch.join("bin").to_string_lossy().to_string())
979 .output()
980 .expect("running the cut block")
981 }
982
983 fn cut_calls(root: &Path) -> String {
984 std::fs::read_to_string(root.join("quasi-type/calls")).unwrap_or_default()
985 }
986
987 /// A seeded cache reaches the tool, and the README that keeps the directory in
988 /// git does not.
989 #[test]
990 fn the_seeded_bases_are_copied_into_the_checkouts_cache() {
991 let scratch = cut_root("seed-fetch", &[PINNED[1]]);
992 let root = scratch.path();
993
994 let output = run_cut(&scratch, "fetch");
995 assert!(
996 output.status.success(),
997 "the cut block failed:\n{}",
998 String::from_utf8_lossy(&output.stderr),
999 );
1000
1001 let cache = root.join("quasi-type/bases/cache");
1002 assert!(
1003 cache.join(PINNED[1]).exists(),
1004 "the seeded base did not reach the directory quasi-type reads",
1005 );
1006 assert!(
1007 !cache.join("README.md").exists(),
1008 "the README was copied in as though it were a base",
1009 );
1010 assert!(
1011 !cut_calls(root).contains("--offline"),
1012 "the default mode sealed the build; a stranger with an empty seed could not build",
1013 );
1014 }
1015
1016 /// `fetch` tolerates a partial seed, which is the whole difference between the
1017 /// two modes. A stranger cloning the repo has none of these files, and the
1018 /// three the seed does carry still have to be used rather than re-downloaded.
1019 #[test]
1020 fn fetch_runs_on_a_partial_seed() {
1021 let scratch = cut_root("seed-fetch-partial", &PINNED[..3]);
1022
1023 let output = run_cut(&scratch, "fetch");
1024 assert!(
1025 output.status.success(),
1026 "a partial seed stopped the default mode:\n{}",
1027 String::from_utf8_lossy(&output.stderr),
1028 );
1029 assert_eq!(
1030 cut_calls(scratch.path()).lines().count(),
1031 2,
1032 "the default mode did not cut both slots",
1033 );
1034 }
1035
1036 /// The mirror is probed for every digest the pins carry, and for nothing else.
1037 ///
1038 /// The probe derives the digests from the pinned checkout's own `pins.toml`
1039 /// rather than naming them here, which is the same rule the seed's file names
1040 /// follow: a list in this repo would be a second copy of the pin and would go
1041 /// stale on the first base version bump, reporting a live mirror as a dead one.
1042 /// The fixture's four digests are the two faces and the two licence texts.
1043 #[test]
1044 fn the_mirror_is_asked_for_every_pinned_digest() {
1045 let scratch = cut_root("mirror-probe", &[]);
1046 let output = run_cut_with_mirror(&scratch, "fetch", "https://mirror.invalid/bases");
1047 assert!(
1048 output.status.success(),
1049 "the mirror probe stopped the cut:\n{}",
1050 String::from_utf8_lossy(&output.stderr),
1051 );
1052
1053 let asked = std::fs::read_to_string(scratch.join("curls")).unwrap_or_default();
1054 let mut urls: Vec<&str> = asked.lines().collect();
1055 urls.sort_unstable();
1056 assert_eq!(
1057 urls,
1058 [
1059 "https://mirror.invalid/bases/1111111111111111111111111111111111111111111111111111111111111111",
1060 "https://mirror.invalid/bases/2222222222222222222222222222222222222222222222222222222222222222",
1061 "https://mirror.invalid/bases/3333333333333333333333333333333333333333333333333333333333333333",
1062 "https://mirror.invalid/bases/4444444444444444444444444444444444444444444444444444444444444444",
1063 ],
1064 "the probe asked for something other than the four digests the pins name",
1065 );
1066 }
1067
1068 /// A mirror that answers nothing is reported, not fatal.
1069 ///
1070 /// The whole argument for defaulting `QUASI_TYPE_MIRROR` to makenot.work is
1071 /// that it adds a source: every base still carries its upstream url and falls
1072 /// back to it, so a mirror that is down or not yet deployed is a slower first
1073 /// build. A build that failed here would turn a second source into a second
1074 /// dependency, which is the opposite of the point.
1075 #[test]
1076 fn a_mirror_that_holds_nothing_is_reported_rather_than_fatal() {
1077 let scratch = cut_root("mirror-empty", &PINNED);
1078 let output = run_cut_with_mirror(&scratch, "fetch", "https://mirror.invalid/bases");
1079 assert!(output.status.success(), "an empty mirror failed the build");
1080
1081 let said = String::from_utf8_lossy(&output.stdout);
1082 assert!(
1083 said.contains("holds 0 of 4 pinned file(s)"),
1084 "the build did not say what the mirror holds, so a mirror nobody deployed \
1085 reads the same as a live one:\n{said}",
1086 );
1087 assert_eq!(
1088 cut_calls(scratch.path()).lines().count(),
1089 2,
1090 "the cut did not run after the probe",
1091 );
1092 }
1093
1094 /// A mirror that holds the pinned files is reported as holding them, and
1095 /// nothing outside it is asked for.
1096 ///
1097 /// The other mirror tests answer 404 or name no mirror, so the path a deployed
1098 /// mirror takes had never run: this is the one that exercises the answer the
1099 /// files in the MNW server's `static/bases/` produce. Three things have to hold
1100 /// together for the stage to be worth its lines. The count is the whole report,
1101 /// so a mirror holding everything must not read as a partial one. The probe is
1102 /// content-addressed, so every request goes to the mirror and none to the hosts
1103 /// the pins name, which is the rate limiter this exists to stop asking. And the
1104 /// mirror has to reach the tool: the fetch the report is about is quasi-type's,
1105 /// several lines below the probe, and it finds the mirror in the environment.
1106 #[test]
1107 fn a_mirror_that_holds_the_pinned_files_is_reported_and_reaches_the_cut() {
1108 let mirror = "https://mirror.invalid/bases";
1109 let scratch = cut_root("mirror-hit", &[]);
1110 a_mirror_that_answers(&scratch, mirror);
1111
1112 let output = run_cut_with_mirror(&scratch, "fetch", mirror);
1113 assert!(
1114 output.status.success(),
1115 "the cut failed against a live mirror:\n{}",
1116 String::from_utf8_lossy(&output.stderr),
1117 );
1118
1119 let said = String::from_utf8_lossy(&output.stdout);
1120 assert!(
1121 said.contains("holds 4 of 4 pinned file(s)"),
1122 "a mirror holding every pinned file did not report as complete:\n{said}",
1123 );
1124
1125 let asked = std::fs::read_to_string(scratch.join("curls")).unwrap_or_default();
1126 assert_eq!(
1127 asked.lines().count(),
1128 4,
1129 "the probe made other requests:\n{asked}"
1130 );
1131 for url in asked.lines() {
1132 assert!(
1133 url.starts_with(&format!("{mirror}/")),
1134 "the stage asked `{url}`, which is not the mirror; the pinned hosts are \
1135 the ones a build must stop asking",
1136 );
1137 }
1138
1139 let calls = cut_calls(scratch.path());
1140 assert_eq!(
1141 calls
1142 .lines()
1143 .filter(|call| call.contains(&format!("[mirror={mirror}]")))
1144 .count(),
1145 2,
1146 "the cut ran without the mirror the stage just probed:\n{calls}",
1147 );
1148 assert!(
1149 containerfile().contains("ENV QUASI_TYPE_MIRROR=${QUASI_TYPE_MIRROR}"),
1150 "the mirror is a build argument the stage does not export, so it reaches \
1151 neither the cut nor `cargo install shop`",
1152 );
1153 }
1154
1155 /// The curl stub, rewritten to answer a mirror that holds everything.
1156 ///
1157 /// Still records every url, because what the mirror answers and what the stage
1158 /// asks for are separate assertions: a probe that asked the pinned hosts as
1159 /// well would pass on the count alone.
1160 fn a_mirror_that_answers(scratch: &Scratch, mirror: &str) {
1161 write_executable(
1162 &scratch.join("bin/curl"),
1163 &format!(
1164 "#!/bin/sh\nstatus=22\nfor arg; do case \"$arg\" in\n {mirror}/*) status=0 ;;\n\
1165 esac; case \"$arg\" in http*) printf '%s\\n' \"$arg\" \
1166 >> \"$(dirname \"$0\")/../curls\" ;; esac; done\nexit $status\n",
1167 ),
1168 );
1169 }
1170
1171 /// No mirror named, no request made, and the log says which it was.
1172 ///
1173 /// `--build-arg QUASI_TYPE_MIRROR=` is the documented way to build against
1174 /// upstream alone, and a probe that ran anyway would be a request a person
1175 /// asked not to make.
1176 #[test]
1177 fn an_empty_mirror_is_not_probed() {
1178 let scratch = cut_root("mirror-off", &PINNED);
1179 let output = run_cut(&scratch, "fetch");
1180 assert!(output.status.success(), "the unmirrored cut failed");
1181
1182 assert!(
1183 !scratch.join("curls").exists(),
1184 "a build with no mirror still made a request",
1185 );
1186 assert!(
1187 String::from_utf8_lossy(&output.stdout).contains("mirror: not consulted"),
1188 "the build did not say the mirror was skipped",
1189 );
1190 }
1191
1192 /// `sealed` names no mirror either, because a sealed cut fetches nothing.
1193 ///
1194 /// The probe would be a request in the one mode whose point is that there are
1195 /// none, and its answer could not change anything: `--offline` means a file the
1196 /// seed is short of fails rather than being fetched from anywhere.
1197 #[test]
1198 fn a_sealed_cut_does_not_probe_the_mirror() {
1199 let scratch = cut_root("mirror-sealed", &PINNED);
1200 let output = run_cut_with_mirror(&scratch, "sealed", "https://mirror.invalid/bases");
1201 assert!(
1202 output.status.success(),
1203 "the sealed cut failed:\n{}",
1204 String::from_utf8_lossy(&output.stderr),
1205 );
1206 assert!(
1207 !scratch.join("curls").exists(),
1208 "a sealed build probed a mirror it may not fetch from",
1209 );
1210 }
1211
1212 /// `sealed` is the mode with a guarantee: the cut may not reach the network.
1213 /// With every pinned file seeded it runs, and both slots are cut offline.
1214 #[test]
1215 fn sealed_forbids_the_fetch() {
1216 let scratch = cut_root("seed-sealed", &PINNED);
1217 let root = scratch.path();
1218
1219 let output = run_cut(&scratch, "sealed");
1220 assert!(
1221 output.status.success(),
1222 "the sealed cut failed with a complete seed:\n{}",
1223 String::from_utf8_lossy(&output.stderr),
1224 );
1225
1226 let calls = cut_calls(root);
1227 assert_eq!(
1228 calls
1229 .lines()
1230 .filter(|call| call.contains("--offline"))
1231 .count(),
1232 2,
1233 "both slots have to be cut offline, or the sealed build still reaches out:\n{calls}",
1234 );
1235 }
1236
1237 /// A seed that is present but short is the failure this gate exists for, and it
1238 /// is the one a count could not see.
1239 ///
1240 /// The names are long and percent-encoded, so a hand-copied seed missing one
1241 /// file, or carrying it under a name the pins do not use, is the likely
1242 /// mistake. Accepting any non-empty directory means dying in the cut, after
1243 /// building the tool. `sealed` has to name what is missing, and it has to do it
1244 /// before `cargo build`, or the gate buys nothing over the cut's own offline
1245 /// error.
1246 #[test]
1247 fn sealed_with_a_partial_seed_fails_before_the_build() {
1248 let scratch = cut_root("seed-sealed-partial", &PINNED[..1]);
1249 let root = scratch.path();
1250
1251 let output = run_cut(&scratch, "sealed");
1252 assert!(
1253 !output.status.success(),
1254 "a sealed build with one of the four pinned files was allowed to proceed",
1255 );
1256 let stderr = String::from_utf8_lossy(&output.stderr);
1257 for missing in &PINNED[1..] {
1258 assert!(
1259 stderr.contains(missing),
1260 "the failure does not name the missing `{missing}`:\n{stderr}",
1261 );
1262 }
1263 assert!(
1264 !stderr.contains(PINNED[0]),
1265 "the failure names a file the seed carries, so the reader cannot tell which \
1266 ones to copy:\n{stderr}",
1267 );
1268 assert!(
1269 cut_calls(root).is_empty(),
1270 "the tool ran anyway; the check is meant to be cheaper than the build",
1271 );
1272 }
1273
1274 /// Sealing an empty seed is the same mistake at its widest, and the message has
1275 /// to say what to do about it rather than only what is wrong.
1276 #[test]
1277 fn sealed_with_nothing_seeded_fails_before_the_build() {
1278 let scratch = cut_root("seed-sealed-empty", &[]);
1279 let root = scratch.path();
1280
1281 let output = run_cut(&scratch, "sealed");
1282 assert!(
1283 !output.status.success(),
1284 "a sealed build with an empty seed was allowed to proceed",
1285 );
1286 let stderr = String::from_utf8_lossy(&output.stderr);
1287 assert!(
1288 stderr.contains("base-cache"),
1289 "the failure does not name the directory to seed:\n{stderr}",
1290 );
1291 assert!(
1292 cut_calls(root).is_empty(),
1293 "the tool ran anyway; the check is meant to be cheaper than the build",
1294 );
1295 }
1296
1297 /// A checkout whose pins the block cannot read fails rather than sealing
1298 /// nothing.
1299 ///
1300 /// The gate derives what a complete seed is from `bases/pins.toml`, so a pins
1301 /// file that moved or changed shape would leave it with an empty list, and an
1302 /// empty list is satisfied by an empty seed. That is the failure mode a derived
1303 /// check has and a hardcoded list does not, so it is asserted.
1304 #[test]
1305 fn a_checkout_with_unreadable_pins_fails() {
1306 let scratch = cut_root("seed-no-pins", &PINNED);
1307 std::fs::write(scratch.join("quasi-type/bases/pins.toml"), "# moved\n").expect("pins");
1308
1309 let output = run_cut(&scratch, "sealed");
1310 assert!(
1311 !output.status.success(),
1312 "the block sealed a build whose pins it could not read",
1313 );
1314 assert!(
1315 String::from_utf8_lossy(&output.stderr).contains("pins"),
1316 "the failure does not say the pins are what could not be read",
1317 );
1318 }
1319
1320 /// An unknown mode dies at the top of the block, for the reason the Containerfile
1321 /// validates `PROFILE` before anything reads it: a typo must not fall through to
1322 /// the branch that reaches the network.
1323 #[test]
1324 fn an_unknown_bases_mode_is_rejected() {
1325 let scratch = cut_root("seed-typo", &PINNED);
1326
1327 let output = run_cut(&scratch, "seeled");
1328 assert!(
1329 !output.status.success(),
1330 "an unknown QUASI_BASES was accepted"
1331 );
1332 assert!(
1333 cut_calls(scratch.path()).is_empty(),
1334 "the cut ran under a mode nobody defined",
1335 );
1336 }
1337
1338 /// The seed directory has to survive the build context, which is the failure
1339 /// that took the image down for two days in August: an exclusion and a COPY
1340 /// that cancelled, caught by nothing because no image was built in the window.
1341 /// `build_context.rs` holds the general rule; this is the one path that rule
1342 /// would not have covered before the directory existed.
1343 ///
1344 /// What this proves is that the negations are there and that they follow the
1345 /// exclusion, which is what `.containerignore`'s own comment says is required.
1346 /// What it does NOT prove is that a matcher re-includes a directory's contents
1347 /// from the directory negation alone. That was not measured, because measuring
1348 /// it means running a build, and it is why the file carries the `/**` form
1349 /// beside it: every negation in that file that a shipped build has proven names
1350 /// files, so the file-shaped pattern is the one carrying the weight and the
1351 /// directory-shaped one is there in case the matcher wants it instead. A
1352 /// `podman build` that COPYs a seeded directory and lists what arrived would
1353 /// settle which of the two is load-bearing, and until someone runs one, neither
1354 /// line comes out.
1355 #[test]
1356 fn the_seed_directory_is_in_the_repo_and_in_the_context() {
1357 let dir = repo_root().join("build/base-cache");
1358 assert!(
1359 dir.join("README.md").exists(),
1360 "build/base-cache has no README, so git does not carry the directory and the COPY \
1361 reads nothing",
1362 );
1363 let ignore = std::fs::read_to_string(repo_root().join(".containerignore"))
1364 .expect("cannot read .containerignore");
1365 let build_at = ignore.find("\n/build\n").expect("no /build exclusion");
1366 for negation in ["!/build/base-cache\n", "!/build/base-cache/**"] {
1367 let at = ignore.find(negation).unwrap_or_else(|| {
1368 panic!(
1369 "`{}` is missing; the seed directory is excluded from the build context \
1370 with too little to let it back in",
1371 negation.trim_end(),
1372 )
1373 });
1374 assert!(
1375 at > build_at,
1376 "`{}` precedes the exclusion, which .containerignore's own comment warns does \
1377 nothing",
1378 negation.trim_end(),
1379 );
1380 }
1381 }
1382
1383 // ---------------------------------------------------------------------------
1384 // What the seed is documented to do, against what it does.
1385 // ---------------------------------------------------------------------------
1386
1387 fn manual() -> String {
1388 let path = repo_root().join("docs/manual/02-building-your-image.md");
1389 std::fs::read_to_string(&path)
1390 .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()))
1391 }
1392
1393 fn seed_readme() -> String {
1394 let path = repo_root().join("build/base-cache/README.md");
1395 std::fs::read_to_string(&path)
1396 .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()))
1397 }
1398
1399 /// The seed cannot cover the fetch that caused the outage, and every document
1400 /// that describes it has to say so.
1401 ///
1402 /// `cargo install shop` runs above the `COPY`, and moving the COPY would not
1403 /// change it: shop's build script passes its own `OUT_DIR` to `quasi_type::cut`
1404 /// with `offline` hardcoded false, so nothing in this repo can aim it at a
1405 /// carried copy. The manual promised the opposite, that `sealed` fails before
1406 /// "an hour of work that ends at somebody else's rate limiter", when the one
1407 /// fetch it cannot cover is the early one.
1408 ///
1409 /// The assertion is conditional on the ordering rather than unconditional,
1410 /// because the ordering is the fact that makes the limit true. If shop grows
1411 /// the contract and the COPY moves above the install, this test stops demanding
1412 /// the caveat instead of demanding a stale one.
1413 #[test]
1414 fn the_docs_state_the_fetch_the_seed_cannot_cover() {
1415 let text = containerfile();
1416 let install = text
1417 .find("RUN cargo install")
1418 .expect("no `cargo install` of the terminal");
1419 let copy = text
1420 .find("COPY build/base-cache/")
1421 .expect("the seed is not copied into the build");
1422 if install > copy {
1423 return;
1424 }
1425
1426 for (label, doc) in [
1427 ("the Containerfile", &text),
1428 ("the manual", &manual()),
1429 ("the seed README", &seed_readme()),
1430 ] {
1431 assert!(
1432 doc.contains("does not cover"),
1433 "{label} describes the seed without saying which fetch it does not cover, \
1434 and the one it does not cover is the one that broke the build",
1435 );
1436 }
1437 }
1438
1439 /// A seeded build is not an offline build, and no document may say it is.
1440 ///
1441 /// The block still clones two repositories and resolves crates.io before the
1442 /// cut, and shop's face is cut over the network above it. A reader who acts on
1443 /// "a seeded build makes no request at all" takes a build host offline and
1444 /// watches it fail.
1445 #[test]
1446 fn the_docs_do_not_promise_an_offline_build() {
1447 for (label, doc) in [("the manual", manual()), ("the seed README", seed_readme())] {
1448 assert!(
1449 !doc.contains("no request at all"),
1450 "{label} still claims a seeded build makes no request, and the clone, the \
1451 crates.io resolve and shop's own cut all disagree",
1452 );
1453 }
1454 }
1455
1456 /// The retry story is one story, and the two places that tell it have to agree.
1457 ///
1458 /// The manual said the retry "covers a transient answer and not a rate limit",
1459 /// while the Containerfile said it covers 429. curl settles it: `--retry`
1460 /// retries 408, 429 and the 5xx family, which is what quasi-type's own fetch
1461 /// documents. A reader believing the manual would retry the build by hand
1462 /// instead of seeding, which is the opposite of the advice underneath it.
1463 #[test]
1464 fn the_docs_agree_that_the_retry_covers_a_429() {
1465 assert!(
1466 containerfile().contains("covers 429"),
1467 "the Containerfile no longer says what the retry covers",
1468 );
1469 assert!(
1470 !manual().contains("not a rate limit"),
1471 "the manual says the retry does not cover a rate limit, and curl retries a 429 \
1472 like any other transient answer",
1473 );
1474 }
1475
1476 /// Both documented escape hatches name what they cost.
1477 ///
1478 /// `-v <dir>:/base-cache:ro` needs a bare `podman build`: neither wrapper
1479 /// script forwards anything but `--build-arg`, and a bare build stamps no
1480 /// version into the image, which docs/IMAGE.md describes as reporting
1481 /// `0.1 (Fedora 43)`. Offering it as a plain alternative sends a reader to an
1482 /// unstamped image for the sake of keeping four files out of a directory.
1483 ///
1484 /// Keyed on the mount itself rather than on the flag. The skip was
1485 /// `doc.contains("-v ")`, and the manual carries zero of those: it wraps as
1486 /// ``podman build -v`` then ``<dir>:/base-cache:ro`` on the next line, and
1487 /// writes the flag elsewhere as ``a `-v`,``. So the one document this test
1488 /// names first was skipped by its own guard and only the README was ever
1489 /// asserted on, while the test read as covering both. `:/base-cache:ro` is the
1490 /// mount's own spelling and survives however the line is broken.
1491 #[test]
1492 fn the_documented_mount_route_names_what_it_costs() {
1493 let mut offered = Vec::new();
1494 for (label, doc) in [("the manual", manual()), ("the seed README", seed_readme())] {
1495 if !doc.contains(":/base-cache:ro") {
1496 continue;
1497 }
1498 offered.push(label);
1499 assert!(
1500 doc.contains("build-arg") && doc.contains("stamp"),
1501 "{label} offers the mount route without saying that the wrapper scripts \
1502 forward only --build-arg and that a bare podman build carries no stamp",
1503 );
1504 }
1505
1506 // The skip above is what made this test vacuous once already, so a
1507 // document that stops offering the route says so here rather than
1508 // quietly taking its assertion with it.
1509 assert_eq!(
1510 offered,
1511 ["the manual", "the seed README"],
1512 "a document stopped documenting the mount route; if that is deliberate, \
1513 drop it from this test rather than letting the skip hide it",
1514 );
1515 }
1516
1517 /// The terminal's dependencies are a claim about another repository, and it
1518 /// drifts silently.
1519 ///
1520 /// The Containerfile documents an affordance: a server operator who wants the
1521 /// terminal can install it from the carried repo. Dropping fontconfig from the
1522 /// server profile changed what that install needs, since `shop.spec` requires
1523 /// fontconfig by name. The note now says the install resolves over the network
1524 /// and names the packages it means. If the spec stops requiring fontconfig, the
1525 /// note is stale in the other direction, and this is what says so.
1526 #[test]
1527 fn the_layering_note_matches_the_terminals_spec() {
1528 let spec = std::fs::read_to_string(repo_root().join("build/rpm/shop.spec"))
1529 .expect("cannot read build/rpm/shop.spec");
1530 let requires: Vec<String> = spec
1531 .lines()
1532 .filter_map(|line| line.strip_prefix("Requires:"))
1533 .map(|name| name.trim().to_string())
1534 .collect();
1535 assert!(
1536 requires.iter().any(|name| name == "fontconfig"),
1537 "shop.spec no longer requires fontconfig, so the Containerfile's note about a \
1538 server operator layering the terminal names a dependency that is gone:\n{requires:?}",
1539 );
1540
1541 let text = containerfile();
1542 let note = text
1543 .find("A server operator who wants the")
1544 .expect("the layering affordance is no longer documented");
1545 let tail = &text[note..note + 1200.min(text.len() - note)];
1546 assert!(
1547 tail.contains("shop.spec") && tail.contains("fontconfig"),
1548 "the layering affordance does not say what the install needs, on a profile that \
1549 carries none of it:\n{tail}",
1550 );
1551 }
1552