| 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 |
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 |
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 |
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 |
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 |
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}");
|