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.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..2f7b69f 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 @@ -171,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/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..1c8bed5 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 @@ -45,6 +45,8 @@ 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")] @@ -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..d82814c --- /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 = ["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..da9a8f2 --- /dev/null +++ b/src/uu/gpasswd/src/gpasswd.rs @@ -0,0 +1,796 @@ +// 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::audit; +use shadow_core::crypt; +use shadow_core::group::GroupEntry; +use shadow_core::gshadow::{self, GshadowEntry}; +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"; + 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 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<()> { + 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 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(), + ); + } + + // 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())) + })?; + + 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 group_entries = group_file.entries_mut(); + 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")) + })?; + + if !is_root { + let caller = current_caller_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(); + } + } + + 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) + { + 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 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(); + + if let Some(gshadow_file) = gshadow_file.as_mut() { + let created = gshadow_file.find(&group_name).is_none(); + let gs = ensure_gshadow_entry( + gshadow_file.entries_mut(), + &group_name, + &members_for_gshadow, + &old_group_passwd, + ); + apply_gshadow_changes(gs, &req); + // The password now lives in gshadow, matching GNU when it creates the + // line. + if created { + group_file.entries_mut()[idx].passwd = "x".to_string(); + } + } + + // 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)); + } + 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 { + let _ = writeln!(out, "Adding user {user} to group {group_name}"); + } + if let Some(ref user) = req.del_user { + let _ = writeln!(out, "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 { + // 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("New Password: ")?; + let pass2 = read("Re-enter new password: ")?; + if *pass1 == *pass2 { + break pass1; + } + // GNU gpasswd retries instead of exiting on a mismatch. + let _ = writeln!(io::stderr(), "They don't match; try again"); + }; + + 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}"))) +} + +#[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..a36c3e5 --- /dev/null +++ b/tests/by-util/test_gpasswd.rs @@ -0,0 +1,479 @@ +// 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 `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; + +/// 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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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/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/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..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