Skip to main content

max / alloy

install: give new accounts nushell as their login shell The config tree ships nushell configuration at etc/skel/.config/nushell/, but every install landed on /bin/bash from the image's /etc/default/useradd, so none of it applied to a fresh account. useradd does not validate --shell: handed a missing or unregistered one it warns and creates the account anyway, which gives an install that succeeds, reboots, and cannot get a shell. The plan reads the target's /etc/shells first and refuses before anything is written. That makes the Containerfile half load-bearing rather than tidy. Fedora's nushell package does not register itself, and an unlisted shell is a second-class one: chsh refuses it, so the account's owner could not switch away from nu or back to it. bash stays at /bin/sh and /bin/bash for scripts. Plan tests move from position to command name. Adding a stage at the front shifted every index, and an index assertion checks the wrong line rather than failing.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 02:28 UTC
Signed with PGP, not checked
Commit: 78944d0d1f8b2089f12ff1320206e73ce3c3594e
Parent: 70d10e5
2 files changed, +120 insertions, -5 deletions
@@ -235,6 +235,22 @@
235 235 # =====================================================================
236 236 RUN useradd -M -r -s /sbin/nologin greeter
237 237
238 + # =====================================================================
239 + # Register nushell as a legitimate login shell.
240 + # =====================================================================
241 + # The installer sets new accounts to /usr/bin/nu (crates/alloy/src/
242 + # install.rs, LOGIN_SHELL), which is the shell etc/skel/.config/nushell/
243 + # is written for. Fedora's nushell package does not add itself to
244 + # /etc/shells, and an unlisted shell is a second-class one: chsh refuses
245 + # it, so the account's owner cannot switch away from or back to it, and
246 + # anything consulting /etc/shells treats the account as restricted.
247 + #
248 + # The installer reads this file and refuses to create an account if the
249 + # shell is missing from it, so this line is load-bearing rather than
250 + # tidy: without it every install stops with "not listed in the target's
251 + # /etc/shells". bash stays at /bin/sh and /bin/bash for scripts.
252 + RUN echo /usr/bin/nu >> /etc/shells
253 +
238 254 # =====================================================================
239 255 # Config tree — the etc/ and usr/ trees in the repo map 1:1 into
240 256 # the image. Per-user defaults live under etc/skel/.config/ and
@@ -328,6 +328,41 @@
328 328 /// Mode for a new home directory, matching Fedora's `HOME_MODE`.
329 329 const HOME_MODE: &str = "700";
330 330
331 + /// The shell new accounts get.
332 + ///
333 + /// nushell is the shell Alloy's config tree is written for: the aliases, the
334 + /// prompt wiring and the environment all live in `etc/skel/.config/nushell/`,
335 + /// and an account on bash gets none of it. Until this was set, every install
336 + /// landed on `/bin/bash` from the image's `/etc/default/useradd`, so the shipped
337 + /// nushell configuration was dead weight on a fresh machine.
338 + ///
339 + /// bash is deliberately left in place as `/bin/sh` and `/bin/bash`. nushell is
340 + /// not POSIX and nothing that runs a script should get it; this is the
341 + /// interactive login shell only.
342 + const LOGIN_SHELL: &str = "/usr/bin/nu";
343 +
344 + /// Check that the target sanctions [`LOGIN_SHELL`] as a login shell.
345 + ///
346 + /// `useradd` does not verify this. Handed a shell that is missing or
347 + /// unregistered it prints a warning and creates the account anyway, which is the
348 + /// worst available outcome: the install succeeds, the machine reboots, and the
349 + /// account cannot get a shell. Failing here instead costs an install that stops
350 + /// with the target still mounted and says why.
351 + ///
352 + /// `/etc/shells` rather than testing the binary because it answers the question
353 + /// that matters. A path can exist and still not be a shell the system will hand
354 + /// out, and `chsh` refuses anything unlisted, so an account set to an
355 + /// unregistered shell is one its owner cannot change back.
356 + fn shell_is_registered(listing: &str, shell: &str) -> Result<(), String> {
357 + listing
358 + .lines()
359 + .map(str::trim)
360 + .filter(|line| !line.is_empty() && !line.starts_with('#'))
361 + .any(|line| line == shell)
362 + .then_some(())
363 + .ok_or_else(|| format!("{shell} is not listed in the target's /etc/shells"))
364 + }
365 +
331 366 /// Where the stateroot's `/var` is, given the deployment directory.
332 367 ///
333 368 /// **A deployment's own `var` is not the system's `/var`.** It is an empty
@@ -452,6 +487,15 @@
452 487 let ids_home = home.clone();
453 488
454 489 Ok(vec![
490 + // Before anything is written: a shell the target will not hand out
491 + // makes an account nobody can log into, and useradd will not catch it.
492 + Stage::Resolve {
493 + invocation: Invocation::new("cat").arg(format!("{root}/etc/shells")),
494 + then: Box::new(move |listing| {
495 + shell_is_registered(listing, LOGIN_SHELL)?;
496 + Ok(Vec::new())
497 + }),
498 + },
455 499 Stage::Run(
456 500 Invocation::new("systemd-firstboot")
457 501 .arg(format!("--root={root}"))
@@ -476,6 +520,7 @@
476 520 Invocation::new("useradd")
477 521 .args(["--root", root, "--no-create-home"])
478 522 .args(["--home-dir", &installed_home])
523 + .args(["--shell", LOGIN_SHELL])
479 524 .args(["--groups", "wheel"])
480 525 .arg(username),
481 526 ),
@@ -2055,12 +2100,27 @@
2055 2100 .collect()
2056 2101 }
2057 2102
2103 + /// The one configure command running `program`.
2104 + ///
2105 + /// By program rather than by index: the plan gains and loses steps, and a
2106 + /// test asserting about position rather than about the command it means
2107 + /// starts checking the wrong line without failing.
2108 + fn command_starting(program: &str) -> String {
2109 + let mut found: Vec<String> = configured()
2110 + .into_iter()
2111 + .filter(|line| line.starts_with(program))
2112 + .collect();
2113 +
2114 + assert_eq!(found.len(), 1, "expected exactly one {program}: {found:#?}");
2115 + found.remove(0)
2116 + }
2117 +
2058 2118 // --force matters: etc/hostname ships with "alloy" already in it, and
2059 2119 // firstboot skips any setting already present. Without the flag the user's
2060 2120 // answer is silently discarded.
2061 2121 #[test]
2062 2122 fn firstboot_forces_over_the_images_baked_in_hostname() {
2063 - let firstboot = configured().remove(0);
2123 + let firstboot = command_starting("systemd-firstboot");
2064 2124
2065 2125 assert!(firstboot.contains("--hostname=workshop"), "{firstboot}");
2066 2126 assert!(firstboot.contains("--force"), "{firstboot}");
@@ -2083,11 +2143,51 @@
2083 2143 }
2084 2144 }
2085 2145
2146 + // The config tree ships nushell configuration; an account on bash gets
2147 + // none of it. Before this, every install landed on /bin/bash from the
2148 + // image's /etc/default/useradd.
2149 + #[test]
2150 + fn the_account_gets_nushell_as_its_login_shell() {
2151 + let useradd = command_starting("useradd");
2152 +
2153 + assert!(useradd.contains("--shell /usr/bin/nu"), "{useradd}");
2154 + }
2155 +
2156 + // useradd does not check the shell: handed a missing or unregistered one
2157 + // it warns and creates the account anyway. That gives an install that
2158 + // succeeds, reboots, and cannot log in. The plan checks first, before
2159 + // anything is written.
2160 + #[test]
2161 + fn the_login_shell_is_checked_before_the_account_is_made() {
2162 + let lines = configured();
2163 + let check = &lines[0];
2164 + let useradd = lines.iter().position(|l| l.starts_with("useradd"));
2165 +
2166 + assert!(check.contains("/etc/shells"), "{check}");
2167 + assert!(useradd.expect("the plan creates an account") > 0);
2168 + }
2169 +
2170 + #[test]
2171 + fn a_registered_shell_passes_and_an_unregistered_one_is_refused() {
2172 + let shells = "# comment\n/bin/sh\n/bin/bash\n/usr/bin/nu\n";
2173 + assert!(shell_is_registered(shells, LOGIN_SHELL).is_ok());
2174 +
2175 + let error = shell_is_registered("/bin/sh\n/bin/bash\n", LOGIN_SHELL).unwrap_err();
2176 + assert!(error.contains("/etc/shells"), "{error}");
2177 + assert!(error.contains(LOGIN_SHELL), "{error}");
2178 + }
2179 +
2180 + // A comment naming the shell is not a registration.
2181 + #[test]
2182 + fn a_commented_out_shell_does_not_count_as_registered() {
2183 + assert!(shell_is_registered("#/usr/bin/nu\n/bin/bash\n", LOGIN_SHELL).is_err());
2184 + }
2185 +
2086 2186 // An account that cannot escalate leaves an install with no way to
2087 2187 // administer itself.
2088 2188 #[test]
2089 2189 fn the_account_can_sudo() {
2090 - let useradd = configured().remove(1);
2190 + let useradd = command_starting("useradd");
2091 2191
2092 2192 assert!(useradd.contains("wheel"), "{useradd}");
2093 2193 assert!(useradd.ends_with("max"), "{useradd}");
@@ -2098,9 +2198,8 @@
2098 2198 // boot. A home made there is a home the installed system never sees.
2099 2199 #[test]
2100 2200 fn the_home_is_made_in_the_stateroot_var_not_the_deployments() {
2101 - let lines = configured();
2102 - let useradd = &lines[1];
2103 - let mkdir = &lines[2];
2201 + let useradd = command_starting("useradd");
2202 + let mkdir = command_starting("mkdir");
2104 2203
2105 2204 assert!(useradd.contains("--no-create-home"), "{useradd}");
2106 2205 assert!(useradd.contains("--home-dir /var/home/max"), "{useradd}");