From 498fab624441787c8f95fb10d36b9aa7d4758257 Mon Sep 17 00:00:00 2001 From: Jocelyn Le Sage Date: Fri, 4 Sep 2026 16:32:46 +0000 Subject: [PATCH 1/2] gpasswd: added implementation of gpasswd --- Cargo.lock | 13 + Cargo.toml | 4 +- Makefile | 12 +- README.md | 9 +- docs/PLATFORM-SUPPORT.md | 2 +- docs/man/gpasswd.1.md | 107 ++++ src/bin/completions.rs | 4 + src/bin/shadow-rs.rs | 14 +- src/uu/gpasswd/Cargo.toml | 36 ++ src/uu/gpasswd/locales/en-US.ftl | 2 + src/uu/gpasswd/src/gpasswd.rs | 891 +++++++++++++++++++++++++++++++ src/uu/gpasswd/src/main.rs | 6 + tests/by-util/test_gpasswd.rs | 482 +++++++++++++++++ tests/e2e/deploy-test.sh | 13 +- tests/gnu-compat.sh | 3 + tests/tests.rs | 2 + 16 files changed, 1578 insertions(+), 22 deletions(-) create mode 100644 docs/man/gpasswd.1.md create mode 100644 src/uu/gpasswd/Cargo.toml create mode 100644 src/uu/gpasswd/locales/en-US.ftl create mode 100644 src/uu/gpasswd/src/gpasswd.rs create mode 100644 src/uu/gpasswd/src/main.rs create mode 100644 tests/by-util/test_gpasswd.rs diff --git a/Cargo.lock b/Cargo.lock index a1664d9..4668ad4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -738,6 +738,18 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_gpasswd" +version = "0.3.1" +dependencies = [ + "clap", + "rustix", + "shadow-core", + "tempfile", + "uucore", + "zeroize", +] + [[package]] name = "uu_groupadd" version = "0.3.1" @@ -827,6 +839,7 @@ dependencies = [ "uu_chfn", "uu_chpasswd", "uu_chsh", + "uu_gpasswd", "uu_groupadd", "uu_groupdel", "uu_groupmod", diff --git a/Cargo.toml b/Cargo.toml index eab8ce4..3bbc3b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ members = [ "src/uu/newgrp", "src/uu/chpasswd", "src/uu/chage", + "src/uu/gpasswd", ] [workspace.package] @@ -90,10 +91,11 @@ grpck = { optional = true, version = "0.3.1", package = "uu_grpck", path = "src/ chfn = { optional = true, version = "0.3.1", package = "uu_chfn", path = "src/uu/chfn" } chsh = { optional = true, version = "0.3.1", package = "uu_chsh", path = "src/uu/chsh" } newgrp = { optional = true, version = "0.3.1", package = "uu_newgrp", path = "src/uu/newgrp" } +gpasswd = { optional = true, version = "0.3.1", package = "uu_gpasswd", path = "src/uu/gpasswd" } [features] default = ["passwd", "pwck", "useradd", "userdel", "usermod", "chpasswd", "chage", - "groupadd", "groupdel", "groupmod", "grpck", "chfn", "chsh", "newgrp"] + "groupadd", "groupdel", "groupmod", "grpck", "chfn", "chsh", "newgrp", "gpasswd"] # 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 diff --git a/Makefile b/Makefile index 89a3abd..30d4e69 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,8 @@ BINDIR ?= $(PREFIX)/bin SBINDIR ?= $(PREFIX)/sbin # Tools that need setuid-root to allow non-root callers (change own password, -# GECOS, shell, effective group). -SETUID_TOOLS = passwd chfn chsh newgrp +# GECOS, shell, effective group, or administer a group as a group admin). +SETUID_TOOLS = passwd chfn chsh newgrp gpasswd # Root-only tools (no setuid; fail at getuid() check for non-root callers). ROOT_TOOLS = useradd userdel usermod chpasswd \ @@ -83,7 +83,7 @@ test: test-gnu-compat: bash tests/gnu-compat.sh -# Default install: 14 standalone per-tool binaries, with the setuid layout and +# Default install: 15 standalone per-tool binaries, with the setuid layout and # the bin/sbin split GNU shadow-utils uses. Only passwd/chfn/chsh/newgrp are # setuid. install: build @@ -100,9 +100,9 @@ install: build @echo " $(DESTDIR)$(SBINDIR)/ root (0755): $(ROOT_TOOLS)" # Opt-in install: single multicall binary with symlinks. Smaller footprint. -# The binary is installed setuid-root for passwd/chfn/chsh/newgrp; the other -# applets drop back to the caller's uid before running, so the privilege model -# matches the per-tool layout. Intended for container/embedded use. +# The binary is installed setuid-root for passwd/chfn/chsh/newgrp/gpasswd; the +# other applets drop back to the caller's uid before running, so the privilege +# model matches the per-tool layout. Intended for container/embedded use. install-multicall: build-multicall install -Dm4755 target/release/shadow-rs $(DESTDIR)$(SBINDIR)/shadow-rs @install -d $(DESTDIR)$(BINDIR) diff --git a/README.md b/README.md index 8f0bfa1..1c6783f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ default-in-Ubuntu in under 3 years. This project follows that playbook. | `chfn` | **Implemented.** GECOS sub-field modification. | | `chsh` | **Implemented.** Shell change with /etc/shells validation. | | `newgrp` | **Implemented.** Effective group change with crypt verification. | +| `gpasswd` | **Implemented.** Group membership, administrators, and group password. | ## Building @@ -87,9 +88,9 @@ docker compose run --rm debian cargo build --release ### Install -Default install: 14 standalone per-tool binaries with least-privilege setuid -layout matching GNU shadow-utils. Only `passwd`, `chfn`, `chsh`, `newgrp` are -installed setuid-root; the other 10 are plain `0755`. +Default install: 15 standalone per-tool binaries with least-privilege setuid +layout matching GNU shadow-utils. Only `passwd`, `chfn`, `chsh`, `newgrp`, +`gpasswd` are installed setuid-root; the other 10 are plain `0755`. ```shell sudo make install PREFIX=/usr/local @@ -140,7 +141,7 @@ 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 chage chpasswd groupadd groupdel \ +for tool in passwd chfn chsh newgrp gpasswd chage chpasswd groupadd groupdel \ groupmod grpck pwck useradd userdel usermod; do sudo ln -sf shadow-rs "/usr/local/bin/$tool" done diff --git a/docs/PLATFORM-SUPPORT.md b/docs/PLATFORM-SUPPORT.md index 7ff28fa..bd28d4c 100644 --- a/docs/PLATFORM-SUPPORT.md +++ b/docs/PLATFORM-SUPPORT.md @@ -93,7 +93,7 @@ tools, which are root-only anyway and reach `/etc/shadow` directly. ### 2. No NSS `shadow_core::process` resolves the calling user through `getpwuid_r`, used by -`passwd`, `chfn`, `chsh`, `chage` and `newgrp`. +`passwd`, `chfn`, `chsh`, `chage`, `newgrp` and `gpasswd`. glibc answers such lookups through its NSS module system, so it sees users from LDAP, SSSD, Active Directory or systemd-userdb. musl has no NSS module system diff --git a/docs/man/gpasswd.1.md b/docs/man/gpasswd.1.md new file mode 100644 index 0000000..619f350 --- /dev/null +++ b/docs/man/gpasswd.1.md @@ -0,0 +1,107 @@ +# gpasswd(1) - administer /etc/group and /etc/gshadow + +## NAME + +gpasswd - administer /etc/group and /etc/gshadow + +## SYNOPSIS + +**gpasswd** [*options*] *group* + +## DESCRIPTION + +The **gpasswd** command administers `/etc/group` and `/etc/gshadow`. +Every group can have administrators, members, and a password. + +System administrators can use the **-A** option to define group +administrator(s) and the **-M** option to define members. They have +all rights of group administrators and members. + +**gpasswd** called by a group administrator with a group name only +prompts for the new password of the *group*. + +If a password is set the members can still use **newgrp**(1) without a +password, and non-members must supply the password. + +This tool is installed setuid-root so that a group administrator (a +user named in the gshadow administrators field) can add and remove +members and change the group password without being root. **-A** and +**-M** remain root-only. + +### Notes about group passwords + +Group passwords are an inherent security problem since more than one +person is permitted to know the password. However, groups are a useful +tool for permitting co-operation between different users. + +## OPTIONS + +Except for the **-A** and **-M** options, the options cannot be combined. + +**-a**, **--add** *USER* +: Add *USER* to the named group. + +**-d**, **--delete** *USER* +: Remove *USER* from the named group. + +**-A**, **--administrators** *USER,...* +: Set the list of administrative users. Root only. Requires + `/etc/gshadow`. An empty list clears the administrators. + +**-M**, **--members** *USER,...* +: Set the list of group members. Root only. An empty list clears + the members. + +**-r**, **--remove-password** +: Remove the password from the named group. The group password + will be empty. Only group members will be allowed to use + **newgrp** to join the named group. + +**-R**, **--restrict** +: Restrict access to the named group. The group password is set + to "!". Only group members will be allowed to use **newgrp** to + join the named group. + +**-Q**, **--root** *CHROOT_DIR* +: Locate the system files under *CHROOT_DIR* instead of `/`. Only + absolute paths are supported. Root only. + +**-P**, **--prefix** *PREFIX_DIR* +: Use *PREFIX_DIR* as a prefix for system file paths. Root only. + +## EXIT STATUS + +**0** +: Success. + +**1** +: Permission denied. + +**2** +: Invalid command syntax. + +**3** +: Invalid argument to option, or specified group doesn't exist. + +**10** +: Can't update group file. + +**17** +: Shadow group file required for **-A**. + +## FILES + +/etc/group +: Group account information. + +/etc/gshadow +: Secure group account information. + +/etc/login.defs +: Shadow password suite configuration (`ENCRYPT_METHOD`, + `SHA_CRYPT_MIN_ROUNDS`, `SHA_CRYPT_MAX_ROUNDS`). + +## SEE ALSO + +newgrp(1), groupadd(8), groupdel(8), groupmod(8), grpck(8), group(5), +gshadow(5) diff --git a/src/bin/completions.rs b/src/bin/completions.rs index cacb254..47af911 100644 --- a/src/bin/completions.rs +++ b/src/bin/completions.rs @@ -37,6 +37,8 @@ fn get_tool_app(name: &str) -> Option { "groupdel" => Some(groupdel::uu_app()), #[cfg(feature = "groupmod")] "groupmod" => Some(groupmod::uu_app()), + #[cfg(feature = "gpasswd")] + "gpasswd" => Some(gpasswd::uu_app()), #[cfg(feature = "grpck")] "grpck" => Some(grpck::uu_app()), #[cfg(feature = "newgrp")] @@ -72,6 +74,8 @@ fn all_tool_names() -> Vec<&'static str> { names.push("groupdel"); #[cfg(feature = "groupmod")] names.push("groupmod"); + #[cfg(feature = "gpasswd")] + names.push("gpasswd"); #[cfg(feature = "grpck")] names.push("grpck"); #[cfg(feature = "newgrp")] diff --git a/src/bin/shadow-rs.rs b/src/bin/shadow-rs.rs index f7f0f35..e757c48 100644 --- a/src/bin/shadow-rs.rs +++ b/src/bin/shadow-rs.rs @@ -24,9 +24,9 @@ use std::process::ExitCode; type Applet = fn(&[OsString]) -> i32; -/// Applets that keep euid 0 for an unprivileged caller: the same four that +/// Applets that keep euid 0 for an unprivileged caller: the same five that /// `make install` marks setuid. -const SETUID_APPLETS: [&str; 4] = ["passwd", "chfn", "chsh", "newgrp"]; +const SETUID_APPLETS: [&str; 5] = ["passwd", "chfn", "chsh", "newgrp", "gpasswd"]; /// Every applet compiled into this binary, by name, in `--list` order. // `#[cfg]` is not accepted on the elements of a `vec![]` literal, so the @@ -49,6 +49,8 @@ fn applets() -> Vec<(&'static str, Applet)> { table.push(("groupadd", |a| groupadd::uumain(a.iter().cloned()))); #[cfg(feature = "groupdel")] table.push(("groupdel", |a| groupdel::uumain(a.iter().cloned()))); + #[cfg(feature = "gpasswd")] + table.push(("gpasswd", |a| gpasswd::uumain(a.iter().cloned()))); #[cfg(feature = "groupmod")] table.push(("groupmod", |a| groupmod::uumain(a.iter().cloned()))); #[cfg(feature = "grpck")] @@ -223,9 +225,9 @@ fn print_available_utils() { mod tests { use super::*; - const ALL_TOOLS: [&str; 14] = [ - "chage", "chfn", "chpasswd", "chsh", "groupadd", "groupdel", "groupmod", "grpck", "newgrp", - "passwd", "pwck", "useradd", "userdel", "usermod", + const ALL_TOOLS: [&str; 15] = [ + "chage", "chfn", "chpasswd", "chsh", "gpasswd", "groupadd", "groupdel", "groupmod", + "grpck", "newgrp", "passwd", "pwck", "useradd", "userdel", "usermod", ]; // The table drives both dispatch and `--list`, so it must contain only @@ -247,7 +249,7 @@ mod tests { for tool in ALL_TOOLS { assert_eq!( keeps_privilege(tool), - matches!(tool, "passwd" | "chfn" | "chsh" | "newgrp"), + matches!(tool, "passwd" | "chfn" | "chsh" | "newgrp" | "gpasswd"), "{tool}" ); } diff --git a/src/uu/gpasswd/Cargo.toml b/src/uu/gpasswd/Cargo.toml new file mode 100644 index 0000000..2066d03 --- /dev/null +++ b/src/uu/gpasswd/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "uu_gpasswd" +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 = "gpasswd ~ (shadow-rs) administer /etc/group and /etc/gshadow" + +[lib] +path = "src/gpasswd.rs" + +[[bin]] +name = "gpasswd" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +rustix = { workspace = true } +zeroize = { workspace = true } +shadow-core = { workspace = true, features = ["group", "gshadow", "login-defs", "crypt"] } +uucore = { 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/gpasswd/locales/en-US.ftl b/src/uu/gpasswd/locales/en-US.ftl new file mode 100644 index 0000000..46cd31b --- /dev/null +++ b/src/uu/gpasswd/locales/en-US.ftl @@ -0,0 +1,2 @@ +gpasswd-about = Administer group membership and the group password +gpasswd-usage = gpasswd [options] group diff --git a/src/uu/gpasswd/src/gpasswd.rs b/src/uu/gpasswd/src/gpasswd.rs new file mode 100644 index 0000000..a52acc4 --- /dev/null +++ b/src/uu/gpasswd/src/gpasswd.rs @@ -0,0 +1,891 @@ +// 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 gpasswd gshadow nscd sysroot yescrypt + +//! `gpasswd` — administer `/etc/group` and `/etc/gshadow`. +//! +//! Drop-in replacement for GNU shadow-utils `gpasswd(1)`. + +use std::fmt; +use std::io::{self, Write as _}; +use std::path::Path; + +use clap::{Arg, ArgAction, ArgGroup, Command}; +use uucore::error::{UError, UResult}; + +use shadow_core::atomic; +use shadow_core::audit; +use shadow_core::crypt; +use shadow_core::group::{self, GroupEntry}; +use shadow_core::gshadow::{self, GshadowEntry}; +use shadow_core::lock::FileLock; +use shadow_core::login_defs::LoginDefs; +use shadow_core::nscd; +use shadow_core::passwd; +use shadow_core::sysroot::SysRoot; + +mod options { + pub const GROUP: &str = "GROUP"; + pub const ADD: &str = "add"; + pub const DELETE: &str = "delete"; + pub const ADMINISTRATORS: &str = "administrators"; + pub const MEMBERS: &str = "members"; + pub const REMOVE_PASSWORD: &str = "remove-password"; + pub const RESTRICT: &str = "restrict"; + pub const ROOT: &str = "root"; + pub const PREFIX: &str = "prefix"; +} + +mod exit_codes { + pub const FAILURE: i32 = 1; + pub const BAD_SYNTAX: i32 = 2; + pub const BAD_ARGUMENT: i32 = 3; + pub const CANT_UPDATE: i32 = 10; + pub const GSHADOW_REQUIRED: i32 = 17; +} + +#[derive(Debug)] +enum GpasswdError { + Failure(String), + BadSyntax(String), + BadArgument(String), + CantUpdate(String), + GshadowRequired(String), +} + +impl fmt::Display for GpasswdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Failure(msg) + | Self::BadSyntax(msg) + | Self::BadArgument(msg) + | Self::CantUpdate(msg) + | Self::GshadowRequired(msg) => f.write_str(msg), + } + } +} + +impl std::error::Error for GpasswdError {} + +impl UError for GpasswdError { + fn code(&self) -> i32 { + match self { + Self::Failure(_) => exit_codes::FAILURE, + Self::BadSyntax(_) => exit_codes::BAD_SYNTAX, + Self::BadArgument(_) => exit_codes::BAD_ARGUMENT, + Self::CantUpdate(_) => exit_codes::CANT_UPDATE, + Self::GshadowRequired(_) => exit_codes::GSHADOW_REQUIRED, + } + } +} + +/// Parsed request. `-A` and `-M` may be combined; every other action is exclusive. +struct Request { + add_user: Option, + del_user: Option, + set_admins: Option>, + set_members: Option>, + remove_password: bool, + restrict: bool, + new_password_hash: Option, +} + +impl Request { + fn touches_group_file(&self) -> bool { + self.add_user.is_some() + || self.del_user.is_some() + || self.set_members.is_some() + || self.remove_password + || self.restrict + || self.new_password_hash.is_some() + } + + fn requires_system_admin(&self) -> bool { + self.set_admins.is_some() || self.set_members.is_some() + } +} + +fn permission_denied() -> UResult<()> { + uucore::show_error!("{}", shadow_core::os_error::permission_denied()); + Err(shadow_core::cli::AlreadyPrinted(1).into()) +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let _clean_env = shadow_core::hardening::harden_process(); + + let Some(matches) = shadow_core::cli::parse_args(uu_app(), args, |_| exit_codes::BAD_SYNTAX)? + else { + return Ok(()); + }; + + // Unlike groupadd/groupmod, gpasswd is setuid: a non-root group + // administrator may change membership and the group password. + do_gpasswd(&matches) +} + +/// Core logic, separated from argument parsing to keep `uumain` short. +#[allow(clippy::too_many_lines)] +fn do_gpasswd(matches: &clap::ArgMatches) -> UResult<()> { + let group_name = matches + .get_one::(options::GROUP) + .ok_or_else(|| GpasswdError::BadSyntax("group name required".into()))? + .clone(); + + let add_user = matches.get_one::(options::ADD).cloned(); + let del_user = matches.get_one::(options::DELETE).cloned(); + let set_admins = matches + .get_one::(options::ADMINISTRATORS) + .map(|s| parse_user_list(s)); + let set_members = matches + .get_one::(options::MEMBERS) + .map(|s| parse_user_list(s)); + let remove_password = matches.get_flag(options::REMOVE_PASSWORD); + let restrict = matches.get_flag(options::RESTRICT); + + let prefix = matches.get_one::(options::PREFIX).map(Path::new); + let root_dir = matches.get_one::(options::ROOT).map(Path::new); + + if let Some(dir) = root_dir + && !dir.is_absolute() + { + return Err(GpasswdError::BadArgument(format!( + "invalid chroot path '{}', only absolute paths are supported.", + dir.display() + )) + .into()); + } + + // Except for -A and -M, the options cannot be combined (gpasswd(1)). + let exclusive_count = u8::from(add_user.is_some()) + + u8::from(del_user.is_some()) + + u8::from(remove_password) + + u8::from(restrict) + + u8::from(set_admins.is_some() || set_members.is_some()); + if exclusive_count > 1 { + return Err(GpasswdError::BadSyntax("invalid combination of options".into()).into()); + } + + let is_root = shadow_core::hardening::caller_is_root(); + // Non-setuid callers cannot write the databases. Setuid non-root + // callers are group administrators and are checked after the files + // are read. + if !is_root && !rustix::process::geteuid().is_root() { + return permission_denied(); + } + + let mut req = Request { + add_user, + del_user, + set_admins, + set_members, + remove_password, + restrict, + new_password_hash: None, + }; + + // -A/-M and --root/--prefix are system-administrator operations. + if !is_root && (req.requires_system_admin() || prefix.is_some() || root_dir.is_some()) { + return permission_denied(); + } + + let root = SysRoot::new(prefix.or(root_dir)); + + // Hash before taking locks so a slow crypt(3) does not stall writers. + // Peek gshadow first so a non-admin is not prompted at all. + if exclusive_count == 0 { + if !is_root && !caller_is_named_admin(&root.gshadow_path(), &group_name)? { + return permission_denied(); + } + req.new_password_hash = Some(prompt_and_hash_password(&root, &group_name)?); + } + + let _signals = shadow_core::hardening::SignalBlocker::block_critical() + .map_err(|e| GpasswdError::CantUpdate(format!("cannot block signals: {e}")))?; + + let group_path = root.group_path(); + let gshadow_path = root.gshadow_path(); + let gshadow_exists = gshadow_path.exists(); + + if req.set_admins.is_some() && !gshadow_exists { + return Err( + GpasswdError::GshadowRequired("shadow group passwords required for -A".into()).into(), + ); + } + + let group_lock = FileLock::acquire(&group_path).map_err(|e| { + GpasswdError::CantUpdate(format!("cannot lock {}: {e}", group_path.display())) + })?; + + // Hold both locks before writing so a failed gshadow update cannot + // leave membership only half-applied. + let gs_lock = if gshadow_exists { + Some(FileLock::acquire(&gshadow_path).map_err(|e| { + GpasswdError::CantUpdate(format!("cannot lock {}: {e}", gshadow_path.display())) + })?) + } else { + None + }; + + let mut group_entries = group::read_group_file(&group_path).map_err(|e| { + GpasswdError::CantUpdate(format!("cannot read {}: {e}", group_path.display())) + })?; + + let idx = group_entries + .iter() + .position(|g| g.name == group_name) + .ok_or_else(|| { + GpasswdError::BadArgument(format!("group '{group_name}' does not exist in /etc/group")) + })?; + + let mut gs_entries = if gshadow_exists { + gshadow::read_gshadow_file(&gshadow_path).map_err(|e| { + GpasswdError::CantUpdate(format!("cannot read {}: {e}", gshadow_path.display())) + })? + } else { + Vec::new() + }; + + if !is_root { + let caller = current_caller_name()?; + let is_admin = gs_entries + .iter() + .find(|g| g.name == group_name) + .is_some_and(|g| is_group_admin(&caller, &g.admins)); + if !is_admin { + return permission_denied(); + } + } + + let passwd_path = root.passwd_path(); + let passwd_entries = if passwd_path.exists() { + passwd::read_passwd_file(&passwd_path).map_err(|e| { + GpasswdError::CantUpdate(format!("cannot read {}: {e}", passwd_path.display())) + })? + } else { + Vec::new() + }; + let user_exists = |name: &str| passwd_entries.iter().any(|p| p.name == name); + + if let Some(ref user) = req.add_user { + require_user_exists(user, user_exists)?; + } + if let Some(ref users) = req.set_members { + for user in users { + require_user_exists(user, user_exists)?; + } + } + if let Some(ref users) = req.set_admins { + for user in users { + require_user_exists(user, user_exists)?; + } + } + + // GNU gpasswd always prints the removing line for -d, then fails if the + // user is not already a member (including names absent from passwd). + if let Some(ref user) = req.del_user + && !group_entries[idx].members.iter().any(|m| m == user) + { + println!("Removing user {user} from group {group_name}"); + return Err(GpasswdError::BadArgument(format!( + "user '{user}' is not a member of '{group_name}'" + )) + .into()); + } + + let old_group_passwd = group_entries[idx].passwd.clone(); + apply_group_changes(&mut group_entries[idx], &req, gshadow_exists); + let modified_gid = group_entries[idx].gid; + let members_for_gshadow = group_entries[idx].members.clone(); + + let mut created_gshadow_line = false; + if gshadow_exists { + created_gshadow_line = !gs_entries.iter().any(|g| g.name == group_name); + let gs = ensure_gshadow_entry( + &mut gs_entries, + &group_name, + &members_for_gshadow, + &old_group_passwd, + ); + apply_gshadow_changes(gs, &req); + // Password now lives in gshadow, matching GNU when it creates the line. + if created_gshadow_line { + group_entries[idx].passwd = "x".to_string(); + } + } + + if req.touches_group_file() || created_gshadow_line { + atomic::atomic_write(&group_path, |f| group::write_group(&group_entries, f)).map_err( + |e| GpasswdError::CantUpdate(format!("cannot write {}: {e}", group_path.display())), + )?; + } + + if gshadow_exists { + atomic::atomic_write(&gshadow_path, |f| gshadow::write_gshadow(&gs_entries, f)).map_err( + |e| GpasswdError::CantUpdate(format!("cannot write {}: {e}", gshadow_path.display())), + )?; + } + + drop(gs_lock); + drop(group_lock); + + nscd::invalidate_cache("group"); + audit::log_user_event("MOD_GROUP", &group_name, modified_gid, true); + + if let Some(ref user) = req.add_user { + println!("Adding user {user} to group {group_name}"); + } + if let Some(ref user) = req.del_user { + println!("Removing user {user} from group {group_name}"); + } + + Ok(()) +} + +fn require_user_exists(user: &str, user_exists: impl Fn(&str) -> bool) -> Result<(), GpasswdError> { + if user_exists(user) { + Ok(()) + } else { + Err(GpasswdError::BadArgument(format!( + "user '{user}' does not exist" + ))) + } +} + +fn is_group_admin(username: &str, admins: &[String]) -> bool { + admins.iter().any(|a| a == username) +} + +fn current_caller_name() -> Result { + shadow_core::hardening::current_username() + .map_err(|e| GpasswdError::CantUpdate(format!("cannot determine caller: {e}"))) +} + +/// Best-effort admin check used before the password prompt so a non-admin +/// is not asked for a password. The locked path re-checks after re-read. +fn caller_is_named_admin(gshadow_path: &Path, group_name: &str) -> Result { + if !gshadow_path.exists() { + return Ok(false); + } + let caller = current_caller_name()?; + let entries = gshadow::read_gshadow_file(gshadow_path).map_err(|e| { + GpasswdError::CantUpdate(format!("cannot read {}: {e}", gshadow_path.display())) + })?; + Ok(entries + .iter() + .find(|g| g.name == group_name) + .is_some_and(|g| is_group_admin(&caller, &g.admins))) +} + +fn parse_user_list(s: &str) -> Vec { + if s.is_empty() { + return Vec::new(); + } + s.split(',') + .map(str::trim) + .filter(|u| !u.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn add_unique(list: &mut Vec, user: &str) { + if !list.iter().any(|m| m == user) { + list.push(user.to_string()); + } +} + +fn apply_group_changes(entry: &mut GroupEntry, req: &Request, has_gshadow: bool) { + if let Some(ref user) = req.add_user { + add_unique(&mut entry.members, user); + } + if let Some(ref user) = req.del_user { + entry.members.retain(|m| m != user); + } + if let Some(ref members) = req.set_members { + entry.members.clone_from(members); + } + if req.remove_password || req.restrict || req.new_password_hash.is_some() { + if has_gshadow { + entry.passwd = "x".to_string(); + } else if req.remove_password { + entry.passwd.clear(); + } else if req.restrict { + entry.passwd = "!".to_string(); + } else if let Some(ref hash) = req.new_password_hash { + entry.passwd.clone_from(hash); + } + } +} + +fn apply_gshadow_changes(entry: &mut GshadowEntry, req: &Request) { + if let Some(ref user) = req.add_user { + add_unique(&mut entry.members, user); + } + if let Some(ref user) = req.del_user { + entry.members.retain(|m| m != user); + } + if let Some(ref members) = req.set_members { + entry.members.clone_from(members); + } + if let Some(ref admins) = req.set_admins { + entry.admins.clone_from(admins); + } + if req.remove_password { + entry.passwd.clear(); + } + if req.restrict { + entry.passwd = "!".to_string(); + } + if let Some(ref hash) = req.new_password_hash { + entry.passwd.clone_from(hash); + } +} + +fn ensure_gshadow_entry<'a>( + entries: &'a mut Vec, + name: &str, + members: &[String], + passwd: &str, +) -> &'a mut GshadowEntry { + if let Some(i) = entries.iter().position(|g| g.name == name) { + return &mut entries[i]; + } + entries.push(GshadowEntry { + name: name.to_string(), + passwd: passwd.to_string(), + admins: Vec::new(), + members: members.to_vec(), + }); + let i = entries.len() - 1; + &mut entries[i] +} + +/// SHA crypt round count from login.defs, per gpasswd(1). +/// +/// Unspecified → libc default (`None`). A single bound is used as-is. +/// If both are set, the higher value is used (the man page's rule when +/// `MIN > MAX`, and the stronger of the two otherwise). +fn sha_crypt_rounds(defs: &LoginDefs) -> Option { + const ROUNDS_MIN: i64 = 1000; + const ROUNDS_MAX: i64 = 999_999_999; + let clamp = |n: i64| u32::try_from(n.clamp(ROUNDS_MIN, ROUNDS_MAX)).unwrap_or(5000); + match ( + defs.get_i64("SHA_CRYPT_MIN_ROUNDS"), + defs.get_i64("SHA_CRYPT_MAX_ROUNDS"), + ) { + (None, None) => None, + (Some(n), None) | (None, Some(n)) => Some(clamp(n)), + (Some(a), Some(b)) => Some(clamp(a.max(b))), + } +} + +fn crypt_method(defs: &LoginDefs) -> crypt::CryptMethod { + match defs.get("ENCRYPT_METHOD").unwrap_or("SHA512") { + "SHA256" => crypt::CryptMethod::Sha256, + "YESCRYPT" => crypt::CryptMethod::Yescrypt, + _ => crypt::CryptMethod::Sha512, + } +} + +fn prompt_and_hash_password(root: &SysRoot, group_name: &str) -> Result { + eprintln!("Changing the password for group {group_name}"); + let _ = io::stderr().flush(); + + let password = loop { + let pass1 = read_password("New Password: ")?; + let pass2 = read_password("Re-enter new password: ")?; + if *pass1 == *pass2 { + break pass1; + } + // GNU gpasswd retries instead of exiting on a mismatch. + eprintln!("They don't match; try again"); + let _ = io::stderr().flush(); + }; + + let defs = LoginDefs::load(&root.login_defs_path()) + .map_err(|e| GpasswdError::CantUpdate(format!("cannot read login.defs: {e}")))?; + let method = crypt_method(&defs); + let rounds = match method { + crypt::CryptMethod::Sha256 | crypt::CryptMethod::Sha512 => sha_crypt_rounds(&defs), + crypt::CryptMethod::Yescrypt => None, + }; + crypt::hash_password(&password, method, rounds) + .map_err(|e| GpasswdError::CantUpdate(format!("cannot hash password: {e}"))) +} + +/// RAII guard that restores terminal echo on drop (same pattern as newgrp). +struct EchoGuard { + tty: std::fs::File, + old_termios: rustix::termios::Termios, +} + +impl EchoGuard { + /// Disable echo on the given tty file. + fn disable(tty: std::fs::File) -> Result { + use std::os::unix::io::AsFd; + + let old_termios = rustix::termios::tcgetattr(tty.as_fd()).map_err(|e| { + GpasswdError::CantUpdate(format!("cannot get terminal attributes: {e}")) + })?; + + let mut new_termios = old_termios.clone(); + new_termios.local_modes &= !rustix::termios::LocalModes::ECHO; + rustix::termios::tcsetattr( + tty.as_fd(), + rustix::termios::OptionalActions::Now, + &new_termios, + ) + .map_err(|e| GpasswdError::CantUpdate(format!("cannot disable echo: {e}")))?; + + Ok(Self { tty, old_termios }) + } +} + +impl Drop for EchoGuard { + fn drop(&mut self) { + use std::os::unix::io::AsFd; + let _ = rustix::termios::tcsetattr( + self.tty.as_fd(), + rustix::termios::OptionalActions::Now, + &self.old_termios, + ); + } +} + +/// Read a password from `/dev/tty` with echo disabled. +/// +/// The returned password is wrapped in `Zeroizing` so it is scrubbed from +/// memory when dropped. +fn read_password(prompt: &str) -> Result, GpasswdError> { + use std::io::{BufRead, Write as _}; + + let tty = std::fs::File::options() + .read(true) + .write(true) + .open("/dev/tty") + .map_err(|_| GpasswdError::Failure("Not a tty".into()))?; + + if !rustix::termios::isatty(&tty) { + return Err(GpasswdError::Failure("Not a tty".into())); + } + + (&tty) + .write_all(prompt.as_bytes()) + .map_err(|e| GpasswdError::CantUpdate(format!("cannot write prompt: {e}")))?; + (&tty) + .flush() + .map_err(|e| GpasswdError::CantUpdate(format!("cannot flush prompt: {e}")))?; + + // Clone the tty handle: one for the guard (to restore echo), one for reading. + let tty_for_guard = tty + .try_clone() + .map_err(|e| GpasswdError::CantUpdate(format!("cannot clone tty handle: {e}")))?; + + let guard = EchoGuard::disable(tty_for_guard)?; + + let mut buf = zeroize::Zeroizing::new(String::new()); + let mut reader = std::io::BufReader::new(&tty); + reader + .read_line(&mut buf) + .map_err(|e| GpasswdError::CantUpdate(format!("cannot read password: {e}")))?; + + drop(guard); + let _ = (&tty).write_all(b"\n"); + + Ok(zeroize::Zeroizing::new( + buf.trim_end_matches(['\r', '\n']).to_string(), + )) +} + +#[must_use] +pub fn uu_app() -> Command { + Command::new("gpasswd") + .about("Administer group membership and the group password") + .override_usage("gpasswd [options] group") + .version(shadow_core::cli::VERSION) + .after_help(shadow_core::cli::AFTER_HELP) + .arg( + Arg::new(options::ADD) + .short('a') + .long("add") + .value_name("USER") + .help("Add USER to the named group"), + ) + .arg( + Arg::new(options::DELETE) + .short('d') + .long("delete") + .value_name("USER") + .help("Remove USER from the named group"), + ) + .arg( + Arg::new(options::ADMINISTRATORS) + .short('A') + .long("administrators") + .value_name("USER,...") + .help("Set the list of administrative users"), + ) + .arg( + Arg::new(options::MEMBERS) + .short('M') + .long("members") + .value_name("USER,...") + .help("Set the list of group members"), + ) + .arg( + Arg::new(options::REMOVE_PASSWORD) + .short('r') + .long("remove-password") + .help("Remove the password from the named group") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::RESTRICT) + .short('R') + .long("restrict") + .help("Restrict access to the named group (password set to !)") + .action(ArgAction::SetTrue), + ) + .arg( + // GNU gpasswd uses -Q for --root (-R is --restrict). + Arg::new(options::ROOT) + .short('Q') + .long("root") + .value_name("CHROOT_DIR") + .help("Locate the system files under CHROOT_DIR instead of /"), + ) + .arg( + Arg::new(options::PREFIX) + .short('P') + .long("prefix") + .value_name("PREFIX_DIR") + .help("Directory prefix"), + ) + .arg( + Arg::new(options::GROUP) + .required(true) + .index(1) + .help("Group to administer"), + ) + .group( + ArgGroup::new("exclusive") + .args([ + options::ADD, + options::DELETE, + options::REMOVE_PASSWORD, + options::RESTRICT, + ]) + .multiple(false), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_app_builds() { + uu_app().debug_assert(); + } + + #[test] + fn test_group_required() { + assert!(uu_app().try_get_matches_from(["gpasswd"]).is_err()); + } + + #[test] + fn test_add_flag() { + let m = uu_app() + .try_get_matches_from(["gpasswd", "-a", "alice", "devs"]) + .expect("valid args"); + assert_eq!( + m.get_one::(options::ADD).map(String::as_str), + Some("alice") + ); + assert_eq!( + m.get_one::(options::GROUP).map(String::as_str), + Some("devs") + ); + } + + #[test] + fn test_delete_flag() { + let m = uu_app() + .try_get_matches_from(["gpasswd", "-d", "bob", "devs"]) + .expect("valid args"); + assert_eq!( + m.get_one::(options::DELETE).map(String::as_str), + Some("bob") + ); + } + + #[test] + fn test_members_flag() { + let m = uu_app() + .try_get_matches_from(["gpasswd", "-M", "a,b", "devs"]) + .expect("valid args"); + assert_eq!( + m.get_one::(options::MEMBERS).map(String::as_str), + Some("a,b") + ); + } + + #[test] + fn test_admins_and_members_may_combine() { + let m = uu_app() + .try_get_matches_from(["gpasswd", "-A", "alice", "-M", "alice,bob", "devs"]) + .expect("-A and -M may be combined"); + assert_eq!( + m.get_one::(options::ADMINISTRATORS) + .map(String::as_str), + Some("alice") + ); + assert_eq!( + m.get_one::(options::MEMBERS).map(String::as_str), + Some("alice,bob") + ); + } + + #[test] + fn test_add_and_restrict_are_exclusive() { + assert!( + uu_app() + .try_get_matches_from(["gpasswd", "-a", "alice", "-R", "devs"]) + .is_err() + ); + } + + #[test] + fn test_root_short_flag_is_q() { + let m = uu_app() + .try_get_matches_from(["gpasswd", "-Q", "/chroot", "-r", "devs"]) + .expect("valid args"); + assert_eq!( + m.get_one::(options::ROOT).map(String::as_str), + Some("/chroot") + ); + assert!(m.get_flag(options::REMOVE_PASSWORD)); + } + + #[test] + fn test_parse_user_list() { + assert_eq!( + parse_user_list("a,b,c"), + vec!["a".to_string(), "b".to_string(), "c".to_string()] + ); + assert_eq!( + parse_user_list("a, b ,c"), + vec!["a".to_string(), "b".to_string(), "c".to_string()] + ); + assert!(parse_user_list("").is_empty()); + assert!(parse_user_list(",,").is_empty()); + } + + #[test] + fn test_is_group_admin() { + let admins = vec!["alice".to_string(), "bob".to_string()]; + assert!(is_group_admin("alice", &admins)); + assert!(!is_group_admin("carol", &admins)); + assert!(!is_group_admin("alice", &[])); + } + + #[test] + fn test_sha_crypt_rounds() { + let empty = LoginDefs::load(Path::new("/nonexistent/login.defs")).expect("missing is ok"); + assert_eq!(sha_crypt_rounds(&empty), None); + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("login.defs"); + std::fs::write( + &path, + "SHA_CRYPT_MIN_ROUNDS 2000\nSHA_CRYPT_MAX_ROUNDS 8000\n", + ) + .expect("write login.defs"); + let defs = LoginDefs::load(&path).expect("load"); + assert_eq!(sha_crypt_rounds(&defs), Some(8000)); + + std::fs::write(&path, "SHA_CRYPT_MIN_ROUNDS 4000\n").expect("write login.defs"); + let defs = LoginDefs::load(&path).expect("load"); + assert_eq!(sha_crypt_rounds(&defs), Some(4000)); + } + + #[test] + fn test_add_unique() { + let mut members = vec!["bob".to_string()]; + add_unique(&mut members, "alice"); + add_unique(&mut members, "alice"); + assert_eq!(members, vec!["bob".to_string(), "alice".to_string()]); + } + + fn skip_unless_root() -> bool { + !rustix::process::geteuid().is_root() + } + + #[test] + fn test_add_user_integration() { + if skip_unless_root() { + return; + } + + let dir = tempfile::tempdir().expect("tempdir"); + let etc = dir.path().join("etc"); + std::fs::create_dir_all(&etc).expect("etc"); + std::fs::write(etc.join("group"), "devs:x:1000:bob\n").expect("group"); + std::fs::write(etc.join("gshadow"), "devs:!::bob\n").expect("gshadow"); + std::fs::write( + etc.join("passwd"), + "bob:x:1000:1000::/home/bob:/bin/sh\nalice:x:1001:1001::/home/alice:/bin/sh\n", + ) + .expect("passwd"); + + let code = uumain( + vec![ + "gpasswd".into(), + "-a".into(), + "alice".into(), + "-P".into(), + dir.path().as_os_str().to_owned(), + "devs".into(), + ] + .into_iter(), + ); + assert_eq!(code, 0); + + let group = std::fs::read_to_string(etc.join("group")).expect("read group"); + assert!(group.contains("alice"), "{group}"); + let gshadow = std::fs::read_to_string(etc.join("gshadow")).expect("read gshadow"); + assert!(gshadow.contains("alice"), "{gshadow}"); + } + + #[test] + fn test_nonexistent_group_fails() { + if skip_unless_root() { + return; + } + + let dir = tempfile::tempdir().expect("tempdir"); + let etc = dir.path().join("etc"); + std::fs::create_dir_all(&etc).expect("etc"); + std::fs::write(etc.join("group"), "root:x:0:\n").expect("group"); + + let code = uumain( + vec![ + "gpasswd".into(), + "-a".into(), + "alice".into(), + "-P".into(), + dir.path().as_os_str().to_owned(), + "missing".into(), + ] + .into_iter(), + ); + assert_ne!(code, 0); + } +} diff --git a/src/uu/gpasswd/src/main.rs b/src/uu/gpasswd/src/main.rs new file mode 100644 index 0000000..0872ae2 --- /dev/null +++ b/src/uu/gpasswd/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_gpasswd); diff --git a/tests/by-util/test_gpasswd.rs b/tests/by-util/test_gpasswd.rs new file mode 100644 index 0000000..234aedd --- /dev/null +++ b/tests/by-util/test_gpasswd.rs @@ -0,0 +1,482 @@ +// 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 gpasswd gshadow + +//! Integration tests for the `gpasswd` utility. +//! +//! Tests that require root are guarded by `common::skip_unless_root()` and run +//! inside Docker CI containers. Non-root tests exercise clap parsing and error +//! paths that do not need privilege. + +use std::ffi::OsString; + +#[path = "../common/mod.rs"] +mod common; + +/// Run `uumain` with the given args, returning the exit code. +fn run(args: &[&str]) -> i32 { + let os_args: Vec = args.iter().map(|s| (*s).into()).collect(); + gpasswd::uumain(os_args.into_iter()) +} + +/// Helper to create a temp dir with `etc/group`, `etc/gshadow`, and `etc/passwd`. +fn setup_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("failed to create etc dir"); + + std::fs::write(etc.join("group"), "root:x:0:\ndevs:x:1000:bob\n") + .expect("failed to write group file"); + std::fs::write(etc.join("gshadow"), "root:!::\ndevs:!::bob\n") + .expect("failed to write gshadow file"); + std::fs::write( + etc.join("passwd"), + "root:x:0:0:root:/root:/bin/bash\n\ +bob:x:1000:1000::/home/bob:/bin/bash\n\ +alice:x:1001:1001::/home/alice:/bin/bash\n", + ) + .expect("failed to write passwd file"); + + dir +} + +fn setup_prefix_without_gshadow() -> tempfile::TempDir { + let dir = setup_prefix(); + std::fs::remove_file(dir.path().join("etc/gshadow")).expect("remove gshadow"); + dir +} + +/// Run `uumain` with a `--prefix` dir prepended to the args. +fn run_with_prefix(dir: &tempfile::TempDir, extra_args: &[&str]) -> i32 { + let prefix_str = dir.path().to_str().expect("non-UTF-8 temp path"); + let mut args = vec!["gpasswd", "-P", prefix_str]; + args.extend_from_slice(extra_args); + run(&args) +} + +fn read_group(dir: &tempfile::TempDir) -> String { + std::fs::read_to_string(dir.path().join("etc/group")).expect("failed to read group file") +} + +fn read_gshadow(dir: &tempfile::TempDir) -> String { + std::fs::read_to_string(dir.path().join("etc/gshadow")).expect("failed to read gshadow file") +} + +fn named_line(content: &str, name: &str) -> String { + let prefix = format!("{name}:"); + content + .lines() + .find(|l| l.starts_with(&prefix)) + .unwrap_or_else(|| panic!("missing {name} entry in:\n{content}")) + .to_owned() +} + +fn colon_fields(line: &str) -> Vec<&str> { + line.split(':').collect() +} + +// --------------------------------------------------------------------------- +// Non-root tests -- clap parsing and error paths +// --------------------------------------------------------------------------- + +#[test] +fn test_help_exits_zero() { + let code = run(&["gpasswd", "--help"]); + assert_eq!(code, 0, "--help should exit 0"); +} + +#[test] +fn test_missing_group_exits_error() { + let code = run(&["gpasswd"]); + assert_eq!(code, 2, "missing GROUP should exit 2"); +} + +#[test] +fn test_unknown_flag_exits_error() { + let code = run(&["gpasswd", "--bogus", "devs"]); + assert_eq!(code, 2, "unknown flag should exit 2"); +} + +#[test] +fn test_relative_root_exits_error() { + let code = run(&["gpasswd", "-Q", "tmp", "-r", "devs"]); + assert_eq!(code, 3, "relative --root should exit 3"); +} + +#[test] +fn test_add_and_restrict_exits_error() { + let code = run(&["gpasswd", "-a", "alice", "-R", "devs"]); + assert_eq!(code, 2, "exclusive options should exit 2"); +} + +#[test] +fn test_add_and_admins_combination_fails() { + let code = run(&["gpasswd", "-a", "alice", "-A", "alice", "devs"]); + assert_eq!(code, 2, "-a and -A cannot be combined"); +} + +// --------------------------------------------------------------------------- +// Root-only tests -- real operations via --prefix +// --------------------------------------------------------------------------- + +#[test] +fn test_add_user() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + let code = run_with_prefix(&dir, &["-a", "alice", "devs"]); + assert_eq!(code, 0, "gpasswd -a should exit 0"); + + let group = named_line(&read_group(&dir), "devs"); + let members = colon_fields(&group)[3]; + assert!( + members.split(',').any(|m| m == "alice"), + "alice should appear in /etc/group, got: {group}" + ); + assert!( + members.split(',').any(|m| m == "bob"), + "bob should remain in /etc/group, got: {group}" + ); + + let gshadow = named_line(&read_gshadow(&dir), "devs"); + let gs_members = colon_fields(&gshadow)[3]; + assert!( + gs_members.split(',').any(|m| m == "alice"), + "alice should appear in /etc/gshadow, got: {gshadow}" + ); +} + +#[test] +fn test_add_user_idempotent() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + assert_eq!(run_with_prefix(&dir, &["-a", "alice", "devs"]), 0); + assert_eq!( + run_with_prefix(&dir, &["-a", "alice", "devs"]), + 0, + "adding an existing member should still exit 0" + ); + + let group = named_line(&read_group(&dir), "devs"); + let count = colon_fields(&group)[3] + .split(',') + .filter(|m| *m == "alice") + .count(); + assert_eq!(count, 1, "alice should appear only once, got: {group}"); +} + +#[test] +fn test_delete_non_member_fails() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + let code = run_with_prefix(&dir, &["-d", "alice", "devs"]); + assert_eq!(code, 3, "deleting a non-member should exit 3"); + let group = named_line(&read_group(&dir), "devs"); + assert!( + colon_fields(&group)[3].split(',').any(|m| m == "bob"), + "bob should remain, got: {group}" + ); +} + +#[test] +fn test_delete_user() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + let code = run_with_prefix(&dir, &["-d", "bob", "devs"]); + assert_eq!(code, 0, "gpasswd -d should exit 0"); + + let group = named_line(&read_group(&dir), "devs"); + let members = colon_fields(&group)[3]; + assert!( + !members.split(',').any(|m| m == "bob"), + "bob should be removed from /etc/group, got: {group}" + ); +} + +#[test] +fn test_delete_does_not_remove_admin() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + std::fs::write( + dir.path().join("etc/gshadow"), + "root:!::\ndevs:!:alice:alice,bob\n", + ) + .expect("write gshadow"); + std::fs::write( + dir.path().join("etc/group"), + "root:x:0:\ndevs:x:1000:alice,bob\n", + ) + .expect("write group"); + + assert_eq!(run_with_prefix(&dir, &["-d", "alice", "devs"]), 0); + + let gshadow = named_line(&read_gshadow(&dir), "devs"); + let fields = colon_fields(&gshadow); + assert!( + fields[2].split(',').any(|a| a == "alice"), + "alice should remain an admin, got: {gshadow}" + ); + assert!( + !fields[3].split(',').any(|m| m == "alice"), + "alice should be removed from members, got: {gshadow}" + ); +} + +#[test] +fn test_set_members() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + let code = run_with_prefix(&dir, &["-M", "alice,bob", "devs"]); + assert_eq!(code, 0, "gpasswd -M should exit 0"); + + let group = named_line(&read_group(&dir), "devs"); + let members = colon_fields(&group)[3]; + assert!( + members.split(',').any(|m| m == "alice") && members.split(',').any(|m| m == "bob"), + "both members should be present, got: {group}" + ); + + let gshadow = named_line(&read_gshadow(&dir), "devs"); + let gs_members = colon_fields(&gshadow)[3]; + assert!( + gs_members.split(',').any(|m| m == "alice") && gs_members.split(',').any(|m| m == "bob"), + "both members should be in gshadow, got: {gshadow}" + ); +} + +#[test] +fn test_clear_members() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + let code = run_with_prefix(&dir, &["-M", "", "devs"]); + assert_eq!(code, 0, "gpasswd -M '' should exit 0"); + + let group = named_line(&read_group(&dir), "devs"); + assert_eq!( + colon_fields(&group)[3], + "", + "members should be empty, got: {group}" + ); +} + +#[test] +fn test_set_administrators() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + let code = run_with_prefix(&dir, &["-A", "alice", "devs"]); + assert_eq!(code, 0, "gpasswd -A should exit 0"); + + let gshadow = named_line(&read_gshadow(&dir), "devs"); + let fields = colon_fields(&gshadow); + assert!( + fields.len() >= 3 && fields[2].split(',').any(|a| a == "alice"), + "alice should be an admin, got: {gshadow}" + ); + + let group = named_line(&read_group(&dir), "devs"); + assert_eq!( + colon_fields(&group)[3], + "bob", + "-A must not change group members, got: {group}" + ); +} + +#[test] +fn test_set_admins_and_members_together() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + let code = run_with_prefix(&dir, &["-A", "alice", "-M", "alice,bob", "devs"]); + assert_eq!(code, 0, "gpasswd -A -M should exit 0"); + + let gshadow = named_line(&read_gshadow(&dir), "devs"); + let fields = colon_fields(&gshadow); + assert!( + fields[2].split(',').any(|a| a == "alice"), + "alice should be an admin, got: {gshadow}" + ); + assert!( + fields[3].split(',').any(|m| m == "alice") && fields[3].split(',').any(|m| m == "bob"), + "both members should be set, got: {gshadow}" + ); +} + +#[test] +fn test_administrators_without_gshadow_fails() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix_without_gshadow(); + let code = run_with_prefix(&dir, &["-A", "alice", "devs"]); + assert_eq!(code, 17, "-A without gshadow should exit 17"); +} + +#[test] +fn test_remove_password() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + std::fs::write( + dir.path().join("etc/gshadow"), + "root:!::\ndevs:$6$salt$hash::bob\n", + ) + .expect("write gshadow"); + + let code = run_with_prefix(&dir, &["-r", "devs"]); + assert_eq!(code, 0, "gpasswd -r should exit 0"); + + let gshadow = named_line(&read_gshadow(&dir), "devs"); + let fields = colon_fields(&gshadow); + assert_eq!( + fields[1], "", + "password field should be empty, got: {gshadow}" + ); +} + +#[test] +fn test_restrict() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + let code = run_with_prefix(&dir, &["-R", "devs"]); + assert_eq!(code, 0, "gpasswd -R should exit 0"); + + let gshadow = named_line(&read_gshadow(&dir), "devs"); + let fields = colon_fields(&gshadow); + assert_eq!(fields[1], "!", "password should be !, got: {gshadow}"); +} + +#[test] +fn test_restrict_without_gshadow() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix_without_gshadow(); + let code = run_with_prefix(&dir, &["-R", "devs"]); + assert_eq!(code, 0, "gpasswd -R without gshadow should exit 0"); + + let group = named_line(&read_group(&dir), "devs"); + assert_eq!( + colon_fields(&group)[1], + "!", + "group password should be !, got: {group}" + ); +} + +#[test] +fn test_remove_password_without_gshadow() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix_without_gshadow(); + std::fs::write( + dir.path().join("etc/group"), + "root:x:0:\ndevs:hash:1000:bob\n", + ) + .expect("write group"); + + let code = run_with_prefix(&dir, &["-r", "devs"]); + assert_eq!(code, 0, "gpasswd -r without gshadow should exit 0"); + + let group = named_line(&read_group(&dir), "devs"); + assert_eq!( + colon_fields(&group)[1], + "", + "group password should be empty, got: {group}" + ); +} + +#[test] +fn test_creates_gshadow_entry() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + std::fs::write(dir.path().join("etc/gshadow"), "root:!::\n").expect("write gshadow"); + + assert_eq!(run_with_prefix(&dir, &["-a", "alice", "devs"]), 0); + + let gshadow = named_line(&read_gshadow(&dir), "devs"); + let fields = colon_fields(&gshadow); + assert_eq!( + fields[1], "x", + "new gshadow password should copy /etc/group, got: {gshadow}" + ); + assert!( + fields[3].split(',').any(|m| m == "alice"), + "created gshadow line should list alice, got: {gshadow}" + ); +} + +#[test] +fn test_nonexistent_group_fails() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + let code = run_with_prefix(&dir, &["-a", "alice", "missing"]); + assert_eq!(code, 3, "missing group should exit 3"); +} + +#[test] +fn test_nonexistent_user_fails() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + let code = run_with_prefix(&dir, &["-a", "nobody", "devs"]); + assert_eq!(code, 3, "missing user should exit 3 (BAD_ARGUMENT)"); +} + +#[test] +fn test_preserves_other_entries() { + if common::skip_unless_root() { + return; + } + + let dir = setup_prefix(); + assert_eq!(run_with_prefix(&dir, &["-a", "alice", "devs"]), 0); + + let group = read_group(&dir); + assert!( + group.contains("root:x:0:"), + "root entry should be preserved, got: {group}" + ); +} diff --git a/tests/e2e/deploy-test.sh b/tests/e2e/deploy-test.sh index 5b4bfc8..0cabc92 100755 --- a/tests/e2e/deploy-test.sh +++ b/tests/e2e/deploy-test.sh @@ -100,13 +100,13 @@ hash_password() { # ── TOOLS list ────────────────────────────────────────────────────── -TOOLS="passwd pwck useradd userdel usermod chpasswd chage groupadd groupdel groupmod grpck chfn chsh newgrp" -SETUID_TOOLS="passwd chfn chsh newgrp" +TOOLS="passwd pwck useradd userdel usermod chpasswd chage groupadd groupdel groupmod gpasswd grpck chfn chsh newgrp" +SETUID_TOOLS="passwd chfn chsh newgrp gpasswd" # The tools an unprivileged user runs are installed in bin, the rest in sbin, # which is the split the GNU package uses: sbin is not on a normal user's # PATH, so `passwd` there would be "command not found". -USER_TOOLS="passwd chfn chsh newgrp chage" +USER_TOOLS="$SETUID_TOOLS chage" BINDIR="/usr/sbin" USER_BINDIR="/usr/bin" @@ -317,7 +317,7 @@ test_user_lifecycle() { # ── Group lifecycle ───────────────────────────────────────────────── test_group_lifecycle() { - section "Group lifecycle (groupadd → groupmod → groupdel → grpck)" + section "Group lifecycle (groupadd → gpasswd → groupmod → groupdel → grpck)" # Clean up from any previous failed run groupdel lifecycle_testgrp 2>/dev/null || true @@ -329,6 +329,11 @@ test_group_lifecycle() { assert_file_contains "group in /etc/group" \ /etc/group "^lifecycle_testgrp:" + assert_ok "gpasswd -a root lifecycle_testgrp" \ + gpasswd -a root lifecycle_testgrp + assert_file_contains "gpasswd added root to group" \ + /etc/group "^lifecycle_testgrp:.*root" + # Modify group name assert_ok "groupmod -n lifecycle_renamed lifecycle_testgrp" \ groupmod -n lifecycle_renamed lifecycle_testgrp diff --git a/tests/gnu-compat.sh b/tests/gnu-compat.sh index 2625dea..d3321d1 100755 --- a/tests/gnu-compat.sh +++ b/tests/gnu-compat.sh @@ -216,6 +216,9 @@ done # ── Results ───────────────────────────────────────────────────────── +echo "=== gpasswd ===" +compare_exit "gpasswd --help" "$RS/gpasswd --help" "/usr/bin/gpasswd --help" + echo "" echo "=== Results ===" printf " ${GREEN}PASS: %d${NC}\n" "$PASS" diff --git a/tests/tests.rs b/tests/tests.rs index 7ba0a7e..48937fe 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -23,6 +23,8 @@ mod test_chpasswd; mod test_chsh; #[path = "by-util/test_fuzz_corpus.rs"] mod test_fuzz_corpus; +#[path = "by-util/test_gpasswd.rs"] +mod test_gpasswd; #[path = "by-util/test_groupadd.rs"] mod test_groupadd; #[path = "by-util/test_groupdel.rs"] From c26e6d1dd7131e23db784334f097ba1efe62be20 Mon Sep 17 00:00:00 2001 From: Pierre Warnier Date: Sat, 5 Sep 2026 14:04:06 +0200 Subject: [PATCH 2/2] gpasswd: rebase onto main and port to the current APIs Rebases jlesage's implementation onto a main that moved a long way under it, and carries it onto the interfaces that landed since the branch was cut. The tool's behaviour is unchanged and still matches GNU on every path probed. What the rebase had to resolve: - shadow-rs.spec and debian/ were revived by the merge; both were deleted because neither could build. - The standalone dispatch() and hand-written --list predate the single applets() table, which now drives both. gpasswd joins the table. - The five shadow-core parser features it asked for no longer exist. - The fourteen [[test]] targets are one; test_gpasswd joins it. - README, Makefile and PLATFORM-SUPPORT described the pre-#239 privilege model, in which every applet ran with euid 0. What changed beyond the rebase: - gpasswd keeps its privilege in the multicall build. It is setuid in GNU and the branch marks it setuid for the per-tool install, but SETUID_APPLETS still listed four names, so the multicall build would have dropped privilege and no group administrator could have used it -- silently. - Comments in /etc/group survive. It read entries only and wrote them back, which erases every comment, blank line and NIS compatibility line in the file. The locked-file transaction keeps them, and commits group and gshadow together so a bad value cannot leave the pair disagreeing. - The duplicated EchoGuard and password reader are gone, replaced by shadow_core::tty, which blocks interrupts for the read so Ctrl-C at the prompt cannot leave the terminal with echo off. - The print macros are replaced. They panic when the stream is closed, which a setuid-root tool must not do part way through a change. These were the only five in the repository and no lint caught them, so clippy::print_stdout and print_stderr are now denied workspace-wide, with the test crate exempt. The GNU comparison suite gains gpasswd: the unknown-group, unknown-user and non-member exit codes, all three of which match. CONTRIBUTING's licence list also said ISC, BSD-2-Clause, CC0-1.0, Zlib and MPL-2.0 were acceptable, which stopped being true when deny.toml was narrowed to the graph. It now says what deny.toml enforces. --- CONTRIBUTING.md | 9 +- Cargo.toml | 8 ++ src/bin/shadow-rs.rs | 4 +- src/uu/gpasswd/Cargo.toml | 2 +- src/uu/gpasswd/src/gpasswd.rs | 217 +++++++++----------------------- tests/by-util/test_gpasswd.rs | 41 +++--- tests/by-util/test_multicall.rs | 6 +- tests/gnu-compat.sh | 21 +++- tests/tests.rs | 5 + 9 files changed, 123 insertions(+), 190 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0eb1e69..b04bcfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -164,7 +164,10 @@ development tool. shadow-rs is distributed under the terms of the [MIT License](LICENSE). -Acceptable dependency licenses: MIT, Apache-2.0, ISC, BSD-2-Clause, -BSD-3-Clause, CC0-1.0, Unicode-3.0, Zlib, MPL-2.0. +Acceptable dependency licences are exactly the ones the dependency graph uses +today: **MIT, Apache-2.0, BSD-3-Clause, Unicode-3.0**. The list is deliberately +no wider than the graph, so a crate arriving under a new licence is a decision +someone records in `deny.toml` rather than something that passes silently. -**No GPL or LGPL dependencies, ever.** +**No GPL or LGPL dependencies, ever**, and no MPL-2.0 either: it is file-level +copyleft and this project ships MIT only. diff --git a/Cargo.toml b/Cargo.toml index 3bbc3b4..2f7b69f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -173,5 +173,13 @@ must_use_candidate = { level = "allow", priority = 1 } module_name_repetitions = { level = "allow", priority = 1 } cargo_common_metadata = { level = "allow", priority = 1 } +# The print macros panic when the stream is closed, which a setuid-root tool +# must not do part way through changing an account file. Every tool uses +# `writeln!` or `uucore::show_error!` instead; this makes the rule a build +# failure rather than something a reviewer has to remember. Tests are exempt: +# a panic there is a failed test, which is the point. +print_stdout = { level = "deny", priority = 1 } +print_stderr = { level = "deny", priority = 1 } + [lints] workspace = true diff --git a/src/bin/shadow-rs.rs b/src/bin/shadow-rs.rs index e757c48..1c8bed5 100644 --- a/src/bin/shadow-rs.rs +++ b/src/bin/shadow-rs.rs @@ -45,12 +45,12 @@ fn applets() -> Vec<(&'static str, Applet)> { table.push(("chpasswd", |a| chpasswd::uumain(a.iter().cloned()))); #[cfg(feature = "chsh")] table.push(("chsh", |a| chsh::uumain(a.iter().cloned()))); + #[cfg(feature = "gpasswd")] + table.push(("gpasswd", |a| gpasswd::uumain(a.iter().cloned()))); #[cfg(feature = "groupadd")] table.push(("groupadd", |a| groupadd::uumain(a.iter().cloned()))); #[cfg(feature = "groupdel")] table.push(("groupdel", |a| groupdel::uumain(a.iter().cloned()))); - #[cfg(feature = "gpasswd")] - table.push(("gpasswd", |a| gpasswd::uumain(a.iter().cloned()))); #[cfg(feature = "groupmod")] table.push(("groupmod", |a| groupmod::uumain(a.iter().cloned()))); #[cfg(feature = "grpck")] diff --git a/src/uu/gpasswd/Cargo.toml b/src/uu/gpasswd/Cargo.toml index 2066d03..d82814c 100644 --- a/src/uu/gpasswd/Cargo.toml +++ b/src/uu/gpasswd/Cargo.toml @@ -21,7 +21,7 @@ path = "src/main.rs" clap = { workspace = true } rustix = { workspace = true } zeroize = { workspace = true } -shadow-core = { workspace = true, features = ["group", "gshadow", "login-defs", "crypt"] } +shadow-core = { workspace = true, features = ["crypt"] } uucore = { workspace = true } [dev-dependencies] diff --git a/src/uu/gpasswd/src/gpasswd.rs b/src/uu/gpasswd/src/gpasswd.rs index a52acc4..da9a8f2 100644 --- a/src/uu/gpasswd/src/gpasswd.rs +++ b/src/uu/gpasswd/src/gpasswd.rs @@ -15,16 +15,15 @@ use std::path::Path; use clap::{Arg, ArgAction, ArgGroup, Command}; use uucore::error::{UError, UResult}; -use shadow_core::atomic; use shadow_core::audit; use shadow_core::crypt; -use shadow_core::group::{self, GroupEntry}; +use shadow_core::group::GroupEntry; use shadow_core::gshadow::{self, GshadowEntry}; -use shadow_core::lock::FileLock; use shadow_core::login_defs::LoginDefs; use shadow_core::nscd; use shadow_core::passwd; use shadow_core::sysroot::SysRoot; +use shadow_core::transaction::{self, Commit, LockedFile}; mod options { pub const GROUP: &str = "GROUP"; @@ -93,15 +92,6 @@ struct Request { } impl Request { - fn touches_group_file(&self) -> bool { - self.add_user.is_some() - || self.del_user.is_some() - || self.set_members.is_some() - || self.remove_password - || self.restrict - || self.new_password_hash.is_some() - } - fn requires_system_admin(&self) -> bool { self.set_admins.is_some() || self.set_members.is_some() } @@ -118,7 +108,7 @@ fn permission_denied() -> UResult<()> { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let _clean_env = shadow_core::hardening::harden_process(); + shadow_core::hardening::harden_process(); let Some(matches) = shadow_core::cli::parse_args(uu_app(), args, |_| exit_codes::BAD_SYNTAX)? else { @@ -206,9 +196,6 @@ fn do_gpasswd(matches: &clap::ArgMatches) -> UResult<()> { req.new_password_hash = Some(prompt_and_hash_password(&root, &group_name)?); } - let _signals = shadow_core::hardening::SignalBlocker::block_critical() - .map_err(|e| GpasswdError::CantUpdate(format!("cannot block signals: {e}")))?; - let group_path = root.group_path(); let gshadow_path = root.gshadow_path(); let gshadow_exists = gshadow_path.exists(); @@ -219,24 +206,26 @@ fn do_gpasswd(matches: &clap::ArgMatches) -> UResult<()> { ); } - let group_lock = FileLock::acquire(&group_path).map_err(|e| { - GpasswdError::CantUpdate(format!("cannot lock {}: {e}", group_path.display())) + // Each transaction locks, then reads, and blocks signals for its lifetime. + // Both are opened before either is written, so a failed gshadow update + // cannot leave membership only half applied, and the layout keeps the + // comments and NIS compatibility lines the files carry -- reading entries + // only and writing them back would erase every comment in /etc/group. + let mut group_file = LockedFile::::open(&group_path).map_err(|e| { + GpasswdError::CantUpdate(format!("cannot open {}: {e}", group_path.display())) })?; - // Hold both locks before writing so a failed gshadow update cannot - // leave membership only half-applied. - let gs_lock = if gshadow_exists { - Some(FileLock::acquire(&gshadow_path).map_err(|e| { - GpasswdError::CantUpdate(format!("cannot lock {}: {e}", gshadow_path.display())) - })?) + let mut gshadow_file = if gshadow_exists { + Some( + LockedFile::::open(&gshadow_path).map_err(|e| { + GpasswdError::CantUpdate(format!("cannot open {}: {e}", gshadow_path.display())) + })?, + ) } else { None }; - let mut group_entries = group::read_group_file(&group_path).map_err(|e| { - GpasswdError::CantUpdate(format!("cannot read {}: {e}", group_path.display())) - })?; - + let group_entries = group_file.entries_mut(); let idx = group_entries .iter() .position(|g| g.name == group_name) @@ -244,19 +233,11 @@ fn do_gpasswd(matches: &clap::ArgMatches) -> UResult<()> { GpasswdError::BadArgument(format!("group '{group_name}' does not exist in /etc/group")) })?; - let mut gs_entries = if gshadow_exists { - gshadow::read_gshadow_file(&gshadow_path).map_err(|e| { - GpasswdError::CantUpdate(format!("cannot read {}: {e}", gshadow_path.display())) - })? - } else { - Vec::new() - }; - if !is_root { let caller = current_caller_name()?; - let is_admin = gs_entries - .iter() - .find(|g| g.name == group_name) + let is_admin = gshadow_file + .as_ref() + .and_then(|f| f.find(&group_name)) .is_some_and(|g| is_group_admin(&caller, &g.admins)); if !is_admin { return permission_denied(); @@ -292,57 +273,56 @@ fn do_gpasswd(matches: &clap::ArgMatches) -> UResult<()> { if let Some(ref user) = req.del_user && !group_entries[idx].members.iter().any(|m| m == user) { - println!("Removing user {user} from group {group_name}"); + let _ = writeln!(io::stdout(), "Removing user {user} from group {group_name}"); return Err(GpasswdError::BadArgument(format!( "user '{user}' is not a member of '{group_name}'" )) .into()); } - let old_group_passwd = group_entries[idx].passwd.clone(); - apply_group_changes(&mut group_entries[idx], &req, gshadow_exists); - let modified_gid = group_entries[idx].gid; - let members_for_gshadow = group_entries[idx].members.clone(); + let entries = group_file.entries_mut(); + let old_group_passwd = entries[idx].passwd.clone(); + apply_group_changes(&mut entries[idx], &req, gshadow_exists); + let modified_gid = entries[idx].gid; + let members_for_gshadow = entries[idx].members.clone(); - let mut created_gshadow_line = false; - if gshadow_exists { - created_gshadow_line = !gs_entries.iter().any(|g| g.name == group_name); + if let Some(gshadow_file) = gshadow_file.as_mut() { + let created = gshadow_file.find(&group_name).is_none(); let gs = ensure_gshadow_entry( - &mut gs_entries, + gshadow_file.entries_mut(), &group_name, &members_for_gshadow, &old_group_passwd, ); apply_gshadow_changes(gs, &req); - // Password now lives in gshadow, matching GNU when it creates the line. - if created_gshadow_line { - group_entries[idx].passwd = "x".to_string(); + // The password now lives in gshadow, matching GNU when it creates the + // line. + if created { + group_file.entries_mut()[idx].passwd = "x".to_string(); } } - if req.touches_group_file() || created_gshadow_line { - atomic::atomic_write(&group_path, |f| group::write_group(&group_entries, f)).map_err( - |e| GpasswdError::CantUpdate(format!("cannot write {}: {e}", group_path.display())), - )?; - } - - if gshadow_exists { - atomic::atomic_write(&gshadow_path, |f| gshadow::write_gshadow(&gs_entries, f)).map_err( - |e| GpasswdError::CantUpdate(format!("cannot write {}: {e}", gshadow_path.display())), - )?; + // Both files are validated before either is written, so a value that + // would corrupt one cannot leave the pair disagreeing. A commit that + // would write the same bytes writes nothing, which is why there is no + // longer a "did anything change" guard here. + let mut files: Vec> = vec![Box::new(group_file)]; + if let Some(gshadow_file) = gshadow_file { + files.push(Box::new(gshadow_file)); } - - drop(gs_lock); - drop(group_lock); + transaction::commit_all(files) + .map_err(|e| GpasswdError::CantUpdate(format!("cannot write: {e}")))?; nscd::invalidate_cache("group"); audit::log_user_event("MOD_GROUP", &group_name, modified_gid, true); + // GNU prints these on stdout, so a script capturing it keeps working. + let mut out = io::stdout().lock(); if let Some(ref user) = req.add_user { - println!("Adding user {user} to group {group_name}"); + let _ = writeln!(out, "Adding user {user} to group {group_name}"); } if let Some(ref user) = req.del_user { - println!("Removing user {user} from group {group_name}"); + let _ = writeln!(out, "Removing user {user} from group {group_name}"); } Ok(()) @@ -494,18 +474,27 @@ fn crypt_method(defs: &LoginDefs) -> crypt::CryptMethod { } fn prompt_and_hash_password(root: &SysRoot, group_name: &str) -> Result { - eprintln!("Changing the password for group {group_name}"); - let _ = io::stderr().flush(); + // Never println!/eprintln!: they panic when the stream is closed, which a + // setuid-root tool must not do part way through a change. + let _ = writeln!(io::stderr(), "Changing the password for group {group_name}"); + + // The shared helper blocks SIGINT, SIGQUIT and SIGTSTP for the read, so + // Ctrl-C at the prompt cannot leave the terminal with echo disabled, and + // it falls back to stderr and stdin where there is no controlling + // terminal. + let read = |prompt: &str| { + shadow_core::tty::read_password(prompt) + .map_err(|e| GpasswdError::Failure(format!("cannot read the password: {e}"))) + }; let password = loop { - let pass1 = read_password("New Password: ")?; - let pass2 = read_password("Re-enter new password: ")?; + let pass1 = read("New Password: ")?; + let pass2 = read("Re-enter new password: ")?; if *pass1 == *pass2 { break pass1; } // GNU gpasswd retries instead of exiting on a mismatch. - eprintln!("They don't match; try again"); - let _ = io::stderr().flush(); + let _ = writeln!(io::stderr(), "They don't match; try again"); }; let defs = LoginDefs::load(&root.login_defs_path()) @@ -519,90 +508,6 @@ fn prompt_and_hash_password(root: &SysRoot, group_name: &str) -> Result Result { - use std::os::unix::io::AsFd; - - let old_termios = rustix::termios::tcgetattr(tty.as_fd()).map_err(|e| { - GpasswdError::CantUpdate(format!("cannot get terminal attributes: {e}")) - })?; - - let mut new_termios = old_termios.clone(); - new_termios.local_modes &= !rustix::termios::LocalModes::ECHO; - rustix::termios::tcsetattr( - tty.as_fd(), - rustix::termios::OptionalActions::Now, - &new_termios, - ) - .map_err(|e| GpasswdError::CantUpdate(format!("cannot disable echo: {e}")))?; - - Ok(Self { tty, old_termios }) - } -} - -impl Drop for EchoGuard { - fn drop(&mut self) { - use std::os::unix::io::AsFd; - let _ = rustix::termios::tcsetattr( - self.tty.as_fd(), - rustix::termios::OptionalActions::Now, - &self.old_termios, - ); - } -} - -/// Read a password from `/dev/tty` with echo disabled. -/// -/// The returned password is wrapped in `Zeroizing` so it is scrubbed from -/// memory when dropped. -fn read_password(prompt: &str) -> Result, GpasswdError> { - use std::io::{BufRead, Write as _}; - - let tty = std::fs::File::options() - .read(true) - .write(true) - .open("/dev/tty") - .map_err(|_| GpasswdError::Failure("Not a tty".into()))?; - - if !rustix::termios::isatty(&tty) { - return Err(GpasswdError::Failure("Not a tty".into())); - } - - (&tty) - .write_all(prompt.as_bytes()) - .map_err(|e| GpasswdError::CantUpdate(format!("cannot write prompt: {e}")))?; - (&tty) - .flush() - .map_err(|e| GpasswdError::CantUpdate(format!("cannot flush prompt: {e}")))?; - - // Clone the tty handle: one for the guard (to restore echo), one for reading. - let tty_for_guard = tty - .try_clone() - .map_err(|e| GpasswdError::CantUpdate(format!("cannot clone tty handle: {e}")))?; - - let guard = EchoGuard::disable(tty_for_guard)?; - - let mut buf = zeroize::Zeroizing::new(String::new()); - let mut reader = std::io::BufReader::new(&tty); - reader - .read_line(&mut buf) - .map_err(|e| GpasswdError::CantUpdate(format!("cannot read password: {e}")))?; - - drop(guard); - let _ = (&tty).write_all(b"\n"); - - Ok(zeroize::Zeroizing::new( - buf.trim_end_matches(['\r', '\n']).to_string(), - )) -} - #[must_use] pub fn uu_app() -> Command { Command::new("gpasswd") diff --git a/tests/by-util/test_gpasswd.rs b/tests/by-util/test_gpasswd.rs index 234aedd..a36c3e5 100644 --- a/tests/by-util/test_gpasswd.rs +++ b/tests/by-util/test_gpasswd.rs @@ -6,15 +6,12 @@ //! Integration tests for the `gpasswd` utility. //! -//! Tests that require root are guarded by `common::skip_unless_root()` and run +//! Tests that require root are guarded by `crate::common::skip_unless_root()` and run //! inside Docker CI containers. Non-root tests exercise clap parsing and error //! paths that do not need privilege. use std::ffi::OsString; -#[path = "../common/mod.rs"] -mod common; - /// Run `uumain` with the given args, returning the exit code. fn run(args: &[&str]) -> i32 { let os_args: Vec = args.iter().map(|s| (*s).into()).collect(); @@ -123,7 +120,7 @@ fn test_add_and_admins_combination_fails() { #[test] fn test_add_user() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -152,7 +149,7 @@ fn test_add_user() { #[test] fn test_add_user_idempotent() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -174,7 +171,7 @@ fn test_add_user_idempotent() { #[test] fn test_delete_non_member_fails() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -190,7 +187,7 @@ fn test_delete_non_member_fails() { #[test] fn test_delete_user() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -208,7 +205,7 @@ fn test_delete_user() { #[test] fn test_delete_does_not_remove_admin() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -240,7 +237,7 @@ fn test_delete_does_not_remove_admin() { #[test] fn test_set_members() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -265,7 +262,7 @@ fn test_set_members() { #[test] fn test_clear_members() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -283,7 +280,7 @@ fn test_clear_members() { #[test] fn test_set_administrators() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -308,7 +305,7 @@ fn test_set_administrators() { #[test] fn test_set_admins_and_members_together() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -330,7 +327,7 @@ fn test_set_admins_and_members_together() { #[test] fn test_administrators_without_gshadow_fails() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -341,7 +338,7 @@ fn test_administrators_without_gshadow_fails() { #[test] fn test_remove_password() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -365,7 +362,7 @@ fn test_remove_password() { #[test] fn test_restrict() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -380,7 +377,7 @@ fn test_restrict() { #[test] fn test_restrict_without_gshadow() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -398,7 +395,7 @@ fn test_restrict_without_gshadow() { #[test] fn test_remove_password_without_gshadow() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -422,7 +419,7 @@ fn test_remove_password_without_gshadow() { #[test] fn test_creates_gshadow_entry() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -445,7 +442,7 @@ fn test_creates_gshadow_entry() { #[test] fn test_nonexistent_group_fails() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -456,7 +453,7 @@ fn test_nonexistent_group_fails() { #[test] fn test_nonexistent_user_fails() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } @@ -467,7 +464,7 @@ fn test_nonexistent_user_fails() { #[test] fn test_preserves_other_entries() { - if common::skip_unless_root() { + if crate::common::skip_unless_root() { return; } diff --git a/tests/by-util/test_multicall.rs b/tests/by-util/test_multicall.rs index f5862cb..b922d54 100644 --- a/tests/by-util/test_multicall.rs +++ b/tests/by-util/test_multicall.rs @@ -16,9 +16,9 @@ use std::process::Command; use crate::common::{run, run_cmd}; /// Every applet this build is expected to carry, in `--list` order. -const TOOLS: [&str; 14] = [ - "chage", "chfn", "chpasswd", "chsh", "groupadd", "groupdel", "groupmod", "grpck", "newgrp", - "passwd", "pwck", "useradd", "userdel", "usermod", +const TOOLS: [&str; 15] = [ + "chage", "chfn", "chpasswd", "chsh", "gpasswd", "groupadd", "groupdel", "groupmod", "grpck", + "newgrp", "passwd", "pwck", "useradd", "userdel", "usermod", ]; /// The binary with no applet argument. diff --git a/tests/gnu-compat.sh b/tests/gnu-compat.sh index d3321d1..6761ff5 100755 --- a/tests/gnu-compat.sh +++ b/tests/gnu-compat.sh @@ -111,7 +111,17 @@ PROBE=gnucompat_probe echo "error: cannot create the probe account" >&2 exit 1 } -cleanup() { /usr/sbin/userdel -r "$PROBE" >/dev/null 2>&1; } +# A throwaway group for the gpasswd comparisons. +PROBE_GRP=gnucompat_probegrp +/usr/sbin/groupdel "$PROBE_GRP" >/dev/null 2>&1 +/usr/sbin/groupadd "$PROBE_GRP" >/dev/null 2>&1 || { + echo "error: cannot create the probe group" >&2 + exit 1 +} +cleanup() { + /usr/sbin/userdel -r "$PROBE" >/dev/null 2>&1 + /usr/sbin/groupdel "$PROBE_GRP" >/dev/null 2>&1 +} trap cleanup EXIT # ── passwd -S ─────────────────────────────────────────────────────── @@ -173,6 +183,12 @@ compare_exit "groupdel on an unknown group" \ "$RS/groupdel no_such_group_9f3a" "/usr/sbin/groupdel no_such_group_9f3a" compare_exit "userdel on an unknown login" \ "$RS/userdel no_such_user_9f3a" "/usr/sbin/userdel no_such_user_9f3a" +compare_exit "gpasswd on an unknown group" \ + "$RS/gpasswd no_such_group_9f3a