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..b7920e1 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"]