Skip to main content

max / alloy

30.8 KB · 719 lines History Blame Raw
1 //! Everything the install does to a deployment once bootc has made one.
2 //!
3 //! One ordered emission of stages, and the order is the correctness property:
4 //! the group before the account that joins it, the key and the display seed
5 //! before the recursive chown that owns them, the enrollment last because the
6 //! TPM slot authorizing it only answers while the volume is open. Kept whole
7 //! for that reason.
8
9 use crate::cli::{Invocation, Secret};
10 use crate::run::Stage;
11
12 use super::account::{
13 HOME_MODE, HOME_PARENT, LOGIN_SHELL, SSH_DIR_MODE, SSH_FILE_MODE, hash_password, passwd_ids,
14 shell_is_registered, video_group_line,
15 };
16 use super::encryption::{Encryption, enroll_plan};
17 use super::image::{relabel_stages, update_timer_stages, updates_scheduled};
18 use super::target::{in_target, stateroot_var};
19 use super::timezone::timezone_stages;
20
21 /// What lands in the new home besides the skeleton.
22 ///
23 /// Grouped because they are the same kind of thing and go to the same place:
24 /// both are optional, both are written under the home, and both have to precede
25 /// the recursive chown that makes it the user's. Neither is an answer the wizard
26 /// insists on — a key is optional by the minting decision, and a screen is
27 /// something the machine either reports or does not.
28 #[derive(Debug, Default)]
29 pub(super) struct HomeSeeds<'a> {
30 /// The SSH public key to authorize, if one was given.
31 pub(super) pubkey: Option<&'a str>,
32 /// The screens this machine can describe, empty when it can describe none.
33 /// Plural because a lid-closed install is looking at an external monitor and
34 /// seeding only the built-in panel configures the screen nobody can see. See
35 /// [`crate::display::detect_outputs`].
36 pub(super) screens: &'a [crate::display::Output],
37 }
38
39 /// The commands that configure an already-deployed system.
40 ///
41 /// `root` is the ostree deployment directory, not the mountpoint. See
42 /// [`deployment_dir`](super::target::deployment_dir) for why that distinction
43 /// is the whole ballgame, and [`stateroot_var`] for the half of it that
44 /// `--root` does not cover.
45 ///
46 /// [`Stage`]s rather than plain [`Invocation`]s because three of these depend
47 /// on values that only exist once the ones before them have run: the account's
48 /// numeric ids, the password hash, and the timezone the network was asked for.
49 pub(super) fn configure_plan(
50 hostname: &str,
51 username: &str,
52 password: &str,
53 seeds: &HomeSeeds<'_>,
54 locate_timezone: bool,
55 encryption: Option<&Encryption<'_>>,
56 root: &str,
57 ) -> Result<Vec<Stage>, String> {
58 let var = stateroot_var(root)?;
59 // Where the home is now, through the mounted target...
60 let home = format!("{var}/home/{username}");
61 // ...and where it will be once the system it belongs to is running.
62 let installed_home = format!("{HOME_PARENT}/{username}");
63
64 let hash = hash_password(password)?;
65
66 let ids_root = root.to_string();
67 let ids_user = username.to_string();
68 let ids_home = home.clone();
69
70 let group_root = root.to_string();
71 let group_file = format!("{root}/etc/group");
72
73 let useradd_root = root.to_string();
74 let useradd_user = username.to_string();
75 let useradd_home = installed_home.clone();
76
77 let mut stages = vec![
78 // Before anything is written: a shell the target will not hand out
79 // makes an account nobody can log into, and useradd will not catch it.
80 Stage::Resolve {
81 invocation: Invocation::new("cat").arg(format!("{root}/etc/shells")),
82 then: Box::new(move |listing| {
83 shell_is_registered(listing, LOGIN_SHELL)?;
84 Ok(Vec::new())
85 }),
86 },
87 Stage::Run(
88 Invocation::new("systemd-firstboot")
89 .arg(format!("--root={root}"))
90 .arg(format!("--hostname={hostname}"))
91 // Without --force firstboot skips any setting already present
92 // in the image, and every Alloy image has an /etc/hostname: an
93 // empty one, which podman leaves behind as the target of the
94 // bind mount it makes over that path during each RUN. Present
95 // and empty is the worst shape for this, since it is enough for
96 // firstboot to skip and not enough for systemd to use, so the
97 // machine would answer to the fallback name with the user's
98 // answer discarded and nothing said about it.
99 .arg("--force"),
100 ),
101 // Before useradd, because useradd is what rejects a group it cannot
102 // find, and it rejects it by failing the install outright. See
103 // [`video_group_line`] for why the group is missing from the file that
104 // useradd reads while being present everywhere a person would look.
105 //
106 // Reading the file rather than asking getent, for the reason the uid
107 // discovery below states: getent answers about this machine, and the
108 // question is about the target. Here that distinction is the entire
109 // bug rather than a precaution.
110 Stage::Resolve {
111 invocation: Invocation::new("cat").arg(format!("{group_root}/etc/group")),
112 then: Box::new(move |etc_group| {
113 // Absent or unreadable resolves the same way a missing `video`
114 // does, to no stages: the account is still created, still gets
115 // wheel, and still logs in. Only the brightness keys are lost,
116 // which is the symptom this whole path exists to fix and not a
117 // reason to abort an install whose disk is already gone.
118 let lib_group = std::fs::read_to_string(format!("{group_root}/usr/lib/group"))
119 .unwrap_or_default();
120 let Some(line) = video_group_line(etc_group, &lib_group) else {
121 return Ok(Vec::new());
122 };
123 Ok(vec![Stage::Run(
124 Invocation::new("tee")
125 .arg("-a")
126 .arg(&group_file)
127 .stdin(Secret::new(line)),
128 )])
129 }),
130 },
131 // useradd rather than a sysusers entry because this account needs a home
132 // and supplementary groups. wheel is the group Fedora's polkit resolves
133 // an administrator to, which is what `run0` and every writing console
134 // view ask for, and an install whose only account cannot escalate is one
135 // with no way to administer itself. See wiki note `alloy-privilege`.
136 //
137 // video is what makes the brightness keys work, and it is not
138 // interchangeable with wheel. SwayOSD sets brightness by writing
139 // /sys/class/backlight/<dev>/brightness, which is root-owned 0644 until
140 // a udev rule chgrp's it to `video` and adds group write; the
141 // Containerfile puts that rule on udev's search path, since Fedora ships
142 // it somewhere udev does not read. Both halves are required: the rule
143 // with an empty group grants nothing, and the group with no rule has
144 // nothing to grant. Found the hard way on 2026-07-29, when the only
145 // running install had a working volume OSD and dead brightness keys.
146 //
147 // Not `wheel` doing this job instead: the sysfs write is the *only*
148 // privilege being handed out here, it is one file per backlight device,
149 // and `video` is the group every distribution already uses for it.
150 // Widening wheel's meaning to cover a hardware attribute would put
151 // brightness control behind administrator rights on any future account
152 // that deliberately has none.
153 //
154 // --no-create-home despite the account needing one: --create-home
155 // resolves the path inside the target, which puts the directory in the
156 // deployment's own var, where nothing will ever look for it. useradd
157 // says as much, "chown on '/var/home/<user>' failed", because the
158 // stateroot var ships with cache, lib, log, run and tmp and no home at
159 // all. The directory is made below, in the var that is real.
160 // Prints "Creating mailbox file: No such file or directory" and exits
161 // 0 anyway: the stateroot var has no mail spool for it to write into.
162 // Harmless, but the run screen streams this, so the user watching
163 // their install reads it as a failure. Left alone rather than
164 // papered over: a spool nothing on the image delivers to would be
165 // invented purely to quiet a warning.
166 Stage::Resolve {
167 invocation: Invocation::new("cat").arg(format!("{useradd_root}/etc/passwd")),
168 then: Box::new(move |listing| {
169 // useradd exits 9 against an account that already exists, so
170 // an unguarded call is a stage that can only run once. The
171 // recovery re-runs this whole plan against a disk that may
172 // already carry the account, and a stage that fails because
173 // its work was already done tells nobody anything.
174 //
175 // Asked of the target's own passwd file rather than of getent,
176 // for the reason the id lookup below states: getent answers
177 // about the machine doing the installing. That lookup reads
178 // this file again on purpose, after useradd has written to it.
179 if passwd_ids(listing, &useradd_user).is_ok() {
180 return Ok(Vec::new());
181 }
182 Ok(vec![Stage::Run(
183 in_target(&useradd_root, "useradd")
184 .arg("--no-create-home")
185 .args(["--home-dir", &useradd_home])
186 .args(["--shell", LOGIN_SHELL])
187 .args(["--groups", "wheel,video"])
188 .arg(&useradd_user),
189 )])
190 }),
191 },
192 // -p because /var/home does not exist yet either.
193 Stage::Run(Invocation::new("mkdir").args(["-p", &home])),
194 // The dotfiles --create-home would have copied. Without them an
195 // interactive shell never sources /etc/bashrc.
196 Stage::Run(
197 Invocation::new("cp")
198 .arg("-a")
199 .arg(format!("{root}/etc/skel/."))
200 .arg(&home),
201 ),
202 ];
203
204 // Before the chown below, deliberately. That stage is already recursive over
205 // the home directory, so a `.ssh` written here is covered by it and there is
206 // no second place that has to know the numeric ids. Writing the key after it
207 // would leave the directory root-owned, and sshd refuses an authorized_keys
208 // it does not trust the ownership of, silently.
209 if let Some(key) = seeds.pubkey {
210 let ssh_dir = format!("{home}/.ssh");
211 let authorized = format!("{ssh_dir}/authorized_keys");
212 stages.extend([
213 Stage::Run(Invocation::new("mkdir").args(["-p", &ssh_dir])),
214 // `tee` because the key arrives on stdin, and stdin because an
215 // Invocation is argv with no shell (see [`Invocation`]) so there is
216 // no redirect to write with. The value goes through [`Secret`],
217 // which is the only stdin this type takes; the redaction that comes
218 // with it is incidental rather than a claim that a public key needs
219 // hiding.
220 //
221 // Note that tee copies stdin to stdout, so the key does appear in
222 // the run screen's streamed output even though it is absent from the
223 // rendered argv. That is wanted rather than tolerated: this half of
224 // the pair is meant to be published, and seeing it echoed is how the
225 // user confirms the key that landed is the key they pasted.
226 //
227 // Trailing newline: sshd parses authorized_keys by line, and a final
228 // line without one is not reliably read.
229 Stage::Run(
230 Invocation::new("tee")
231 .arg(&authorized)
232 .stdin(Secret::new(format!("{key}\n"))),
233 ),
234 // sshd enforces these itself: a group- or world-writable .ssh or
235 // authorized_keys is ignored, with the reason going only to the
236 // server's log. So getting them wrong produces exactly the symptom
237 // this whole field exists to prevent.
238 Stage::Run(Invocation::new("chmod").args([SSH_DIR_MODE, &ssh_dir])),
239 Stage::Run(Invocation::new("chmod").args([SSH_FILE_MODE, &authorized])),
240 ]);
241 }
242
243 // The scale each screen wants, before anyone has logged in to set it.
244 //
245 // Same placement argument as the key above: the recursive chown below
246 // covers whatever is under the home, so writing here costs no second place
247 // that has to know the numeric ids.
248 //
249 // Passed in rather than detected here, because detection reads this
250 // machine's sysfs and a plan that changed shape with the screens of whoever
251 // ran the tests would be untestable. [`crate::display::detect_outputs`] is
252 // called once, where the plan is built.
253 if !seeds.screens.is_empty() {
254 let file = format!("{home}/{}", crate::display::FILE);
255 let dir = file
256 .rsplit_once('/')
257 .map_or_else(|| home.clone(), |(parent, _)| parent.to_string());
258 stages.extend([
259 Stage::Run(Invocation::new("mkdir").args(["-p", &dir])),
260 // `tee` and [`Secret`] for the reason the key above gives: an
261 // Invocation is argv with no shell, so stdin is the only way to
262 // hand over a whole file, and Secret is the only stdin it takes.
263 // Nothing here is secret, and the run screen showing the config it
264 // wrote is wanted.
265 Stage::Run(
266 Invocation::new("tee")
267 .arg(&file)
268 .stdin(Secret::new(crate::display::config_file(seeds.screens))),
269 ),
270 ]);
271 }
272
273 stages.extend([
274 // Which uid and gid useradd picked. Reading the file rather than asking
275 // getent, because getent answers about this machine.
276 //
277 // The second read of this file, and not a redundant one: the account
278 // stage reads it first to decide whether it has an account to create,
279 // and useradd writes to it in between. The first read asks whether the
280 // account exists, this one asks what it turned out to be, and merging
281 // them would mean asking for ids before the line carrying them exists.
282 Stage::Resolve {
283 invocation: Invocation::new("cat").arg(format!("{ids_root}/etc/passwd")),
284 then: Box::new(move |listing| {
285 let ids = passwd_ids(listing, &ids_user)?;
286 Ok(vec![
287 Stage::Run(Invocation::new("chown").args(["-R", &ids, &ids_home])),
288 Stage::Run(Invocation::new("chmod").args([HOME_MODE, &ids_home])),
289 ])
290 }),
291 },
292 // `chpasswd` reads `user:hash` lines, so the username is part of the
293 // input rather than an argument. Built here so exactly one place knows
294 // the wire format, and with a trailing newline because chpasswd parses
295 // lines and a final one without it is silently ignored by some builds.
296 Stage::Run(
297 in_target(root, "chpasswd")
298 .arg("--encrypted")
299 .stdin(Secret::new(format!("{username}:{hash}\n"))),
300 ),
301 ]);
302
303 // Last of the answers, and deliberately after the account: it is the only
304 // stage that can decline to do anything, and the only one that touches the
305 // network. Nothing below it depends on the result.
306 stages.extend(timezone_stages(locate_timezone, root));
307
308 // After the account for the same reason the timezone is: nothing depends on
309 // it, and an install that fails here has still produced a machine somebody
310 // can log into. Before the enrollment below, so the two stages that must run
311 // while the volume is open stay last.
312 stages.extend(update_timer_stages(updates_scheduled(), root));
313
314 // Last of the stages that touch the target's /etc, because it fixes what
315 // all of them wrote.
316 stages.extend(relabel_stages(root));
317
318 // Before finalize and umount, because the TPM slot that authorizes these is
319 // only guaranteed to answer while the volume this deployment sits in is
320 // still open.
321 //
322 // The position relative to the account is not a judgment about which wreck
323 // reads better. It follows from the invariant in [`non_tpm_slot_exists`]:
324 // no LUKS volume is left reachable only by a TPM slot. An account can be
325 // added later from any live medium; a keyslot cannot be added to a volume
326 // nobody can open. So the irrecoverable thing is not what gets left to
327 // last, and if this does fail, [`recover_plan`] runs it again rather than
328 // accepting the order's consolation prize.
329 if let Some(encryption) = encryption {
330 stages.extend(enroll_plan(
331 encryption.partition,
332 encryption.passphrase,
333 encryption.recovery,
334 ));
335 }
336
337 Ok(stages)
338 }
339 #[cfg(test)]
340 mod tests {
341 use super::super::encryption::Encryption;
342 use super::super::fixtures::{
343 DEPLOYMENT, ED25519_BODY, command_starting, configured, runs, shown,
344 };
345 use super::super::timezone::GEO_HOST;
346 use super::*;
347 use crate::run::Stage;
348
349 // An unencrypted install must not enroll anything: there is no LUKS header
350 // to enroll into, and cryptenroll against a plain partition fails the
351 // sequence after the account has already been made.
352 #[test]
353 fn an_unencrypted_configure_enrolls_nothing() {
354 let shown = configured();
355 for line in &shown {
356 assert!(!line.contains("cryptenroll"), "{line}");
357 }
358 }
359
360 #[test]
361 fn an_encrypted_configure_enrolls_last() {
362 let encryption = Encryption {
363 partition: "/dev/sda3",
364 passphrase: "opensesame",
365 recovery: "eight words here",
366 };
367 let stages = configure_plan(
368 "workshop",
369 "max",
370 "hunter2",
371 &HomeSeeds::default(),
372 false,
373 Some(&encryption),
374 DEPLOYMENT,
375 )
376 .expect("a well-formed deployment path");
377 let shown = shown(stages);
378
379 assert_eq!(
380 shown.iter().filter(|l| l.contains("cryptenroll")).count(),
381 2,
382 "{shown:#?}"
383 );
384 // The configuration ends here, and its caller appends the acceptance
385 // checks, the finalize and the unmount. The TPM slot authorizing these
386 // is only guaranteed to answer while the volume is still open, so
387 // nothing may be added after them inside this plan.
388 assert!(
389 shown[shown.len() - 2..]
390 .iter()
391 .all(|line| line.contains("cryptenroll")),
392 "the configuration does not end with the enrollment: {shown:#?}"
393 );
394 }
395
396 // --force matters: etc/hostname ships with "alloy" already in it, and
397 // firstboot skips any setting already present. Without the flag the user's
398 // answer is silently discarded.
399 #[test]
400 fn firstboot_forces_over_the_images_baked_in_hostname() {
401 let firstboot = command_starting("systemd-firstboot");
402
403 assert!(firstboot.contains("--hostname=workshop"), "{firstboot}");
404 assert!(firstboot.contains("--force"), "{firstboot}");
405 }
406
407 // The correction that started all this: configuration is pointed at the
408 // ostree deployment, never at the mountpoint. A hostname written to the
409 // sysroot is read by nothing.
410 #[test]
411 fn configuration_targets_the_deployment_not_the_mountpoint() {
412 for line in configured() {
413 // finalize and umount take the mountpoint by definition.
414 if line.starts_with("bootc") || line.starts_with("umount") {
415 continue;
416 }
417 // So does the selinuxfs probe in [`relabel_stages`], and more
418 // completely: it is the one stage whose question is about the live
419 // system rather than about the target. The `setfiles` it decides on
420 // does name the deployment, and is checked by this loop like
421 // everything else.
422 if line.starts_with("sh -c") {
423 continue;
424 }
425 assert!(
426 line.contains("/ostree/deploy/"),
427 "configured the sysroot instead of the deployment: {line}"
428 );
429 }
430 }
431
432 // The opt-in, stated as the absence of a request. Not "the checkbox
433 // defaults to false" — that a plan built without it contains nothing that
434 // could reach the network.
435 #[test]
436 fn an_install_that_was_not_asked_does_not_look_anything_up() {
437 for line in configured() {
438 assert!(!line.contains("curl"), "network call unasked: {line}");
439 assert!(!line.contains(GEO_HOST), "network call unasked: {line}");
440 assert!(!line.contains("--timezone"), "timezone unasked: {line}");
441 }
442 }
443
444 #[test]
445 fn a_located_install_asks_and_bounds_the_asking() {
446 let stages = configure_plan(
447 "workshop",
448 "max",
449 "hunter2",
450 &HomeSeeds::default(),
451 true,
452 None,
453 DEPLOYMENT,
454 )
455 .expect("a well-formed deployment path");
456 let shown = shown(stages);
457
458 let lookup = shown
459 .iter()
460 .find(|line| line.contains(GEO_HOST))
461 .expect("the box was ticked and nothing asks");
462 // Unbounded, this hangs a wiped disk on a network that never answers.
463 assert!(lookup.contains("--max-time"), "{lookup}");
464 }
465
466 // A key given means a key written, with the modes sshd insists on. Getting
467 // those wrong is indistinguishable from not writing it.
468 #[test]
469 fn a_key_becomes_an_authorized_keys_with_the_modes_sshd_demands() {
470 let key = &format!("ssh-ed25519 {ED25519_BODY} max@fw13");
471 let stages = configure_plan(
472 "workshop",
473 "max",
474 "hunter2",
475 &HomeSeeds {
476 pubkey: Some(key),
477 ..HomeSeeds::default()
478 },
479 false,
480 None,
481 DEPLOYMENT,
482 )
483 .unwrap();
484 let shown = shown(stages);
485 let joined = shown.join("\n");
486
487 assert!(joined.contains(".ssh"), "{joined}");
488 assert!(joined.contains("authorized_keys"), "{joined}");
489 assert!(
490 joined.contains(&format!("chmod {SSH_DIR_MODE}")),
491 "the directory mode is missing: {joined}",
492 );
493 assert!(
494 joined.contains(&format!("chmod {SSH_FILE_MODE}")),
495 "the file mode is missing: {joined}",
496 );
497 // The key travels on stdin, so it must not appear in a displayed argv:
498 // this is the mechanism that keeps the password off the log pane, and the
499 // key rides it.
500 assert!(
501 !joined.contains("AAAAC3NzaC1lZDI1NTE5"),
502 "the key body reached a rendered command line: {joined}",
503 );
504 }
505
506 // The ownership fix is a single recursive chown that the existing account
507 // stages already do. Writing .ssh after it would leave the directory
508 // root-owned, and sshd refuses an authorized_keys whose ownership it does not
509 // trust, silently. So the order is the correctness argument.
510 #[test]
511 fn the_key_is_written_before_the_recursive_chown_that_owns_it() {
512 let key = &format!("ssh-ed25519 {ED25519_BODY}");
513 let stages = configure_plan(
514 "workshop",
515 "max",
516 "hunter2",
517 &HomeSeeds {
518 pubkey: Some(key),
519 ..HomeSeeds::default()
520 },
521 false,
522 None,
523 DEPLOYMENT,
524 )
525 .unwrap();
526 let shown = shown(stages);
527
528 let wrote = shown
529 .iter()
530 .position(|line| line.contains("authorized_keys"))
531 .expect("the plan writes the key");
532 // The chown is produced by a Resolve, so what stands in for it is the
533 // `cat /etc/passwd` that discovers the ids. The last one: the account
534 // stage reads the same file first, to decide whether it has anything to
535 // do. The key must precede the discovery.
536 let discovers = shown
537 .iter()
538 .rposition(|line| line.contains("etc/passwd"))
539 .expect("the plan discovers the numeric ids");
540 assert!(
541 wrote < discovers,
542 "the key is written after ownership is settled: {shown:#?}",
543 );
544 }
545
546 // No key means no .ssh at all, rather than an empty authorized_keys. An empty
547 // file is a working key-based login with no keys in it, which is harder to
548 // diagnose than an absent one.
549 #[test]
550 fn no_key_writes_no_ssh_directory() {
551 let joined = configure_plan(
552 "workshop",
553 "max",
554 "hunter2",
555 &HomeSeeds::default(),
556 false,
557 None,
558 DEPLOYMENT,
559 )
560 .unwrap()
561 .iter()
562 .map(Stage::display)
563 .collect::<Vec<_>>()
564 .join("\n");
565 assert!(!joined.contains("authorized_keys"), "{joined}");
566 assert!(!joined.contains(".ssh"), "{joined}");
567 }
568
569 /// A screen as [`crate::display::detect_outputs`] would return one: a
570 /// connector, a scale, and nothing sysfs cannot say.
571 fn screen(name: &str, scale: f64) -> crate::display::Output {
572 crate::display::Output {
573 name: name.into(),
574 make: String::new(),
575 model: String::new(),
576 serial: String::new(),
577 active: true,
578 dpms: true,
579 focused: false,
580 rect: crate::display::Rectangle::default(),
581 scale,
582 transform: "normal".into(),
583 current_mode: None,
584 modes: Vec::new(),
585 }
586 }
587
588 fn plan_with_screens(screens: &[crate::display::Output]) -> Vec<String> {
589 configure_plan(
590 "workshop",
591 "max",
592 "hunter2",
593 &HomeSeeds {
594 screens,
595 ..HomeSeeds::default()
596 },
597 false,
598 None,
599 DEPLOYMENT,
600 )
601 .expect("a well-formed deployment path")
602 .iter()
603 .map(Stage::display)
604 .collect()
605 }
606
607 // The seed exists so a first boot renders at the scale the panel wants
608 // rather than at 1.0. Its directory has to be made first: the skel copy does
609 // not create one, since `alloy display` is what owns this path.
610 #[test]
611 fn a_detected_panel_seeds_the_per_user_display_config() {
612 let shown = plan_with_screens(&[screen("eDP-1", 1.25)]);
613 let joined = shown.join("\n");
614 assert!(joined.contains(crate::display::FILE), "{joined}");
615
616 let made = shown
617 .iter()
618 .position(|line| line.contains("mkdir") && line.contains("sway/config.d"))
619 .expect("the plan makes the directory");
620 let wrote = shown
621 .iter()
622 .position(|line| line.contains(crate::display::FILE))
623 .expect("the plan writes the file");
624 assert!(made < wrote, "{shown:#?}");
625 }
626
627 // Same argument as the ssh key: the recursive chown is what makes the home
628 // the user's, and a file written after it stays root-owned. Here that would
629 // cost the user a scale they cannot change from the console, since
630 // `alloy display` rewrites this exact file.
631 #[test]
632 fn the_seed_is_written_before_the_recursive_chown_that_owns_it() {
633 let shown = plan_with_screens(&[screen("eDP-1", 1.25)]);
634 let wrote = shown
635 .iter()
636 .position(|line| line.contains(crate::display::FILE))
637 .expect("the plan writes the file");
638 // The last passwd read: the account stage reads the same file first,
639 // to decide whether it has anything to do.
640 let discovers = shown
641 .iter()
642 .rposition(|line| line.contains("etc/passwd"))
643 .expect("the plan discovers the numeric ids");
644 assert!(wrote < discovers, "{shown:#?}");
645 }
646
647 // A machine whose panel cannot be read seeds nothing at all. An empty or
648 // guessed file would be a scale nobody chose, in the one file the console
649 // regenerates whole.
650 #[test]
651 fn no_detected_panel_seeds_nothing() {
652 let joined = plan_with_screens(&[]).join("\n");
653 assert!(!joined.contains("50-display.conf"), "{joined}");
654 assert!(!joined.contains("sway"), "{joined}");
655 }
656
657 // The lid-closed desk install: what is lit is an external monitor and there
658 // is no built-in panel in the list at all. The seed still has to happen,
659 // which is what the built-in filter used to make impossible. Measured on
660 // fw13, `docs/HARDWARE-FW13.md`.
661 //
662 // What lands in the file is `display.rs`'s to test: the stanza travels as a
663 // [`Secret`] on stdin and the plan display withholds it, deliberately.
664 #[test]
665 fn a_machine_with_no_built_in_panel_still_seeds_its_screen() {
666 let joined = plan_with_screens(&[screen("DP-3", 1.0)]).join("\n");
667 assert!(joined.contains(crate::display::FILE), "{joined}");
668 }
669
670 // One `tee`, one file. The console regenerates this file whole, so two
671 // writes would leave the first one's stanza live until it did.
672 #[test]
673 fn the_screens_are_seeded_in_a_single_write() {
674 let shown = plan_with_screens(&[screen("eDP-1", 1.75), screen("DP-3", 1.0)]);
675 let writes = shown
676 .iter()
677 .filter(|line| line.contains(crate::display::FILE))
678 .count();
679 assert_eq!(writes, 1, "{shown:#?}");
680 }
681
682 // useradd exits 9 against an account that already exists, so an unguarded
683 // call is a stage that can only ever run once. The recovery re-runs this
684 // plan against a disk that may already carry the account.
685 #[test]
686 fn the_account_is_not_created_twice() {
687 let stages = configure_plan(
688 "workshop",
689 "max",
690 "hunter2",
691 &HomeSeeds::default(),
692 false,
693 None,
694 DEPLOYMENT,
695 )
696 .expect("a well-formed deployment path");
697
698 let gate = stages
699 .into_iter()
700 .find_map(|stage| match stage {
701 Stage::Resolve { invocation, then } if invocation.display().contains("passwd") => {
702 Some(then)
703 }
704 _ => None,
705 })
706 .expect("the plan checks the passwd file before creating the account");
707
708 // An account already there: nothing to do.
709 let existing = "root:x:0:0::/root:/bin/bash\nmax:x:1000:1000::/var/home/max:/usr/bin/nu\n";
710 assert!(gate(existing).expect("a verdict").is_empty());
711 }
712
713 #[test]
714 fn the_account_is_created_when_it_is_missing() {
715 let shown = configured();
716 assert!(shown.iter().any(|line| runs(line, "useradd")), "{shown:#?}");
717 }
718 }
719