From 58c449a3ace841a1571895802ed5b62d6ff6e2fc Mon Sep 17 00:00:00 2001 From: Pierre Warnier Date: Sun, 6 Sep 2026 16:35:10 +0200 Subject: [PATCH 1/2] newusers: create or update accounts in batch newusers is the eighteenth tool and the third of the nine that both Debian and Fedora ship and we did not. One input line of seven fields produces a whole account: the passwd record, the hashed shadow record, a group, and the home directory with the skeleton copied in. Every line is parsed, every name validated, every group resolved and every password hashed before anything is written. A batch with one bad line leaves the system exactly as it was, including for the good lines above it -- the failure mode is "nothing happened", not "the first two hundred accounts exist and the rest do not". That is the whole reason to reach for this tool over a shell loop around useradd. Home directory creation moved to shadow_core::home rather than being copied. useradd's version forces the umask so the requested mode is exact, and hands over ownership through a descriptor opened O_NOFOLLOW so nobody who can write the parent can swap in a symlink between the mkdir and the chown. A second copy of that is a second chance to get it wrong, and the mistake would be silent. useradd now calls the shared one and its own tests still pass. Three deliberate divergences, all established by running the GNU tool: - An empty password field is refused before anything is written. Hashing "" gives a valid hash that a bare Enter matches. GNU passes the empty field to PAM, which refuses it *after* creating the account, leaving a half-made account behind -- verified. - A pw_gid naming a group that does not exist is refused. GNU falls back to the user's own ID and creates no group, so the account is left pointing at a GID that is not there, which grpck then reports. - A missing parent directory is created, as useradd -b does. GNU's newusers fails there, which makes it disagree with GNU's own useradd on the same path. --badname is implemented rather than stubbed: it drops the portability rules on the login name while keeping the checks that stop a name from corrupting the file or being read as an option, which is what makes the flag safe to offer at all. It is what lets a domain-qualified name from a directory join through. The e2e suite re-hashes the stored field with its own salt and compares, so a tool that wrote a well-formed hash of the wrong string would fail; the negative control, the same check against a different password, is asserted to fail. An earlier version of that assertion ended in `|| true` and was therefore always green -- worth saying, because it is the failure mode these checks exist to catch. Verified: 828 tests on debian, alpine and fedora; make check clean, including the unprivileged run; 297 e2e assertions against a real install; 45 GNU comparisons; 22 arm64 assertions under emulation. --- CHANGELOG.md | 16 + Cargo.lock | 12 + Cargo.toml | 5 +- Makefile | 4 +- README.md | 9 +- docs/man/newusers.8.md | 166 +++++++ src/bin/completions.rs | 4 + src/bin/shadow-rs.rs | 7 +- src/shadow-core/src/home.rs | 239 ++++++++++ src/shadow-core/src/lib.rs | 1 + src/uu/newusers/Cargo.toml | 35 ++ src/uu/newusers/locales/en-US.ftl | 2 + src/uu/newusers/src/main.rs | 6 + src/uu/newusers/src/newusers.rs | 709 ++++++++++++++++++++++++++++++ src/uu/useradd/src/useradd.rs | 103 +---- tests/arm64-smoke.sh | 13 +- tests/by-util/test_multicall.rs | 3 +- tests/by-util/test_newusers.rs | 400 +++++++++++++++++ tests/e2e/deploy-test.sh | 71 ++- tests/gnu-compat.sh | 1 + tests/tests.rs | 2 + 21 files changed, 1703 insertions(+), 105 deletions(-) create mode 100644 docs/man/newusers.8.md create mode 100644 src/shadow-core/src/home.rs create mode 100644 src/uu/newusers/Cargo.toml create mode 100644 src/uu/newusers/locales/en-US.ftl create mode 100644 src/uu/newusers/src/main.rs create mode 100644 src/uu/newusers/src/newusers.rs create mode 100644 tests/by-util/test_newusers.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d0148bd..084aef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `newusers`, the eighteenth tool: it creates or updates accounts in batch + from stdin, writing the passwd record, the hashed shadow record, a group and + the home directory for each line. Every line is parsed, every group resolved + and every password hashed before anything is written, so a batch with one bad + line leaves the system exactly as it was rather than half provisioned. An + empty password field is refused before anything is written -- GNU hands it to + PAM, which refuses it after creating the account -- and a `pw_gid` naming a + group that does not exist is refused rather than silently leaving the account + pointing at a GID that is not there + - `chgpasswd`, the seventeenth tool: it sets group passwords in batch from stdin, the counterpart of `chpasswd` for groups. Every line is resolved and hashed before any file is written, so a batch naming one group that does not @@ -28,6 +38,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Home directory creation moved to `shadow_core::home`, shared by `useradd` + and `newusers`. The care it takes -- forcing the umask so the mode is exact, + and handing over ownership through a descriptor opened `O_NOFOLLOW` rather + than by path -- is exactly the kind of thing a second copy gets wrong + silently + - `make check` also runs the suite as an unprivileged user, through the new `make test-unprivileged`. Every container in `docker-compose.yml` runs as root, so a test that silently assumed root passed locally and failed only in diff --git a/Cargo.lock b/Cargo.lock index 93eaaef..57ada6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -814,6 +814,17 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_newusers" +version = "0.4.0" +dependencies = [ + "clap", + "shadow-core", + "tempfile", + "uucore", + "zeroize", +] + [[package]] name = "uu_passwd" version = "0.4.0" @@ -864,6 +875,7 @@ dependencies = [ "uu_groupmod", "uu_grpck", "uu_newgrp", + "uu_newusers", "uu_passwd", "uu_pwck", "uu_sg", diff --git a/Cargo.toml b/Cargo.toml index 93440a2..918931a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ members = [ "src/uu/gpasswd", "src/uu/sg", "src/uu/chgpasswd", + "src/uu/newusers", ] [workspace.package] @@ -96,11 +97,12 @@ newgrp = { optional = true, version = "0.4.0", package = "uu_newgrp", path = "sr gpasswd = { optional = true, version = "0.4.0", package = "uu_gpasswd", path = "src/uu/gpasswd" } sg = { optional = true, version = "0.4.0", package = "uu_sg", path = "src/uu/sg" } chgpasswd = { optional = true, version = "0.4.0", package = "uu_chgpasswd", path = "src/uu/chgpasswd" } +newusers = { optional = true, version = "0.4.0", package = "uu_newusers", path = "src/uu/newusers" } [features] default = ["passwd", "pwck", "useradd", "userdel", "usermod", "chpasswd", "chage", "groupadd", "groupdel", "groupmod", "grpck", "chfn", "chsh", "newgrp", "gpasswd", - "sg", "chgpasswd"] + "sg", "chgpasswd", "newusers"] # PAM authentication (requires libpam-dev). The `?` matters: without it, # asking for PAM would drag in the three applets that can use it even when the @@ -137,6 +139,7 @@ groupmod = { version = "0.4.0", package = "uu_groupmod", path = "src/uu/groupmod newgrp = { version = "0.4.0", package = "uu_newgrp", path = "src/uu/newgrp" } sg = { version = "0.4.0", package = "uu_sg", path = "src/uu/sg" } chgpasswd = { version = "0.4.0", package = "uu_chgpasswd", path = "src/uu/chgpasswd" } +newusers = { version = "0.4.0", package = "uu_newusers", path = "src/uu/newusers" } passwd = { version = "0.4.0", package = "uu_passwd", path = "src/uu/passwd" } useradd = { version = "0.4.0", package = "uu_useradd", path = "src/uu/useradd" } userdel = { version = "0.4.0", package = "uu_userdel", path = "src/uu/userdel" } diff --git a/Makefile b/Makefile index 10d7d01..b5f9278 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ SBINDIR ?= $(PREFIX)/sbin SETUID_TOOLS = passwd chfn chsh newgrp gpasswd sg # Root-only tools (no setuid; fail at getuid() check for non-root callers). -ROOT_TOOLS = useradd userdel usermod chpasswd chgpasswd \ +ROOT_TOOLS = useradd userdel usermod chpasswd chgpasswd newusers \ groupadd groupdel groupmod pwck grpck # Tools an ordinary user runs, and which therefore go in bin rather than sbin: @@ -133,7 +133,7 @@ test-unprivileged: test-gnu-compat: bash tests/gnu-compat.sh -# Default install: 17 standalone per-tool binaries, with the setuid layout and +# Default install: 18 standalone per-tool binaries, with the setuid layout and # the bin/sbin split GNU shadow-utils uses. Only $(SETUID_TOOLS) are setuid. install: build @for tool in $(SETUID_TOOLS); do \ diff --git a/README.md b/README.md index c2364be..8568575 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ default-in-Ubuntu in under 3 years. This project follows that playbook. | `gpasswd` | **Implemented.** Group membership, administrators, and group password. | | `sg` | **Implemented.** Runs one command in another group; shares `newgrp`'s authorization. | | `chgpasswd` | **Implemented.** Batch group passwords, applied all-or-nothing. | +| `newusers` | **Implemented.** Batch account creation: passwd, shadow, group and home. | ## Building @@ -90,9 +91,9 @@ docker compose run --rm debian cargo build --release ### Install -Default install: 17 standalone per-tool binaries with least-privilege setuid +Default install: 18 standalone per-tool binaries with least-privilege setuid layout matching GNU shadow-utils. Only `passwd`, `chfn`, `chsh`, `newgrp`, -`gpasswd` and `sg` are installed setuid-root; the other 11 are plain `0755`. +`gpasswd` and `sg` are installed setuid-root; the other 12 are plain `0755`. ```shell sudo make install PREFIX=/usr/local @@ -143,8 +144,8 @@ would: tar xzf uu_shadow-x86_64-unknown-linux-gnu.tar.gz # or the -musl-static one sudo install -o root -g root -m 4755 \ uu_shadow-*/shadow-rs /usr/local/bin/shadow-rs -for tool in passwd chfn chsh newgrp gpasswd sg chage chpasswd chgpasswd groupadd \ - groupdel groupmod grpck pwck useradd userdel usermod; do +for tool in passwd chfn chsh newgrp gpasswd sg chage chpasswd chgpasswd newusers \ + groupadd groupdel groupmod grpck pwck useradd userdel usermod; do sudo ln -sf shadow-rs "/usr/local/bin/$tool" done ``` diff --git a/docs/man/newusers.8.md b/docs/man/newusers.8.md new file mode 100644 index 0000000..4500bc0 --- /dev/null +++ b/docs/man/newusers.8.md @@ -0,0 +1,166 @@ +# newusers(8) - create or update users in batch + +## NAME + +newusers - create or update users in batch + +## SYNOPSIS + +**newusers** [*options*] + +## DESCRIPTION + +The **newusers** command reads a file of user account descriptions from +standard input and uses it to create new accounts, or to update accounts that +already exist. Each line is of the form: + +``` +pw_name:pw_passwd:pw_uid:pw_gid:pw_gecos:pw_dir:pw_shell +``` + +Exactly seven fields, the same as a line of /etc/passwd, with the plaintext +password where the placeholder would be. Six fields or eight are both an +invalid line: one lost to a stray colon would describe a different account than +the one intended. + +For each line **newusers** writes the /etc/passwd record, a hashed /etc/shadow +record, a group where one is needed, and the home directory. + +## ALL OR NOTHING + +Every line is parsed, every name validated, every group resolved and every +password hashed **before** any file is written. A batch with one bad line +leaves the system exactly as it was, including for the good lines above it. + +That is the property that makes it safe to feed this tool a generated file: +the failure mode is "nothing happened", not "the first two hundred accounts +exist and the rest do not". + +The account files are then written in one locked transaction. Home directories +are created afterwards: a directory that cannot be created is worth reporting, +but the accounts are already correct, and undoing them would be a larger +surprise than a missing directory. + +## FIELDS + +*pw_name* +: The login name. An existing account of that name is updated rather than + refused. + +*pw_passwd* +: The password, in clear text. It is hashed with the scheme from + **ENCRYPT_METHOD** in /etc/login.defs unless **-c** names another. This + field may not be empty; see DIFFERENCES FROM GNU SHADOW. + +*pw_uid* +: Empty to allocate one from the range in /etc/login.defs. On an account + that already exists, empty keeps the ID it has -- reallocating would orphan + every file the account owns. + +*pw_gid* +: Empty for a group of the user's own, created if it is not already there. + A number names a group directly, and one is created carrying the user's + name if no group has that ID. A name must already exist. + +*pw_gecos*, *pw_dir*, *pw_shell* +: Written as given. An empty *pw_dir* means the account gets no home + directory; otherwise the directory is created with mode 0700, owned by the + new account, and /etc/skel is copied into it. + +## OPTIONS + +**-b**, **--badname** +: Allow login names that fail the portability rules. The checks that stop a + name from corrupting the file -- a colon, a newline, a leading `-` -- still + apply, which is what makes the flag safe to offer. Useful for the + domain-qualified names a directory join produces. + +**-c**, **--crypt-method** *METHOD* +: Use *METHOD* to hash the passwords. Supported: **SHA256**, **SHA512**, + **YESCRYPT**. + +**-r**, **--system** +: Create system accounts, allocating from the system ID range. + +**-R**, **--root** *CHROOT_DIR* +: Apply changes in *CHROOT_DIR* and use its configuration files. + +**-P**, **--prefix** *PREFIX_DIR* +: Read and write the account files under *PREFIX_DIR* without chrooting. + +## DIFFERENCES FROM GNU SHADOW + +**An empty password field is refused**, before anything is written. Hashing an +empty string produces a valid hash that a bare Enter matches -- an account +anyone can log into, not an account with no password. GNU passes the empty +field to PAM, which refuses it *after* the account has been created, leaving a +half-made account behind. + +**A *pw_gid* naming a group that does not exist is refused.** GNU falls back to +the user's own ID and creates no group at all, so the account is left pointing +at a GID that is not there -- which **grpck**(8) then reports. Naming a group +that is not there is a mistake worth reporting at the time. + +**A missing parent directory is created**, as **useradd**(8) does with **-b**. +GNU's **newusers** fails there, which makes it behave differently from GNU's +own **useradd** for the same home path. + +**-c NONE**, **-c MD5** and **-c DES** are refused, as they are by +**chpasswd**(8) and **chgpasswd**(8) here. + +## EXIT STATUS + +**0** +: Success. Empty input succeeds having done nothing. + +**1** +: The accounts could not be created. Nothing was written. + +**2** +: Invalid command syntax. + +**3** +: The **--root** directory could not be entered. + +## FILES + +/etc/passwd +: User account information. + +/etc/shadow +: Secure user account information. + +/etc/group +: Group account information. + +/etc/login.defs +: Shadow password suite configuration: ID ranges and **ENCRYPT_METHOD**. + +/etc/skel +: Skeleton copied into each new home directory. + +## EXAMPLES + +Create three accounts from a generated file: + +``` +# newusers < new-accounts.txt +``` + +where the file holds: + +``` +alice:correct horse:::Alice Adams:/home/alice:/bin/bash +bob:battery staple:::Bob Brown:/home/bob:/bin/bash +svc-web:generated:::Web service::/usr/sbin/nologin +``` + +Create service accounts from the system range: + +``` +# newusers -r < services.txt +``` + +## SEE ALSO + +chgpasswd(8), chpasswd(8), groupadd(8), login.defs(5), passwd(5), useradd(8) diff --git a/src/bin/completions.rs b/src/bin/completions.rs index fd6218e..73f575f 100644 --- a/src/bin/completions.rs +++ b/src/bin/completions.rs @@ -45,6 +45,8 @@ fn get_tool_app(name: &str) -> Option { "grpck" => Some(grpck::uu_app()), #[cfg(feature = "newgrp")] "newgrp" => Some(newgrp::uu_app()), + #[cfg(feature = "newusers")] + "newusers" => Some(newusers::uu_app()), #[cfg(feature = "passwd")] "passwd" => Some(passwd::uu_app()), #[cfg(feature = "pwck")] @@ -86,6 +88,8 @@ fn all_tool_names() -> Vec<&'static str> { names.push("grpck"); #[cfg(feature = "newgrp")] names.push("newgrp"); + #[cfg(feature = "newusers")] + names.push("newusers"); #[cfg(feature = "passwd")] names.push("passwd"); #[cfg(feature = "pwck")] diff --git a/src/bin/shadow-rs.rs b/src/bin/shadow-rs.rs index 9a47896..5aed33b 100644 --- a/src/bin/shadow-rs.rs +++ b/src/bin/shadow-rs.rs @@ -39,7 +39,7 @@ const SETUID_APPLETS: [&str; 6] = ["passwd", "chfn", "chsh", "newgrp", "gpasswd" // nothing, and the binding is then not mutated. #[allow(unused_mut)] fn applets() -> Vec<(&'static str, Applet)> { - let mut table: Vec<(&'static str, Applet)> = Vec::with_capacity(17); + let mut table: Vec<(&'static str, Applet)> = Vec::with_capacity(18); #[cfg(feature = "chage")] table.push(("chage", |a| chage::uumain(a.iter().cloned()))); #[cfg(feature = "chfn")] @@ -62,6 +62,8 @@ fn applets() -> Vec<(&'static str, Applet)> { table.push(("grpck", |a| grpck::uumain(a.iter().cloned()))); #[cfg(feature = "newgrp")] table.push(("newgrp", |a| newgrp::uumain(a.iter().cloned()))); + #[cfg(feature = "newusers")] + table.push(("newusers", |a| newusers::uumain(a.iter().cloned()))); #[cfg(feature = "passwd")] table.push(("passwd", |a| passwd::uumain(a.iter().cloned()))); #[cfg(feature = "pwck")] @@ -232,7 +234,7 @@ fn print_available_utils() { mod tests { use super::*; - const ALL_TOOLS: [&str; 17] = [ + const ALL_TOOLS: [&str; 18] = [ "chage", "chfn", "chgpasswd", @@ -244,6 +246,7 @@ mod tests { "groupmod", "grpck", "newgrp", + "newusers", "passwd", "pwck", "sg", diff --git a/src/shadow-core/src/home.rs b/src/shadow-core/src/home.rs new file mode 100644 index 0000000..5098aff --- /dev/null +++ b/src/shadow-core/src/home.rs @@ -0,0 +1,239 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore fchown mkdir umask useradd newusers + +//! Creating a user's home directory. +//! +//! Shared by `useradd(8)` and `newusers(8)`, which both have to do it and must +//! do it identically. The care this takes -- forcing the umask so the mode is +//! exact, and changing ownership through a descriptor rather than a path -- is +//! the reason it lives in one place: a second copy would be a second chance to +//! get it wrong, and the mistake would be silent. + +use std::os::unix::fs::DirBuilderExt as _; +use std::path::Path; + +use crate::error::ShadowError; + +/// What [`create`] found when it tried. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// The directory was created and the skeleton copied into it. + Created, + /// The directory was already there. Nothing was copied into it: the + /// skeleton would overwrite files the existing occupant put there. + AlreadyExisted, +} + +/// The conventional mode for a directory that only holds other homes. +const BASE_DIR_MODE: u32 = 0o755; + +/// Create `home_path` owned by `uid`:`gid` with `mode`, and copy `skel_path` +/// into it. +/// +/// Missing ancestors are created too, so a home under a base directory that +/// does not exist yet works. They get the conventional 0755 and stay +/// root-owned; only the home itself takes `mode` and the caller's ownership. +/// +/// Returns [`Outcome::AlreadyExisted`] rather than an error when the directory +/// is already there, leaving it untouched. Whether that deserves a warning is +/// the calling tool's decision, not this function's. +pub fn create( + home_path: &Path, + skel_path: &Path, + uid: u32, + gid: u32, + mode: u32, +) -> Result { + create_ancestors(home_path)?; + + // The kernel does not reset the umask across setuid, so a caller-controlled + // umask may still be in effect here, and it can mask off requested + // permission bits: with umask 0700 even mkdir(0700) leaves the directory at + // 0000. Forcing it to zero makes the requested mode exact. A umask can only + // ever make the result less permissive, never more, so this cannot widen + // anything. Scoped to the mkdir alone -- fchown does not need it, and + // copy_skel manages its own. + let mkdir_result = { + let _umask = crate::atomic::UmaskGuard::zero(); + std::fs::DirBuilder::new().mode(mode).create(home_path) + }; + + match mkdir_result { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + return Ok(Outcome::AlreadyExisted); + } + Err(e) => { + return Err(ShadowError::Other( + format!("cannot create directory '{}': {e}", home_path.display()).into(), + )); + } + } + + set_ownership(home_path, uid, gid)?; + + crate::skel::copy_skel(skel_path, home_path, uid, gid).map_err(|e| { + ShadowError::Other( + format!( + "cannot copy skel '{}' to '{}': {e}", + skel_path.display(), + home_path.display() + ) + .into(), + ) + })?; + + Ok(Outcome::Created) +} + +/// Create the directories above `home_path` if they are missing. +fn create_ancestors(home_path: &Path) -> Result<(), ShadowError> { + let Some(parent) = home_path.parent() else { + return Ok(()); + }; + if parent.as_os_str().is_empty() || parent.exists() { + return Ok(()); + } + let _umask = crate::atomic::UmaskGuard::zero(); + std::fs::DirBuilder::new() + .recursive(true) + .mode(BASE_DIR_MODE) + .create(parent) + .map_err(|e| { + ShadowError::Other( + format!("cannot create directory '{}': {e}", parent.display()).into(), + ) + }) +} + +/// Hand the directory to its owner. +/// +/// Through a descriptor opened `O_NOFOLLOW`, never by path: between the mkdir +/// and this call, anyone who can write the parent -- a home under `/tmp`, or a +/// shared base directory -- could swap the directory for a symlink and have us +/// hand them the target. +fn set_ownership(home_path: &Path, uid: u32, gid: u32) -> Result<(), ShadowError> { + use rustix::fs::{Mode, OFlags}; + + let dir = rustix::fs::open( + home_path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW, + Mode::empty(), + ) + .map_err(|e| { + ShadowError::Other(format!("cannot open '{}': {e}", home_path.display()).into()) + })?; + + rustix::fs::fchown( + &dir, + Some(rustix::fs::Uid::from_raw(uid)), + Some(rustix::fs::Gid::from_raw(gid)), + ) + .map_err(|e| { + ShadowError::Other(format!("cannot set ownership on '{}': {e}", home_path.display()).into()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The mode has to survive a hostile umask, which is the whole reason for + /// the guard: a home at 0000 locks the user out of their own directory, + /// and one too permissive exposes it. + #[test] + fn test_mode_is_exact_under_any_umask() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("tempdir"); + let skel = dir.path().join("skel"); + std::fs::create_dir(&skel).expect("skel"); + + let home = dir.path().join("home/alice"); + let uid = rustix::process::getuid().as_raw(); + let gid = rustix::process::getgid().as_raw(); + + assert_eq!( + create(&home, &skel, uid, gid, 0o700).expect("create"), + Outcome::Created + ); + let mode = std::fs::metadata(&home).expect("stat").permissions().mode(); + assert_eq!( + mode & 0o777, + 0o700, + "requested mode was not applied exactly" + ); + } + + /// A missing base directory is created, and gets 0755 rather than the + /// home's private mode -- other users' homes have to live there too. + #[test] + fn test_missing_base_directory_is_created() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("tempdir"); + let skel = dir.path().join("skel"); + std::fs::create_dir(&skel).expect("skel"); + + let home = dir.path().join("deeply/nested/home/alice"); + let uid = rustix::process::getuid().as_raw(); + let gid = rustix::process::getgid().as_raw(); + create(&home, &skel, uid, gid, 0o700).expect("create"); + + let base = dir.path().join("deeply/nested/home"); + assert!(base.is_dir()); + let mode = std::fs::metadata(&base).expect("stat").permissions().mode(); + assert_eq!(mode & 0o777, BASE_DIR_MODE); + } + + /// An existing directory is reported, not overwritten: copying the + /// skeleton over it would clobber whatever is already there. + #[test] + fn test_an_existing_directory_is_left_alone() { + let dir = tempfile::tempdir().expect("tempdir"); + let skel = dir.path().join("skel"); + std::fs::create_dir(&skel).expect("skel"); + std::fs::write(skel.join(".profile"), "from skel\n").expect("skel file"); + + let home = dir.path().join("home/alice"); + std::fs::create_dir_all(&home).expect("home"); + std::fs::write(home.join("notes"), "mine\n").expect("existing file"); + + let uid = rustix::process::getuid().as_raw(); + let gid = rustix::process::getgid().as_raw(); + assert_eq!( + create(&home, &skel, uid, gid, 0o700).expect("create"), + Outcome::AlreadyExisted + ); + assert!( + home.join("notes").exists(), + "existing content was disturbed" + ); + assert!( + !home.join(".profile").exists(), + "the skeleton must not be copied over an existing home" + ); + } + + /// The skeleton reaches the new home. + #[test] + fn test_skel_is_copied() { + let dir = tempfile::tempdir().expect("tempdir"); + let skel = dir.path().join("skel"); + std::fs::create_dir(&skel).expect("skel"); + std::fs::write(skel.join(".bashrc"), "alias x=y\n").expect("skel file"); + + let home = dir.path().join("home/alice"); + let uid = rustix::process::getuid().as_raw(); + let gid = rustix::process::getgid().as_raw(); + create(&home, &skel, uid, gid, 0o700).expect("create"); + + assert_eq!( + std::fs::read_to_string(home.join(".bashrc")).expect("copied"), + "alias x=y\n" + ); + } +} diff --git a/src/shadow-core/src/lib.rs b/src/shadow-core/src/lib.rs index 967ae3e..cbc5433 100644 --- a/src/shadow-core/src/lib.rs +++ b/src/shadow-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod error; pub mod group; pub mod gshadow; pub mod hardening; +pub mod home; pub mod lock; pub mod login_defs; pub mod nscd; diff --git a/src/uu/newusers/Cargo.toml b/src/uu/newusers/Cargo.toml new file mode 100644 index 0000000..0e789a4 --- /dev/null +++ b/src/uu/newusers/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "uu_newusers" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +rust-version.workspace = true +description = "newusers ~ (shadow-rs) create or update users in batch" + +[lib] +path = "src/newusers.rs" + +[[bin]] +name = "newusers" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +shadow-core = { workspace = true, features = ["crypt"] } +uucore = { workspace = true } +zeroize = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true + +# Distributed via the `shadow-rs` multicall binary in the workspace root +# package, not as a standalone archive (see dist-workspace.toml, issue #207). +[package.metadata.dist] +dist = false diff --git a/src/uu/newusers/locales/en-US.ftl b/src/uu/newusers/locales/en-US.ftl new file mode 100644 index 0000000..2d3c480 --- /dev/null +++ b/src/uu/newusers/locales/en-US.ftl @@ -0,0 +1,2 @@ +newusers-about = Create or update users in batch from stdin +newusers-usage = newusers [options] diff --git a/src/uu/newusers/src/main.rs b/src/uu/newusers/src/main.rs new file mode 100644 index 0000000..d7da8f9 --- /dev/null +++ b/src/uu/newusers/src/main.rs @@ -0,0 +1,6 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +uucore::bin!(uu_newusers); diff --git a/src/uu/newusers/src/newusers.rs b/src/uu/newusers/src/newusers.rs new file mode 100644 index 0000000..ce85c19 --- /dev/null +++ b/src/uu/newusers/src/newusers.rs @@ -0,0 +1,709 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore newusers gshadow chroot nscd sysroot yescrypt gecos + +//! `newusers` — create or update users in batch. +//! +//! Drop-in replacement for GNU shadow-utils `newusers(8)`. Reads lines of +//! seven colon-separated fields from stdin and creates the accounts they +//! describe, or updates them where they already exist. +//! +//! Every line is parsed and every account resolved before anything is written, +//! so a batch with one bad line leaves the system exactly as it was. That is +//! the property that makes it safe to feed this tool a generated file. + +use std::fmt; +use std::io::{self, BufRead}; +use std::path::{Path, PathBuf}; + +use clap::{Arg, ArgAction, Command}; + +use shadow_core::group::GroupEntry; +use shadow_core::login_defs::LoginDefs; +use shadow_core::passwd::PasswdEntry; +use shadow_core::shadow::ShadowEntry; +use shadow_core::sysroot::SysRoot; +use shadow_core::transaction::{self, Commit, LockedFile}; +use shadow_core::uid_alloc::{self, Scope}; + +use uucore::error::{UError, UResult}; + +mod options { + pub const SYSTEM: &str = "system"; + pub const BADNAME: &str = "badname"; + pub const ROOT: &str = "root"; + pub const PREFIX: &str = "prefix"; + pub const CRYPT_METHOD: &str = "crypt-method"; +} + +/// The number of colon-separated fields every input line must have. +const FIELD_COUNT: usize = 7; + +/// The mode a new home directory is created with. +const HOME_MODE: u32 = 0o700; + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +/// Errors that the `newusers` utility can produce. +#[derive(Debug)] +enum NewusersError { + /// Exit 1 — insufficient privileges. + PermissionDenied(String), + /// Exit 1 — an unexpected runtime failure. + UnexpectedFailure(String), + /// Exit 1 — could not acquire a lock on an account file. + FileBusy(String), + /// Exit 1 — an input line could not be used. + InvalidInput(String), + /// Exit 3 — the `--root` directory could not be entered. + CantChroot(String), +} + +impl fmt::Display for NewusersError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PermissionDenied(msg) + | Self::UnexpectedFailure(msg) + | Self::FileBusy(msg) + | Self::InvalidInput(msg) + | Self::CantChroot(msg) => f.write_str(msg), + } + } +} + +impl std::error::Error for NewusersError {} + +impl UError for NewusersError { + fn code(&self) -> i32 { + match self { + Self::PermissionDenied(_) + | Self::UnexpectedFailure(_) + | Self::FileBusy(_) + | Self::InvalidInput(_) => 1, + Self::CantChroot(_) => 3, + } + } +} + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +/// One input line: `name:password:uid:gid:gecos:home:shell`. +struct Line { + name: String, + password: zeroize::Zeroizing, + /// Empty means "allocate one". + uid: String, + /// Empty means "a group of the user's own"; otherwise a name or a number. + gid: String, + gecos: String, + /// Empty means the account gets no home directory. + home: String, + shell: String, + number: usize, +} + +/// Split one line into its seven fields. +/// +/// Exactly seven: six or eight are both "invalid line" to the GNU tool, and a +/// line that lost a field to a stray colon would otherwise be read as a +/// different account than the one intended. +fn parse_line(line: &str, number: usize) -> Result { + let line = line.strip_suffix('\r').unwrap_or(line); + let fields: Vec<&str> = line.split(':').collect(); + if fields.len() != FIELD_COUNT { + return Err(NewusersError::InvalidInput(format!( + "line {number}: invalid line" + ))); + } + Ok(Line { + name: fields[0].to_string(), + password: zeroize::Zeroizing::new(fields[1].to_string()), + uid: fields[2].to_string(), + gid: fields[3].to_string(), + gecos: fields[4].to_string(), + home: fields[5].to_string(), + shell: fields[6].to_string(), + number, + }) +} + +/// Read every line from stdin. +/// +/// Empty input succeeds having done nothing, which is what a script driving +/// this tool from a possibly-empty list depends on. +fn read_lines() -> Result, NewusersError> { + let stdin = io::stdin(); + let mut lines = Vec::new(); + for (idx, line) in stdin.lock().lines().enumerate() { + let line = + zeroize::Zeroizing::new(line.map_err(|e| { + NewusersError::UnexpectedFailure(format!("error reading stdin: {e}")) + })?); + lines.push(parse_line(&line, idx + 1)?); + } + Ok(lines) +} + +/// Check every field that will reach an account file. +/// +/// `relaxed` is `--badname`: it drops the portability rules on the login name +/// while keeping the checks that stop a name from corrupting the file, which +/// is what makes the flag safe to offer at all. +fn validate(line: &Line, relaxed: bool) -> Result<(), NewusersError> { + let bad = |e: shadow_core::error::ShadowError| { + NewusersError::InvalidInput(format!("line {}: {e}", line.number)) + }; + + if relaxed { + // A colon would add a field, a newline would add a record, and a name + // starting with `-` is read as an option by everything downstream. + // None of those is a matter of taste. + shadow_core::validate::validate_field("username", &line.name).map_err(bad)?; + if line.name.is_empty() || line.name.starts_with('-') { + return Err(NewusersError::InvalidInput(format!( + "line {}: invalid user name '{}'", + line.number, line.name + ))); + } + } else { + shadow_core::validate::validate_username(&line.name).map_err(bad)?; + } + + shadow_core::validate::validate_field("GECOS", &line.gecos).map_err(bad)?; + shadow_core::validate::validate_field("home directory", &line.home).map_err(bad)?; + shadow_core::validate::validate_field("shell", &line.shell).map_err(bad)?; + + // An empty password would be hashed into something a bare Enter matches, + // which is an account anyone can log into rather than one with no + // password. GNU hands the empty field to PAM, which refuses it after the + // account has already been created. + if line.password.is_empty() { + return Err(NewusersError::InvalidInput(format!( + "line {}: no password supplied for '{}'", + line.number, line.name + ))); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + shadow_core::hardening::harden_process(); + + let Some(matches) = shadow_core::cli::parse_args(uu_app(), args, |_| 2)? else { + return Ok(()); + }; + + if let Some(chroot_dir) = matches.get_one::(options::ROOT) { + shadow_core::hardening::chroot_into(Path::new(chroot_dir)) + .map_err(|e| NewusersError::CantChroot(e.to_string()))?; + } + + let prefix = matches.get_one::(options::PREFIX).map(Path::new); + let root = SysRoot::new(prefix); + + if !shadow_core::hardening::caller_is_root() { + return Err( + NewusersError::PermissionDenied(shadow_core::os_error::permission_denied()).into(), + ); + } + + let system = matches.get_flag(options::SYSTEM); + let relaxed = matches.get_flag(options::BADNAME); + let defs = LoginDefs::load(&root.login_defs_path()).unwrap_or_default(); + let method = resolve_crypt_method( + matches + .get_one::(options::CRYPT_METHOD) + .map(String::as_str), + &defs, + )?; + + let lines = read_lines()?; + for line in &lines { + validate(line, relaxed)?; + } + if lines.is_empty() { + return Ok(()); + } + + apply(&root, &defs, &lines, system, method) +} + +/// Build the clap `Command` for `newusers`. +#[must_use] +pub fn uu_app() -> Command { + Command::new("newusers") + .about("Create or update users in batch from stdin") + .override_usage("newusers [options]") + .version(shadow_core::cli::VERSION) + .after_help(shadow_core::cli::AFTER_HELP) + .arg( + Arg::new(options::SYSTEM) + .short('r') + .long("system") + .help("create system accounts") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::BADNAME) + .short('b') + .long("badname") + .help("allow names that fail the portability rules") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::CRYPT_METHOD) + .short('c') + .long("crypt-method") + .help("hashing scheme to apply (SHA256, SHA512, YESCRYPT)") + .value_name("METHOD") + .value_parser(["SHA256", "SHA512", "YESCRYPT", "DES", "MD5", "NONE"]), + ) + .arg( + Arg::new(options::ROOT) + .short('R') + .long("root") + .help("chroot into CHROOT_DIR before applying changes") + .value_name("CHROOT_DIR"), + ) + .arg( + Arg::new(options::PREFIX) + .short('P') + .long("prefix") + .help("directory prefix") + .value_name("PREFIX_DIR"), + ) +} + +// --------------------------------------------------------------------------- +// Applying the batch +// --------------------------------------------------------------------------- + +/// A home directory to create once the account files are safely written. +struct PendingHome { + path: PathBuf, + uid: u32, + gid: u32, +} + +/// Resolve and apply every line in one transaction. +fn apply( + root: &SysRoot, + defs: &LoginDefs, + lines: &[Line], + system: bool, + method: shadow_core::crypt::CryptMethod, +) -> UResult<()> { + // Hash before taking any lock: crypt(3) is deliberately slow, and a batch + // of them would otherwise hold every account file, with signals blocked, + // for the whole run. + let mut hashes = Vec::with_capacity(lines.len()); + for line in lines { + hashes.push( + shadow_core::crypt::hash_password(&line.password, method, None).map_err(|e| { + NewusersError::UnexpectedFailure(format!( + "line {}: cannot hash the password for '{}': {e}", + line.number, line.name + )) + })?, + ); + } + + let scope = Scope::for_prefix(root.is_prefixed()); + let mut passwd = open::(&root.passwd_path())?; + let mut shadow = open::(&root.shadow_path())?; + let mut group = open::(&root.group_path())?; + + let today = shadow_core::shadow::days_since_epoch().map_err(|e| { + NewusersError::UnexpectedFailure(format!("cannot determine the current date: {e}")) + })?; + let (uid_min, uid_max) = uid_alloc::uid_range(defs, system); + let (gid_min, gid_max) = uid_alloc::gid_range(defs, system); + + let mut homes = Vec::new(); + + for (line, hash) in lines.iter().zip(hashes) { + let existing = passwd.entries().iter().position(|e| e.name == line.name); + + let uid = match (line.uid.as_str(), existing) { + // An empty field on an existing account keeps the ID it has; + // reallocating would orphan every file the account owns. + ("", Some(i)) => passwd.entries()[i].uid, + ("", None) => uid_alloc::next_uid(passwd.entries(), uid_min, uid_max, scope) + .map_err(|e| line_error(line, &e.to_string()))?, + (given, _) => parse_id(given) + .ok_or_else(|| line_error(line, &format!("invalid user ID '{given}'")))?, + }; + + let gid = resolve_gid(&mut group, line, uid, gid_min, gid_max, scope)?; + + match existing { + Some(i) => { + let entry = &mut passwd.entries_mut()[i]; + entry.uid = uid; + entry.gid = gid; + entry.gecos.clone_from(&line.gecos); + entry.home.clone_from(&line.home); + entry.shell.clone_from(&line.shell); + } + None => passwd.entries_mut().push(PasswdEntry { + name: line.name.clone(), + passwd: "x".to_string(), + uid, + gid, + gecos: line.gecos.clone(), + home: line.home.clone(), + shell: line.shell.clone(), + }), + } + + set_shadow(&mut shadow, &line.name, hash, today, defs); + + if !line.home.is_empty() { + homes.push(PendingHome { + path: root.resolve(&line.home), + uid, + gid, + }); + } + } + + // Every file is validated before any is written, so a value that would + // corrupt one cannot leave the set disagreeing. + let files: Vec> = vec![Box::new(passwd), Box::new(shadow), Box::new(group)]; + transaction::commit_all(files) + .map_err(|e| NewusersError::UnexpectedFailure(format!("cannot write: {e}")))?; + + shadow_core::nscd::invalidate_cache("passwd"); + shadow_core::nscd::invalidate_cache("group"); + + // Homes come after the commit. A home that cannot be created is worth + // reporting, but the accounts are already correct and rolling them back + // would be a bigger surprise than a missing directory. + create_homes(root, &homes)?; + + for line in lines { + shadow_core::audit::log_user_event("ADD_USER", &line.name, 0, true); + } + + Ok(()) +} + +/// Wrap a message with the line it came from. +fn line_error(line: &Line, message: &str) -> NewusersError { + NewusersError::InvalidInput(format!("line {}: {message}", line.number)) +} + +/// A field that must be a plain unsigned number, with no sign or padding. +fn parse_id(value: &str) -> Option { + if value.chars().all(|c| c.is_ascii_digit()) && !value.is_empty() { + value.parse().ok() + } else { + None + } +} + +/// Work out the group for one line, creating it where the field asks for one +/// that does not exist yet. +fn resolve_gid( + group: &mut LockedFile, + line: &Line, + uid: u32, + gid_min: u32, + gid_max: u32, + scope: Scope, +) -> Result { + // A number names a group directly. If no group has it, one is created + // carrying the user's name -- otherwise the account would be left pointing + // at a group that does not exist, which is what the GNU tool does here and + // what grpck then reports. + if let Some(gid) = parse_id(&line.gid) { + if !group.entries().iter().any(|g| g.gid == gid) { + push_group(group, &line.name, gid); + } + return Ok(gid); + } + + // A name must already exist. GNU silently falls back to the user's own ID + // and leaves no group behind at all, so the account ends up with a + // dangling GID; naming a group that is not there is a mistake worth + // reporting rather than papering over. + if !line.gid.is_empty() { + return match group.entries().iter().find(|g| g.name == line.gid) { + Some(found) => Ok(found.gid), + None => Err(line_error( + line, + &format!("group '{}' does not exist", line.gid), + )), + }; + } + + // An empty field asks for a group of the user's own. Reuse it if a group + // of that name is already there, so a second run over the same input does + // not fail. + if let Some(found) = group.entries().iter().find(|g| g.name == line.name) { + return Ok(found.gid); + } + // Matching the UID keeps user-private groups readable at a glance, which + // is the convention every distribution follows; falling back to the + // allocator when it is taken keeps that a preference, not a requirement. + let gid = if group.entries().iter().any(|g| g.gid == uid) { + uid_alloc::next_gid(group.entries(), gid_min, gid_max, scope) + .map_err(|e| line_error(line, &e.to_string()))? + } else { + uid + }; + push_group(group, &line.name, gid); + Ok(gid) +} + +/// Add a group with no members: the user's primary group is recorded in +/// `/etc/passwd`, not in the member list. +fn push_group(group: &mut LockedFile, name: &str, gid: u32) { + group.entries_mut().push(GroupEntry { + name: name.to_string(), + passwd: "x".to_string(), + gid, + members: Vec::new(), + }); +} + +/// Write the account's hash and aging fields. +fn set_shadow( + shadow: &mut LockedFile, + name: &str, + hash: String, + today: i64, + defs: &LoginDefs, +) { + if let Some(entry) = shadow.entries_mut().iter_mut().find(|e| e.name == name) { + entry.passwd = hash; + entry.last_change = Some(today); + return; + } + shadow.entries_mut().push(ShadowEntry { + name: name.to_string(), + passwd: hash, + last_change: Some(today), + min_age: defs.get_i64("PASS_MIN_DAYS"), + max_age: defs.get_i64("PASS_MAX_DAYS"), + warn_days: defs.get_i64("PASS_WARN_AGE"), + inactive_days: None, + expire_date: None, + reserved: String::new(), + }); +} + +/// Create the home directories the batch asked for. +fn create_homes(root: &SysRoot, homes: &[PendingHome]) -> UResult<()> { + if homes.is_empty() { + return Ok(()); + } + let skel = root.skel_path(); + for home in homes { + let outcome = shadow_core::home::create(&home.path, &skel, home.uid, home.gid, HOME_MODE) + .map_err(|e| NewusersError::UnexpectedFailure(e.to_string()))?; + if outcome == shadow_core::home::Outcome::AlreadyExisted { + uucore::show_warning!( + "home directory '{}' already exists -- not copying from skel directory", + home.path.display() + ); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Lock and read an account file, mapping contention to its own error. +fn open(path: &Path) -> Result, NewusersError> +where + T: transaction::Record, +{ + LockedFile::::open_or_empty(path).map_err(|e| match e { + shadow_core::error::ShadowError::Lock(_) => { + NewusersError::FileBusy(format!("cannot lock {}: try again later", path.display())) + } + other => { + NewusersError::UnexpectedFailure(format!("cannot open {}: {other}", path.display())) + } + }) +} + +/// The hashing scheme, from `-c` or the system's configuration. +fn resolve_crypt_method( + method: Option<&str>, + defs: &LoginDefs, +) -> Result { + match method { + Some(name) => parse_crypt_method(name).ok_or_else(|| { + NewusersError::UnexpectedFailure(match name { + "NONE" => "NONE would store the password unhashed and is not supported".into(), + "MD5" | "DES" => "MD5 and DES are insecure and not supported".into(), + other => format!("unknown crypt method: {other}"), + }) + }), + None => Ok(defs + .get("ENCRYPT_METHOD") + .and_then(parse_crypt_method) + .unwrap_or(shadow_core::crypt::CryptMethod::Sha512)), + } +} + +/// Map a scheme name to a `CryptMethod`, refusing the ones this build will not +/// write. +fn parse_crypt_method(name: &str) -> Option { + use shadow_core::crypt::CryptMethod; + + match name { + "SHA256" => Some(CryptMethod::Sha256), + "SHA512" => Some(CryptMethod::Sha512), + "YESCRYPT" => Some(CryptMethod::Yescrypt), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_app_builds() { + uu_app().debug_assert(); + } + + #[test] + fn test_parse_line_takes_seven_fields() { + let line = parse_line("alice:pw:1000:1000:Alice:/home/alice:/bin/sh", 1).expect("parses"); + assert_eq!(line.name, "alice"); + assert_eq!(&*line.password, "pw"); + assert_eq!(line.uid, "1000"); + assert_eq!(line.gid, "1000"); + assert_eq!(line.gecos, "Alice"); + assert_eq!(line.home, "/home/alice"); + assert_eq!(line.shell, "/bin/sh"); + } + + /// Six fields or eight are both wrong. A line that lost one to a stray + /// colon would otherwise describe a different account than intended. + #[test] + fn test_wrong_field_count_is_refused() { + for bad in [ + "alice:pw:1000", + "alice:pw:1000:1000:Alice:/home/alice", + "alice:pw:1000:1000:Alice:/home/alice:/bin/sh:extra", + "", + "alice", + ] { + let err = parse_line(bad, 3).err().expect("should be refused"); + assert!( + format!("{err}").contains("line 3: invalid line"), + "unexpected message for {bad:?}: {err}" + ); + } + } + + /// Every field may be empty except the count of them. + #[test] + fn test_all_optional_fields_may_be_empty() { + let line = parse_line("alice:pw:::::", 1).expect("parses"); + assert_eq!(line.name, "alice"); + assert!(line.uid.is_empty()); + assert!(line.gid.is_empty()); + assert!(line.home.is_empty()); + } + + fn line(spec: &str) -> Line { + parse_line(spec, 1).expect("parses") + } + + /// An empty password would be hashed into something a bare Enter matches. + #[test] + fn test_empty_password_is_refused() { + let err = validate(&line("alice::1000:1000:::"), false).expect_err("refused"); + assert!(format!("{err}").contains("no password supplied"), "{err}"); + } + + /// A field carrying a colon or a newline would add a field or a record. + #[test] + fn test_fields_that_would_corrupt_the_file_are_refused() { + assert!(validate(&line("alice:pw:1000:1000:a\nb:/h:/bin/sh"), false).is_err()); + assert!(validate(&line("alice:pw:1000:1000::/h:/bin/sh"), false).is_ok()); + } + + /// `--badname` drops the portability rules but keeps the ones that stop a + /// name from corrupting the file or being read as an option. + #[test] + fn test_badname_relaxes_only_the_portability_rules() { + // Both are refused by the portability rules and both turn up on real + // systems: a name starting with a digit, and the domain-qualified form + // an Active Directory join produces. Neither can corrupt a file. + for odd in ["3dprint:pw:1000:1000:::", "alice@corp:pw:1000:1000:::"] { + assert!( + validate(&line(odd), false).is_err(), + "the strict rules should refuse {odd:?}" + ); + assert!( + validate(&line(odd), true).is_ok(), + "--badname should allow {odd:?}" + ); + } + + for hostile in ["-flag:pw:1000:1000:::", ":pw:1000:1000:::"] { + assert!( + validate(&line(hostile), true).is_err(), + "--badname must not allow {hostile:?}" + ); + } + } + + /// IDs are plain numbers: a sign or a trailing letter is a typo, and + /// silently taking the leading digits would create the wrong account. + #[test] + fn test_parse_id() { + assert_eq!(parse_id("1000"), Some(1000)); + assert_eq!(parse_id("0"), Some(0)); + for bad in ["", "-1", "+1", "10x", " 10", "1 0", "99999999999999"] { + assert_eq!(parse_id(bad), None, "{bad:?} should not parse"); + } + } + + #[test] + fn test_crypt_methods_that_are_refused() { + let defs = LoginDefs::default(); + for bad in ["NONE", "MD5", "DES", "BCRYPT"] { + assert!(resolve_crypt_method(Some(bad), &defs).is_err(), "{bad}"); + } + assert!(resolve_crypt_method(Some("SHA512"), &defs).is_ok()); + // No -c and no configuration falls back rather than failing. + assert!(resolve_crypt_method(None, &defs).is_ok()); + } + + #[test] + fn test_exit_codes() { + use uucore::error::UError; + + assert_eq!(NewusersError::PermissionDenied("x".into()).code(), 1); + assert_eq!(NewusersError::UnexpectedFailure("x".into()).code(), 1); + assert_eq!(NewusersError::FileBusy("x".into()).code(), 1); + assert_eq!(NewusersError::InvalidInput("x".into()).code(), 1); + assert_eq!(NewusersError::CantChroot("x".into()).code(), 3); + } +} diff --git a/src/uu/useradd/src/useradd.rs b/src/uu/useradd/src/useradd.rs index a1ba0ec..e8aeb53 100644 --- a/src/uu/useradd/src/useradd.rs +++ b/src/uu/useradd/src/useradd.rs @@ -14,7 +14,6 @@ //! directory and populate it from `/etc/skel`. use std::fmt; -use std::os::unix::fs::DirBuilderExt; use std::path::Path; use clap::{Arg, ArgAction, Command}; @@ -26,7 +25,6 @@ use shadow_core::login_defs::{self, LoginDefs}; use shadow_core::nscd; use shadow_core::passwd::PasswdEntry; use shadow_core::shadow::ShadowEntry; -use shadow_core::skel; use shadow_core::sysroot::SysRoot; use shadow_core::transaction::{self, Commit, LockedFile}; use shadow_core::uid_alloc; @@ -998,97 +996,18 @@ fn create_home_directory( gid: u32, mode: u32, ) -> UResult<()> { - // The kernel does not reset umask across setuid, so a caller-controlled - // inherited umask may still be in effect in our process. A non-zero umask - // can mask off requested permission bits, so even mkdir(0o700) is not - // guaranteed to result in 0o700 unless we clear it first (e.g., umask - // 0o700 would mask the user RWX bits and leave the dir at 0o000). - // Forcing umask to 0 makes the requested mode exact, regardless of caller - // environment; umask can only make the result less permissive than the - // mode we requested, never more. Scoped to the mkdir call only — chown - // doesn't need it, and copy_skel manages its own umask internally. - // useradd(8) -b: with -m the base directory is created if it is missing, - // so a home under a path that does not exist yet works. Ancestors get the - // conventional 0755 and stay root-owned; only the home itself takes `mode` - // and the user's ownership. - if let Some(parent) = home_path.parent() - && !parent.as_os_str().is_empty() - && !parent.exists() - { - let _umask = shadow_core::atomic::UmaskGuard::zero(); - std::fs::DirBuilder::new() - .recursive(true) - .mode(0o755) - .create(parent) - .map_err(|e| { - UseraddError::CannotCreateHome(format!( - "cannot create directory '{}': {e}", - parent.display() - )) - })?; - } - - let mkdir_result = { - let _umask = shadow_core::atomic::UmaskGuard::zero(); - std::fs::DirBuilder::new().mode(mode).create(home_path) - }; - - // Use DirBuilder::mode() so mkdir(2) is called with 0o700 atomically. - // Use create (not recursive) to avoid TOCTOU between exists() and mkdir(). - match mkdir_result { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - uucore::show_warning!( - "home directory '{}' already exists -- not copying from skel directory", - home_path.display() - ); - return Ok(()); - } - Err(e) => { - return Err(UseraddError::CannotCreateHome(format!( - "cannot create directory '{}': {e}", - home_path.display() - )) - .into()); - } - } - - // Change ownership through a descriptor opened with O_NOFOLLOW rather - // than by path: between the mkdir above and this call, anyone able to - // write the parent (a home under /tmp or a shared base directory) could - // swap the directory for a symlink and have us hand them the target. - { - use rustix::fs::{Mode, OFlags}; - let dir = rustix::fs::open( - home_path, - OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW, - Mode::empty(), - ) - .map_err(|e| { - UseraddError::CannotCreateHome(format!("cannot open '{}': {e}", home_path.display())) - })?; - rustix::fs::fchown( - &dir, - Some(rustix::fs::Uid::from_raw(uid)), - Some(rustix::fs::Gid::from_raw(gid)), - ) - .map_err(|e| { - UseraddError::CannotCreateHome(format!( - "cannot set ownership on '{}': {e}", - home_path.display() - )) - })?; - } - - // Copy skeleton directory contents. - skel::copy_skel(skel_path, home_path, uid, gid).map_err(|e| { - UseraddError::CannotCreateHome(format!( - "cannot copy skel '{}' to '{}': {e}", - skel_path.display(), + // The mkdir, the umask handling and the O_NOFOLLOW chown live in + // shadow_core::home because newusers(8) has to do exactly the same thing; + // a second copy would be a second chance to get the ownership handover + // wrong, silently. + let outcome = shadow_core::home::create(home_path, skel_path, uid, gid, mode) + .map_err(|e| UseraddError::CannotCreateHome(e.to_string()))?; + if outcome == shadow_core::home::Outcome::AlreadyExisted { + uucore::show_warning!( + "home directory '{}' already exists -- not copying from skel directory", home_path.display() - )) - })?; - + ); + } Ok(()) } diff --git a/tests/arm64-smoke.sh b/tests/arm64-smoke.sh index 21c61a5..92c096c 100755 --- a/tests/arm64-smoke.sh +++ b/tests/arm64-smoke.sh @@ -64,10 +64,10 @@ version=$("${QEMU[@]}" "$BIN" --version 2>&1) if [ -n "$version" ]; then ok "runs: $version"; else bad "would not run"; exit 1; fi applets=$("${QEMU[@]}" "$BIN" --list 2>/dev/null | tail -n +2 | wc -l) -if [ "$applets" -ge 16 ]; then +if [ "$applets" -ge 18 ]; then ok "carries $applets applets" else - bad "expected at least 16 applets, found $applets" + bad "expected at least 18 applets, found $applets" fi # ── A prefix tree, so nothing here touches the container's own accounts ── @@ -124,6 +124,15 @@ fi # wrong struct width there would show up as a failure to run at all. check "sg starts" "${QEMU[@]}" "$BIN" sg --help +# newusers writes three files and a home directory from one line, so it +# exercises the allocator, crypt(3) and the fchown in shadow_core::home +# together -- the widest single check available here. +printf 'batched:a long passphrase:2500:2500:Batched::/bin/sh\n' \ + | "${QEMU[@]}" "$BIN" newusers -P "$T" >/dev/null 2>&1 +contains "newusers created the account" "$T/etc/passwd" '^batched:x:2500:2500:' +contains "with a SHA-512 hash" "$T/etc/shadow" '^batched:\$6\$' +contains "and a group of its own" "$T/etc/group" '^batched:x:2500:' + # pwck exits 2 for warnings, which a synthetic tree produces (no real shells), # so anything up to 2 means it read and checked the files rather than failing. "${QEMU[@]}" "$BIN" pwck -r "$T/etc/passwd" "$T/etc/shadow" >/dev/null 2>&1 diff --git a/tests/by-util/test_multicall.rs b/tests/by-util/test_multicall.rs index 5ce54f1..65dfd91 100644 --- a/tests/by-util/test_multicall.rs +++ b/tests/by-util/test_multicall.rs @@ -16,7 +16,7 @@ use std::process::Command; use crate::common::{run, run_cmd}; /// Every applet this build is expected to carry, in `--list` order. -const TOOLS: [&str; 17] = [ +const TOOLS: [&str; 18] = [ "chage", "chfn", "chgpasswd", @@ -28,6 +28,7 @@ const TOOLS: [&str; 17] = [ "groupmod", "grpck", "newgrp", + "newusers", "passwd", "pwck", "sg", diff --git a/tests/by-util/test_newusers.rs b/tests/by-util/test_newusers.rs new file mode 100644 index 0000000..cda33ac --- /dev/null +++ b/tests/by-util/test_newusers.rs @@ -0,0 +1,400 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore newusers gshadow gecos + +//! Integration tests for the `newusers` utility. +//! +//! `newusers` writes three files and a directory from one input line, so the +//! tests run the real binary against a prefix tree and read all four back. +//! Checking only `/etc/passwd` would miss the half of the job that makes the +//! account usable. + +use std::io::Write as _; +use std::process::Stdio; + +use crate::common::{Output, skip_unless_root, tool}; + +/// A prefix tree with the account files and a skeleton directory. +fn prefix() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let etc = dir.path().join("etc"); + std::fs::create_dir_all(&etc).expect("etc"); + std::fs::write(etc.join("passwd"), "root:x:0:0:root:/root:/bin/sh\n").expect("passwd"); + std::fs::write(etc.join("shadow"), "root:!:19000:0:99999:7:::\n").expect("shadow"); + std::fs::write(etc.join("group"), "root:x:0:\nstaff:x:2000:\n").expect("group"); + std::fs::write( + etc.join("login.defs"), + "UID_MIN 1000\nGID_MIN 1000\nENCRYPT_METHOD SHA512\n", + ) + .expect("login.defs"); + let skel = etc.join("skel"); + std::fs::create_dir_all(&skel).expect("skel"); + std::fs::write(skel.join(".profile"), "# from skel\n").expect("skel file"); + dir +} + +fn read(dir: &tempfile::TempDir, name: &str) -> String { + std::fs::read_to_string(dir.path().join("etc").join(name)) + .unwrap_or_else(|e| panic!("cannot read {name}: {e}")) +} + +/// One record from a colon-separated file, split into fields. +fn record(dir: &tempfile::TempDir, file: &str, name: &str) -> Vec { + read(dir, file) + .lines() + .find(|l| l.starts_with(&format!("{name}:"))) + .unwrap_or_else(|| panic!("no {file} entry for {name}")) + .split(':') + .map(str::to_string) + .collect() +} + +fn has_entry(dir: &tempfile::TempDir, file: &str, name: &str) -> bool { + read(dir, file) + .lines() + .any(|l| l.starts_with(&format!("{name}:"))) +} + +/// Run `newusers --prefix ` with `input` on stdin. +fn newusers(dir: &tempfile::TempDir, args: &[&str], input: &str) -> Output { + let mut cmd = tool("newusers"); + cmd.arg("--prefix") + .arg(dir.path()) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn().expect("cannot spawn newusers"); + child + .stdin + .as_mut() + .expect("stdin") + .write_all(input.as_bytes()) + .expect("cannot write to newusers"); + let out = child.wait_with_output().expect("newusers did not finish"); + Output { + code: out.status.code().unwrap_or(1), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + } +} + +// --------------------------------------------------------------------------- +// Creating accounts +// --------------------------------------------------------------------------- + +/// One line has to produce a complete, usable account: the passwd record, a +/// hashed shadow record, a group, and a home directory carrying the skeleton. +#[test] +fn test_one_line_creates_a_whole_account() { + if skip_unless_root() { + return; + } + let dir = prefix(); + newusers( + &dir, + &[], + "alice:secret:3000:3000:Alice A:/home/alice:/bin/bash\n", + ) + .assert_code(0); + + let passwd = record(&dir, "passwd", "alice"); + assert_eq!(passwd[1], "x", "the hash must not be in /etc/passwd"); + assert_eq!(passwd[2], "3000"); + assert_eq!(passwd[3], "3000"); + assert_eq!(passwd[4], "Alice A"); + assert_eq!(passwd[5], "/home/alice"); + assert_eq!(passwd[6], "/bin/bash"); + + let shadow = record(&dir, "shadow", "alice"); + assert!( + shadow[1].starts_with("$6$"), + "shadow should hold a SHA-512 hash, got {:?}", + shadow[1] + ); + assert!(!shadow[2].is_empty(), "the last-change day must be set"); + + assert!(has_entry(&dir, "group", "alice"), "no group was created"); + + let home = dir.path().join("home/alice"); + assert!(home.is_dir(), "the home directory was not created"); + assert!( + home.join(".profile").exists(), + "the skeleton was not copied in" + ); +} + +/// Empty ID fields ask for an allocation, and it has to respect login.defs. +#[test] +fn test_empty_ids_are_allocated() { + if skip_unless_root() { + return; + } + // The home field is left empty: no directory, but real IDs. + let dir = prefix(); + newusers(&dir, &[], "alice:secret:::::\n").assert_code(0); + let passwd = record(&dir, "passwd", "alice"); + let uid: u32 = passwd[2].parse().expect("uid"); + assert!(uid >= 1000, "allocated uid {uid} is below UID_MIN"); + assert!(has_entry(&dir, "group", "alice"), "no group was created"); + assert!( + !dir.path().join("home").exists(), + "an empty home field must not create a directory" + ); +} + +/// A numeric group that does not exist yet is created, so the account is never +/// left pointing at a group that is not there. +#[test] +fn test_a_numeric_gid_creates_the_missing_group() { + if skip_unless_root() { + return; + } + let dir = prefix(); + newusers(&dir, &[], "alice:secret:3000:7777:::\n").assert_code(0); + assert_eq!(record(&dir, "passwd", "alice")[3], "7777"); + assert_eq!( + record(&dir, "group", "alice")[2], + "7777", + "a group carrying the user's name should have been created" + ); +} + +/// A group named in the field is used as it stands. +#[test] +fn test_a_named_group_is_used() { + if skip_unless_root() { + return; + } + let dir = prefix(); + newusers(&dir, &[], "alice:secret:3000:staff:::\n").assert_code(0); + assert_eq!(record(&dir, "passwd", "alice")[3], "2000"); +} + +/// GNU quietly falls back to the user's own ID here and creates no group, +/// leaving the account pointing at a GID that does not exist. Naming a group +/// that is not there is a mistake worth reporting. +#[test] +fn test_an_unknown_group_name_is_refused() { + if skip_unless_root() { + return; + } + let dir = prefix(); + let before = read(&dir, "passwd"); + newusers(&dir, &[], "alice:secret:3000:nosuchgroup:::\n") + .assert_code(1) + .assert_stderr_contains("does not exist"); + assert_eq!(read(&dir, "passwd"), before, "nothing may have changed"); +} + +// --------------------------------------------------------------------------- +// Updating accounts +// --------------------------------------------------------------------------- + +/// An account that is already there is updated rather than refused. +#[test] +fn test_an_existing_account_is_updated() { + if skip_unless_root() { + return; + } + let dir = prefix(); + newusers( + &dir, + &[], + "alice:secret:3000:3000:First:/home/alice:/bin/sh\n", + ) + .assert_code(0); + let first_hash = record(&dir, "shadow", "alice")[1].clone(); + + newusers( + &dir, + &[], + "alice:other:3000:3000:Second:/home/alice:/bin/bash\n", + ) + .assert_code(0); + + let passwd = record(&dir, "passwd", "alice"); + assert_eq!(passwd[4], "Second"); + assert_eq!(passwd[6], "/bin/bash"); + assert_ne!( + record(&dir, "shadow", "alice")[1], + first_hash, + "the password should have been changed" + ); + assert_eq!( + read(&dir, "passwd") + .lines() + .filter(|l| l.starts_with("alice:")) + .count(), + 1, + "the account must not be duplicated" + ); +} + +/// An empty ID field on an existing account keeps the ID it has: reallocating +/// would orphan every file the account owns. +#[test] +fn test_an_empty_uid_keeps_the_existing_one() { + if skip_unless_root() { + return; + } + let dir = prefix(); + newusers(&dir, &[], "alice:secret:3000:3000:::\n").assert_code(0); + newusers(&dir, &[], "alice:secret::3000:Changed::\n").assert_code(0); + assert_eq!(record(&dir, "passwd", "alice")[2], "3000"); +} + +// --------------------------------------------------------------------------- +// All or nothing +// --------------------------------------------------------------------------- + +/// The property that makes this safe to feed a generated file: one bad line +/// and the system is exactly as it was, including for the good lines above it. +#[test] +fn test_a_bad_line_changes_nothing() { + if skip_unless_root() { + return; + } + let dir = prefix(); + let passwd_before = read(&dir, "passwd"); + let shadow_before = read(&dir, "shadow"); + + newusers(&dir, &[], "alice:secret:3000:3000:::\nbadline\n") + .assert_code(1) + .assert_stderr_contains("line 2: invalid line"); + + assert_eq!(read(&dir, "passwd"), passwd_before); + assert_eq!(read(&dir, "shadow"), shadow_before); + assert!( + !dir.path().join("home").exists(), + "no home may be created for a batch that failed" + ); +} + +/// Six fields and eight are both wrong: a line that lost one to a stray colon +/// would otherwise describe a different account than intended. +#[test] +fn test_wrong_field_count_is_refused() { + if skip_unless_root() { + return; + } + let dir = prefix(); + for bad in [ + "alice:secret:3000:3000:x:/home/alice\n", + "alice:secret:3000:3000:x:/home/alice:/bin/sh:extra\n", + "\n", + ] { + newusers(&dir, &[], bad) + .assert_code(1) + .assert_stderr_contains("invalid line"); + } + assert!(!has_entry(&dir, "passwd", "alice")); +} + +/// An empty password would be hashed into something a bare Enter matches. +/// GNU hands it to PAM, which refuses it *after* creating the account. +#[test] +fn test_an_empty_password_is_refused_before_anything_is_written() { + if skip_unless_root() { + return; + } + let dir = prefix(); + newusers(&dir, &[], "alice::3000:3000:::\n") + .assert_code(1) + .assert_stderr_contains("no password supplied"); + assert!( + !has_entry(&dir, "passwd", "alice"), + "the account must not exist after the refusal" + ); +} + +/// A field carrying a newline would add a record; `useradd -c` once created a +/// passwordless UID 0 account that way. +#[test] +fn test_a_field_that_would_add_a_record_is_refused() { + if skip_unless_root() { + return; + } + let dir = prefix(); + newusers( + &dir, + &[], + "alice:secret:3000:3000:x\nevil::0:0::/:/bin/sh:/home/alice:/bin/sh\n", + ) + .assert_code(1); + assert!(!has_entry(&dir, "passwd", "evil"), "a record was injected"); +} + +// --------------------------------------------------------------------------- +// Input and flags +// --------------------------------------------------------------------------- + +/// Empty input succeeds having done nothing. +#[test] +fn test_empty_input_succeeds() { + if skip_unless_root() { + return; + } + let dir = prefix(); + let before = read(&dir, "passwd"); + newusers(&dir, &[], "").assert_code(0); + assert_eq!(read(&dir, "passwd"), before); +} + +/// Several accounts in one batch all land. +#[test] +fn test_a_whole_batch_applies() { + if skip_unless_root() { + return; + } + let dir = prefix(); + newusers( + &dir, + &[], + "alice:one:3000:3000:::\nbob:two:3001:3001:::\ncarol:three:3002:3002:::\n", + ) + .assert_code(0); + for name in ["alice", "bob", "carol"] { + assert!(has_entry(&dir, "passwd", name), "{name} is missing"); + assert!(has_entry(&dir, "shadow", name), "{name} has no hash"); + } +} + +/// `--badname` allows a name the portability rules refuse but which cannot +/// corrupt the file -- the domain-qualified form a directory join produces. +#[test] +fn test_badname_allows_a_domain_qualified_name() { + if skip_unless_root() { + return; + } + let dir = prefix(); + newusers(&dir, &[], "alice@corp:secret:3000:3000:::\n").assert_code(1); + newusers(&dir, &["--badname"], "alice@corp:secret:3000:3000:::\n").assert_code(0); + assert!(has_entry(&dir, "passwd", "alice@corp")); +} + +/// `-r` allocates from the system range. +#[test] +fn test_system_accounts_come_from_the_system_range() { + if skip_unless_root() { + return; + } + let dir = prefix(); + newusers(&dir, &["-r"], "svc:secret:::::\n").assert_code(0); + let uid: u32 = record(&dir, "passwd", "svc")[2].parse().expect("uid"); + assert!( + uid < 1000, + "a system account should be below UID_MIN, got {uid}" + ); +} + +#[test] +fn test_help_exits_zero() { + let dir = prefix(); + newusers(&dir, &["--help"], "") + .assert_code(0) + .assert_stdout_contains("Usage:"); +} diff --git a/tests/e2e/deploy-test.sh b/tests/e2e/deploy-test.sh index 964cad0..5f558ab 100755 --- a/tests/e2e/deploy-test.sh +++ b/tests/e2e/deploy-test.sh @@ -100,7 +100,7 @@ hash_password() { # ── TOOLS list ────────────────────────────────────────────────────── -TOOLS="passwd pwck useradd userdel usermod chpasswd chgpasswd chage groupadd groupdel groupmod gpasswd grpck chfn chsh newgrp sg" +TOOLS="passwd pwck useradd userdel usermod chpasswd chgpasswd newusers chage groupadd groupdel groupmod gpasswd grpck chfn chsh newgrp sg" SETUID_TOOLS="passwd chfn chsh newgrp gpasswd sg" # The tools an unprivileged user runs are installed in bin, the rest in sbin, @@ -869,6 +869,74 @@ test_gpasswd_group_admin() { userdel -r gp_member 2>/dev/null || true } +# ── newusers: batch account creation ─────────────────────────────── + +test_newusers() { + section "newusers — batch account creation" + + for u in nu_alice nu_bob nu_svc; do userdel -r $u 2>/dev/null || true; done + groupdel nu_shared 2>/dev/null || true + assert_ok "groupadd nu_shared" groupadd nu_shared + + assert_ok "a batch of three accounts applies" \ + bash -c "printf 'nu_alice:pw1:::Alice:/home/nu_alice:/bin/bash\nnu_bob:pw2::nu_shared:Bob:/home/nu_bob:/bin/sh\nnu_svc:pw3:::Service::/usr/sbin/nologin\n' | newusers" + + assert_file_contains "nu_alice is in passwd" /etc/passwd '^nu_alice:x:' + assert_file_contains "nu_alice has a hash in shadow" /etc/shadow '^nu_alice:\$' + assert_file_contains "nu_alice got a group of her own" /etc/group '^nu_alice:' + assert_ok "nu_alice has a home" test -d /home/nu_alice + assert_ok "the skeleton reached it" bash -c "ls -A /home/nu_alice | grep -q ." + assert_ok "the home belongs to her" \ + bash -c "test \"\$(stat -c %U /home/nu_alice)\" = nu_alice" + assert_ok "and is private" \ + bash -c "test \"\$(stat -c %a /home/nu_alice)\" = 700" + + # A named group in the gid field is used rather than a new one invented. + assert_ok "nu_bob landed in the named group" \ + bash -c "test \"\$(id -gn nu_bob)\" = nu_shared" + + # An empty home field means no directory at all. + assert_ok "nu_svc has no home directory" bash -c "! test -e /home/nu_svc" + + # The default scheme is the host's, from login.defs, not a hard-coded one. + assert_file_contains "the default scheme follows login.defs (yescrypt here)" \ + /etc/shadow '^nu_alice:\$y\$' + + # And the hash has to actually encode the password supplied, not merely + # look like a hash. Re-hash the plaintext with the salt taken from the + # stored field and compare -- a tool that wrote a well-formed hash of the + # wrong string passes every shape check above and fails this one. Done with + # -c SHA512 because openssl can recompute that one. + userdel -r nu_check 2>/dev/null || true + assert_ok "an account hashed with SHA512" \ + bash -c "printf 'nu_check:checkpw:::::\n' | newusers -c SHA512" + assert_ok "the stored hash really encodes the supplied password" \ + bash -c 'stored=$(grep "^nu_check:" /etc/shadow | cut -d: -f2); \ + salt=$(printf "%s" "$stored" | cut -d"$" -f3); \ + test "$stored" = "$(openssl passwd -6 -salt "$salt" checkpw)"' + assert_fail "and does not encode a different one" \ + bash -c 'stored=$(grep "^nu_check:" /etc/shadow | cut -d: -f2); \ + salt=$(printf "%s" "$stored" | cut -d"$" -f3); \ + test "$stored" = "$(openssl passwd -6 -salt "$salt" wrongpw)"' + userdel -r nu_check 2>/dev/null || true + + # All or nothing. + for u in nu_x nu_y; do userdel -r $u 2>/dev/null || true; done + assert_fail "a batch with a bad line fails" \ + bash -c "printf 'nu_x:pw:::::\nbadline\n' | newusers" + assert_ok "and created nothing" bash -c "! grep -q '^nu_x:' /etc/passwd" + + assert_fail "an empty password is refused" \ + bash -c "printf 'nu_y::::::\n' | newusers" + assert_ok "leaving no account behind" bash -c "! grep -q '^nu_y:' /etc/passwd" + + assert_ok "empty input succeeds having done nothing" \ + bash -c "newusers < /dev/null" + + for u in nu_alice nu_bob nu_svc; do userdel -r $u 2>/dev/null || true; done + groupdel nu_shared 2>/dev/null || true +} + # ── chgpasswd: group passwords in batch ──────────────────────────── test_chgpasswd() { @@ -1090,6 +1158,7 @@ main() { test_gpasswd_group_admin test_sg_group_switch test_chgpasswd + test_newusers test_aging_and_input test_audit_logging test_root_option diff --git a/tests/gnu-compat.sh b/tests/gnu-compat.sh index 1e96bcd..41a08d8 100755 --- a/tests/gnu-compat.sh +++ b/tests/gnu-compat.sh @@ -225,6 +225,7 @@ for pair in \ "groupmod:/usr/sbin/groupmod" \ "chpasswd:/usr/sbin/chpasswd" \ "chgpasswd:/usr/sbin/chgpasswd" \ + "newusers:/usr/sbin/newusers" \ "gpasswd:/usr/bin/gpasswd" \ "pwck:/usr/sbin/pwck" \ "grpck:/usr/sbin/grpck"; do diff --git a/tests/tests.rs b/tests/tests.rs index 2866800..eab43a4 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -44,6 +44,8 @@ mod test_grpck; mod test_multicall; #[path = "by-util/test_newgrp.rs"] mod test_newgrp; +#[path = "by-util/test_newusers.rs"] +mod test_newusers; #[path = "by-util/test_passwd.rs"] mod test_passwd; #[path = "by-util/test_pwck.rs"] From e154fe6f3f20ffe0d19dc9adb024e9dd63cacb9a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:36:36 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/arm64-smoke.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/arm64-smoke.sh b/tests/arm64-smoke.sh index 92c096c..b7920e1 100755 --- a/tests/arm64-smoke.sh +++ b/tests/arm64-smoke.sh @@ -131,7 +131,7 @@ printf 'batched:a long passphrase:2500:2500:Batched::/bin/sh\n' \ | "${QEMU[@]}" "$BIN" newusers -P "$T" >/dev/null 2>&1 contains "newusers created the account" "$T/etc/passwd" '^batched:x:2500:2500:' contains "with a SHA-512 hash" "$T/etc/shadow" '^batched:\$6\$' -contains "and a group of its own" "$T/etc/group" '^batched:x:2500:' +contains "and a group of its own" "$T/etc/group" '^batched:x:2500:' # pwck exits 2 for warnings, which a synthetic tree produces (no real shells), # so anything up to 2 means it read and checked the files rather than failing.