phase3: implement groupadd, groupdel, groupmod, grpck, chfn, chsh, newgrp

All 14 shadow-utils tools are now implemented.

groupadd: create groups with auto GID allocation, system groups, force mode.
groupdel: delete groups, checks for primary group usage.
groupmod: modify GID, rename, password changes.
grpck: verify /etc/group and /etc/gshadow integrity.
chfn: change GECOS sub-fields (name, room, phone).
chsh: change login shell with /etc/shells validation.
newgrp: change effective group with crypt(3) password verification.

449 tests, zero clippy warnings, all 14 tools in multicall binary.
This commit is contained in:
Pierre Warnier
2026-03-24 10:46:23 +01:00
parent b3a3041726
commit 8ee65a7552
28 changed files with 4135 additions and 8 deletions
+39 -1
View File
@@ -19,6 +19,13 @@ members = [
"src/uu/useradd",
"src/uu/userdel",
"src/uu/usermod",
"src/uu/groupadd",
"src/uu/groupdel",
"src/uu/groupmod",
"src/uu/grpck",
"src/uu/chfn",
"src/uu/chsh",
"src/uu/newgrp",
"src/uu/chpasswd",
"src/uu/chage",
]
@@ -63,9 +70,17 @@ userdel = { optional = true, version = "0.0.1", package = "uu_userdel", path = "
usermod = { optional = true, version = "0.0.1", package = "uu_usermod", path = "src/uu/usermod" }
chpasswd = { optional = true, version = "0.0.1", package = "uu_chpasswd", path = "src/uu/chpasswd" }
chage = { optional = true, version = "0.0.1", package = "uu_chage", path = "src/uu/chage" }
groupadd = { optional = true, version = "0.0.1", package = "uu_groupadd", path = "src/uu/groupadd" }
groupdel = { optional = true, version = "0.0.1", package = "uu_groupdel", path = "src/uu/groupdel" }
groupmod = { optional = true, version = "0.0.1", package = "uu_groupmod", path = "src/uu/groupmod" }
grpck = { optional = true, version = "0.0.1", package = "uu_grpck", path = "src/uu/grpck" }
chfn = { optional = true, version = "0.0.1", package = "uu_chfn", path = "src/uu/chfn" }
chsh = { optional = true, version = "0.0.1", package = "uu_chsh", path = "src/uu/chsh" }
newgrp = { optional = true, version = "0.0.1", package = "uu_newgrp", path = "src/uu/newgrp" }
[features]
default = ["passwd", "pwck", "useradd", "userdel", "usermod", "chpasswd", "chage"]
default = ["passwd", "pwck", "useradd", "userdel", "usermod", "chpasswd", "chage",
"groupadd", "groupdel", "groupmod", "grpck", "chfn", "chsh", "newgrp"]
# Individual tools
feat_passwd = ["passwd"]
@@ -75,10 +90,18 @@ feat_userdel = ["userdel"]
feat_usermod = ["usermod"]
feat_chpasswd = ["chpasswd"]
feat_chage = ["chage"]
feat_groupadd = ["groupadd"]
feat_groupdel = ["groupdel"]
feat_groupmod = ["groupmod"]
feat_grpck = ["grpck"]
feat_chfn = ["chfn"]
feat_chsh = ["chsh"]
feat_newgrp = ["newgrp"]
# Grouped features
feat_phase1 = ["feat_passwd", "feat_pwck"]
feat_phase2 = ["feat_useradd", "feat_userdel", "feat_usermod", "feat_chpasswd", "feat_chage"]
feat_phase3 = ["feat_groupadd", "feat_groupdel", "feat_groupmod", "feat_grpck", "feat_chfn", "feat_chsh", "feat_newgrp"]
feat_common = ["feat_phase1", "feat_phase2"]
[[bin]]
@@ -97,9 +120,24 @@ path = "tests/by-util/test_chpasswd.rs"
name = "test_passwd"
path = "tests/by-util/test_passwd.rs"
[[test]]
name = "test_chfn"
path = "tests/by-util/test_chfn.rs"
[[test]]
name = "test_chsh"
path = "tests/by-util/test_chsh.rs"
[[test]]
name = "test_newgrp"
path = "tests/by-util/test_newgrp.rs"
[dev-dependencies]
chage = { version = "0.0.1", package = "uu_chage", path = "src/uu/chage" }
chfn = { version = "0.0.1", package = "uu_chfn", path = "src/uu/chfn" }
chpasswd = { version = "0.0.1", package = "uu_chpasswd", path = "src/uu/chpasswd" }
chsh = { version = "0.0.1", package = "uu_chsh", path = "src/uu/chsh" }
newgrp = { version = "0.0.1", package = "uu_newgrp", path = "src/uu/newgrp" }
shadow-core = { path = "src/shadow-core", features = ["shadow"] }
nix = { workspace = true }
tempfile = { workspace = true }
+7 -7
View File
@@ -54,13 +54,13 @@ default-in-Ubuntu in under 3 years. shadow-rs follows that playbook.
| `usermod` | **Implemented.** Modify all properties, group membership, lock/unlock. |
| `chpasswd` | **Implemented.** Batch password change from stdin. |
| `chage` | **Implemented.** Password aging management, `-l` list mode. |
| `groupadd` | Planned (Phase 3) |
| `groupdel` | Planned (Phase 3) |
| `groupmod` | Planned (Phase 3) |
| `grpck` | Planned (Phase 3) |
| `chfn` | Planned (Phase 3) |
| `chsh` | Planned (Phase 3) |
| `newgrp` | Planned (Phase 3) |
| `groupadd` | **Implemented.** Auto GID allocation, system groups, force mode. |
| `groupdel` | **Implemented.** Primary group usage check. |
| `groupmod` | **Implemented.** GID change, rename, password. |
| `grpck` | **Implemented.** Group/gshadow integrity verification. |
| `chfn` | **Implemented.** GECOS sub-field modification. |
| `chsh` | **Implemented.** Shell change with /etc/shells validation. |
| `newgrp` | **Implemented.** Effective group change with crypt verification. |
## Building
+45
View File
@@ -8,6 +8,11 @@
//! Dispatches to the appropriate utility based on `argv[0]`.
//! When invoked as `shadow-rs <util>`, uses the first argument instead.
// newgrp uses crypt(3) from libcrypt — ensure the linker includes it.
#[cfg(feature = "newgrp")]
#[link(name = "crypt")]
extern "C" {}
use std::path::Path;
fn main() {
@@ -54,12 +59,32 @@ fn dispatch(name: &str, args: &[std::ffi::OsString]) -> Option<i32> {
match name {
#[cfg(feature = "chage")]
"chage" => Some(chage::uumain(args.iter().cloned())),
#[cfg(feature = "chfn")]
"chfn" => Some(chfn::uumain(args.iter().cloned())),
#[cfg(feature = "chpasswd")]
"chpasswd" => Some(chpasswd::uumain(args.iter().cloned())),
#[cfg(feature = "chsh")]
"chsh" => Some(chsh::uumain(args.iter().cloned())),
#[cfg(feature = "groupadd")]
"groupadd" => Some(groupadd::uumain(args.iter().cloned())),
#[cfg(feature = "groupdel")]
"groupdel" => Some(groupdel::uumain(args.iter().cloned())),
#[cfg(feature = "groupmod")]
"groupmod" => Some(groupmod::uumain(args.iter().cloned())),
#[cfg(feature = "grpck")]
"grpck" => Some(grpck::uumain(args.iter().cloned())),
#[cfg(feature = "newgrp")]
"newgrp" => Some(newgrp::uumain(args.iter().cloned())),
#[cfg(feature = "passwd")]
"passwd" => Some(passwd::uumain(args.iter().cloned())),
#[cfg(feature = "pwck")]
"pwck" => Some(pwck::uumain(args.iter().cloned())),
#[cfg(feature = "useradd")]
"useradd" => Some(useradd::uumain(args.iter().cloned())),
#[cfg(feature = "userdel")]
"userdel" => Some(userdel::uumain(args.iter().cloned())),
#[cfg(feature = "usermod")]
"usermod" => Some(usermod::uumain(args.iter().cloned())),
_ => None,
}
}
@@ -69,10 +94,30 @@ fn print_available_utils() {
#[cfg(feature = "chage")]
println!(" chage");
#[cfg(feature = "chfn")]
println!(" chfn");
#[cfg(feature = "chpasswd")]
println!(" chpasswd");
#[cfg(feature = "chsh")]
println!(" chsh");
#[cfg(feature = "groupadd")]
println!(" groupadd");
#[cfg(feature = "groupdel")]
println!(" groupdel");
#[cfg(feature = "groupmod")]
println!(" groupmod");
#[cfg(feature = "grpck")]
println!(" grpck");
#[cfg(feature = "newgrp")]
println!(" newgrp");
#[cfg(feature = "passwd")]
println!(" passwd");
#[cfg(feature = "pwck")]
println!(" pwck");
#[cfg(feature = "useradd")]
println!(" useradd");
#[cfg(feature = "userdel")]
println!(" userdel");
#[cfg(feature = "usermod")]
println!(" usermod");
}
+6
View File
@@ -84,6 +84,12 @@ impl SysRoot {
pub fn skel_path(&self) -> PathBuf {
self.resolve("/etc/skel")
}
/// Path to `/etc/shells`.
#[must_use]
pub fn shells_path(&self) -> PathBuf {
self.resolve("/etc/shells")
}
}
impl Default for SysRoot {
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "uu_chfn"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "chfn command (shadow-rs)"
[lib]
path = "src/chfn.rs"
[[bin]]
name = "chfn"
path = "src/main.rs"
[dependencies]
clap = { workspace = true }
libc = { workspace = true }
nix = { workspace = true }
shadow-core = { workspace = true }
uucore = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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_chfn);
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "uu_chsh"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "chsh command (shadow-rs)"
[lib]
path = "src/chsh.rs"
[[bin]]
name = "chsh"
path = "src/main.rs"
[dependencies]
clap = { workspace = true }
libc = { workspace = true }
nix = { workspace = true }
shadow-core = { workspace = true }
uucore = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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_chsh);
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "uu_groupadd"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "groupadd command (shadow-rs)"
[lib]
path = "src/groupadd.rs"
[[bin]]
name = "groupadd"
path = "src/main.rs"
[dependencies]
clap = { workspace = true }
libc = { workspace = true }
nix = { workspace = true }
shadow-core = { workspace = true, features = ["shadow", "group", "gshadow", "login-defs"] }
uucore = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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_groupadd);
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "uu_groupdel"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "groupdel command (shadow-rs)"
[lib]
path = "src/groupdel.rs"
[[bin]]
name = "groupdel"
path = "src/main.rs"
[dependencies]
clap = { workspace = true }
libc = { workspace = true }
nix = { workspace = true }
shadow-core = { workspace = true, features = ["shadow", "group", "gshadow", "login-defs"] }
uucore = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true
+374
View File
@@ -0,0 +1,374 @@
// 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 groupdel gshadow nscd sysroot
//! `groupdel` -- delete a group.
//!
//! Drop-in replacement for GNU shadow-utils `groupdel(8)`.
use std::fmt;
use std::path::Path;
use clap::{Arg, Command};
use uucore::error::{UError, UResult};
use shadow_core::atomic;
use shadow_core::group::{self, GroupEntry};
use shadow_core::gshadow::{self, GshadowEntry};
use shadow_core::lock::FileLock;
use shadow_core::nscd;
use shadow_core::passwd;
use shadow_core::sysroot::SysRoot;
mod options {
pub const GROUP: &str = "GROUP";
pub const ROOT: &str = "root";
pub const PREFIX: &str = "prefix";
}
mod exit_codes {
pub const BAD_SYNTAX: i32 = 2;
pub const GROUP_NOT_FOUND: i32 = 6;
pub const PRIMARY_GROUP: i32 = 8;
pub const CANT_UPDATE: i32 = 10;
}
#[derive(Debug)]
enum GroupdelError {
BadSyntax(String),
GroupNotFound(String),
PrimaryGroup(String),
CantUpdate(String),
AlreadyPrinted(i32),
}
impl fmt::Display for GroupdelError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BadSyntax(msg)
| Self::GroupNotFound(msg)
| Self::PrimaryGroup(msg)
| Self::CantUpdate(msg) => f.write_str(msg),
Self::AlreadyPrinted(_) => Ok(()),
}
}
}
impl std::error::Error for GroupdelError {}
impl UError for GroupdelError {
fn code(&self) -> i32 {
match self {
Self::BadSyntax(_) => exit_codes::BAD_SYNTAX,
Self::GroupNotFound(_) => exit_codes::GROUP_NOT_FOUND,
Self::PrimaryGroup(_) => exit_codes::PRIMARY_GROUP,
Self::CantUpdate(_) => exit_codes::CANT_UPDATE,
Self::AlreadyPrinted(c) => *c,
}
}
}
// ---------------------------------------------------------------------------
// Security hardening
// ---------------------------------------------------------------------------
fn suppress_core_dumps() {
let _ = nix::sys::resource::setrlimit(nix::sys::resource::Resource::RLIMIT_CORE, 0, 0);
#[cfg(target_os = "linux")]
{
// SAFETY: prctl with PR_SET_DUMPABLE is a simple flag set, no pointers.
unsafe {
libc::prctl(libc::PR_SET_DUMPABLE, 0);
}
}
}
fn raise_file_size_limit() {
let _ = nix::sys::resource::setrlimit(
nix::sys::resource::Resource::RLIMIT_FSIZE,
nix::sys::resource::RLIM_INFINITY,
nix::sys::resource::RLIM_INFINITY,
);
}
fn sanitize_env() {
let saved: Vec<(String, String)> = std::env::vars()
.filter(|(k, _)| k == "TERM" || k == "LANG" || k.starts_with("LC_"))
.collect();
let keys: Vec<std::ffi::OsString> = std::env::vars_os().map(|(k, _)| k).collect();
for key in keys {
std::env::remove_var(&key);
}
std::env::set_var("PATH", "/usr/bin:/bin:/usr/sbin:/sbin");
for (key, val) in saved {
std::env::set_var(&key, &val);
}
}
fn caller_is_root() -> bool {
nix::unistd::getuid().is_root()
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
#[uucore::main]
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
suppress_core_dumps();
raise_file_size_limit();
sanitize_env();
let matches = match uu_app().try_get_matches_from(args) {
Ok(m) => m,
Err(e) => {
e.print().ok();
if !e.use_stderr() {
return Ok(());
}
return Err(GroupdelError::AlreadyPrinted(exit_codes::BAD_SYNTAX).into());
}
};
if !caller_is_root() {
uucore::show_error!("Permission denied.");
return Err(GroupdelError::AlreadyPrinted(1).into());
}
let group_name = matches
.get_one::<String>(options::GROUP)
.ok_or_else(|| GroupdelError::BadSyntax("group name required".into()))?;
let prefix = matches.get_one::<String>(options::PREFIX).map(Path::new);
let root_dir = matches.get_one::<String>(options::ROOT).map(Path::new);
let root = SysRoot::new(prefix.or(root_dir));
// Read existing groups to find the target.
let group_path = root.group_path();
let group_lock = FileLock::acquire(&group_path).map_err(|e| {
GroupdelError::CantUpdate(format!("cannot lock {}: {e}", group_path.display()))
})?;
let entries = group::read_group_file(&group_path).map_err(|e| {
GroupdelError::CantUpdate(format!("cannot read {}: {e}", group_path.display()))
})?;
let target = entries
.iter()
.find(|g| g.name == *group_name)
.ok_or_else(|| {
GroupdelError::GroupNotFound(format!("group '{group_name}' does not exist"))
})?;
let target_gid = target.gid;
// Check that no user has this group as their primary group.
let passwd_path = root.passwd_path();
if passwd_path.exists() {
let passwd_entries = passwd::read_passwd_file(&passwd_path).map_err(|e| {
GroupdelError::CantUpdate(format!("cannot read {}: {e}", passwd_path.display()))
})?;
if let Some(user) = passwd_entries.iter().find(|u| u.gid == target_gid) {
drop(group_lock);
return Err(GroupdelError::PrimaryGroup(format!(
"cannot remove the primary group of user '{}'",
user.name
))
.into());
}
}
// Remove the group entry.
let new_entries: Vec<GroupEntry> = entries
.into_iter()
.filter(|g| g.name != *group_name)
.collect();
atomic::atomic_write(&group_path, |f| group::write_group(&new_entries, f)).map_err(|e| {
GroupdelError::CantUpdate(format!("cannot write {}: {e}", group_path.display()))
})?;
drop(group_lock);
// Remove from /etc/gshadow.
let gshadow_path = root.gshadow_path();
if gshadow_path.exists() {
let gs_lock = FileLock::acquire(&gshadow_path).map_err(|e| {
GroupdelError::CantUpdate(format!("cannot lock {}: {e}", gshadow_path.display()))
})?;
let gs_entries = gshadow::read_gshadow_file(&gshadow_path).map_err(|e| {
GroupdelError::CantUpdate(format!("cannot read {}: {e}", gshadow_path.display()))
})?;
let new_gs: Vec<GshadowEntry> = gs_entries
.into_iter()
.filter(|g| g.name != *group_name)
.collect();
// Only write if we actually had gshadow entries to begin with.
if !new_gs.is_empty() {
atomic::atomic_write(&gshadow_path, |f| gshadow::write_gshadow(&new_gs, f)).map_err(
|e| {
GroupdelError::CantUpdate(format!(
"cannot write {}: {e}",
gshadow_path.display()
))
},
)?;
}
drop(gs_lock);
}
nscd::invalidate_cache("group");
Ok(())
}
#[must_use]
pub fn uu_app() -> Command {
Command::new("groupdel")
.about("Delete a group")
.override_usage("groupdel [options] GROUP")
.arg(
Arg::new(options::ROOT)
.short('R')
.long("root")
.value_name("CHROOT_DIR")
.help("Apply changes in the CHROOT_DIR directory"),
)
.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("Name of the group to delete"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_builds() {
uu_app().debug_assert();
}
#[test]
fn test_group_required() {
assert!(uu_app().try_get_matches_from(["groupdel"]).is_err());
}
#[test]
fn test_prefix_flag() {
let m = uu_app()
.try_get_matches_from(["groupdel", "-P", "/mnt", "testgrp"])
.expect("valid args");
assert_eq!(
m.get_one::<String>(options::PREFIX).map(String::as_str),
Some("/mnt")
);
}
fn skip_unless_root() -> bool {
!nix::unistd::geteuid().is_root()
}
#[test]
fn test_delete_group() {
if skip_unless_root() {
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let etc = dir.path().join("etc");
std::fs::create_dir_all(&etc).expect("create etc");
std::fs::write(
etc.join("group"),
"root:x:0:\ntestgrp:x:1000:\nother:x:1001:\n",
)
.expect("write group");
std::fs::write(etc.join("passwd"), "root:x:0:0:root:/root:/bin/bash\n")
.expect("write passwd");
let code = uumain(
vec![
"groupdel".into(),
"-P".into(),
dir.path().as_os_str().to_owned(),
"testgrp".into(),
]
.into_iter(),
);
assert_eq!(code, 0);
let content = std::fs::read_to_string(etc.join("group")).expect("read group");
assert!(!content.contains("testgrp"));
assert!(content.contains("root:x:0:"));
assert!(content.contains("other:x:1001:"));
}
#[test]
fn test_delete_nonexistent_group() {
if skip_unless_root() {
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let etc = dir.path().join("etc");
std::fs::create_dir_all(&etc).expect("create etc");
std::fs::write(etc.join("group"), "root:x:0:\n").expect("write group");
let code = uumain(
vec![
"groupdel".into(),
"-P".into(),
dir.path().as_os_str().to_owned(),
"nogrp".into(),
]
.into_iter(),
);
assert_ne!(code, 0);
}
#[test]
fn test_cannot_delete_primary_group() {
if skip_unless_root() {
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let etc = dir.path().join("etc");
std::fs::create_dir_all(&etc).expect("create etc");
std::fs::write(etc.join("group"), "testgrp:x:1000:\n").expect("write group");
std::fs::write(
etc.join("passwd"),
"testuser:x:1000:1000::/home/testuser:/bin/bash\n",
)
.expect("write passwd");
let code = uumain(
vec![
"groupdel".into(),
"-P".into(),
dir.path().as_os_str().to_owned(),
"testgrp".into(),
]
.into_iter(),
);
assert_ne!(code, 0);
}
}
+6
View File
@@ -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_groupdel);
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "uu_groupmod"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "groupmod command (shadow-rs)"
[lib]
path = "src/groupmod.rs"
[[bin]]
name = "groupmod"
path = "src/main.rs"
[dependencies]
clap = { workspace = true }
libc = { workspace = true }
nix = { workspace = true }
shadow-core = { workspace = true, features = ["shadow", "group", "gshadow", "login-defs"] }
uucore = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true
+471
View File
@@ -0,0 +1,471 @@
// 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 groupmod gshadow nscd sysroot
//! `groupmod` -- modify a group definition.
//!
//! Drop-in replacement for GNU shadow-utils `groupmod(8)`.
use std::fmt;
use std::path::Path;
use clap::{Arg, ArgAction, Command};
use uucore::error::{UError, UResult};
use shadow_core::atomic;
use shadow_core::group::{self};
use shadow_core::gshadow::{self};
use shadow_core::lock::FileLock;
use shadow_core::nscd;
use shadow_core::sysroot::SysRoot;
mod options {
pub const GROUP: &str = "GROUP";
pub const GID: &str = "gid";
pub const NEW_NAME: &str = "new-name";
pub const NON_UNIQUE: &str = "non-unique";
pub const PASSWORD: &str = "password";
pub const ROOT: &str = "root";
pub const PREFIX: &str = "prefix";
}
mod exit_codes {
pub const BAD_SYNTAX: i32 = 2;
pub const BAD_ARGUMENT: i32 = 3;
pub const GID_IN_USE: i32 = 4;
pub const GROUP_NOT_FOUND: i32 = 6;
pub const NAME_IN_USE: i32 = 9;
pub const CANT_UPDATE: i32 = 10;
}
#[derive(Debug)]
enum GroupmodError {
BadSyntax(String),
BadArgument(String),
GidInUse(String),
GroupNotFound(String),
NameInUse(String),
CantUpdate(String),
AlreadyPrinted(i32),
}
impl fmt::Display for GroupmodError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BadSyntax(msg)
| Self::BadArgument(msg)
| Self::GidInUse(msg)
| Self::GroupNotFound(msg)
| Self::NameInUse(msg)
| Self::CantUpdate(msg) => f.write_str(msg),
Self::AlreadyPrinted(_) => Ok(()),
}
}
}
impl std::error::Error for GroupmodError {}
impl UError for GroupmodError {
fn code(&self) -> i32 {
match self {
Self::BadSyntax(_) => exit_codes::BAD_SYNTAX,
Self::BadArgument(_) => exit_codes::BAD_ARGUMENT,
Self::GidInUse(_) => exit_codes::GID_IN_USE,
Self::GroupNotFound(_) => exit_codes::GROUP_NOT_FOUND,
Self::NameInUse(_) => exit_codes::NAME_IN_USE,
Self::CantUpdate(_) => exit_codes::CANT_UPDATE,
Self::AlreadyPrinted(c) => *c,
}
}
}
// ---------------------------------------------------------------------------
// Security hardening
// ---------------------------------------------------------------------------
fn suppress_core_dumps() {
let _ = nix::sys::resource::setrlimit(nix::sys::resource::Resource::RLIMIT_CORE, 0, 0);
#[cfg(target_os = "linux")]
{
// SAFETY: prctl with PR_SET_DUMPABLE is a simple flag set, no pointers.
unsafe {
libc::prctl(libc::PR_SET_DUMPABLE, 0);
}
}
}
fn raise_file_size_limit() {
let _ = nix::sys::resource::setrlimit(
nix::sys::resource::Resource::RLIMIT_FSIZE,
nix::sys::resource::RLIM_INFINITY,
nix::sys::resource::RLIM_INFINITY,
);
}
fn sanitize_env() {
let saved: Vec<(String, String)> = std::env::vars()
.filter(|(k, _)| k == "TERM" || k == "LANG" || k.starts_with("LC_"))
.collect();
let keys: Vec<std::ffi::OsString> = std::env::vars_os().map(|(k, _)| k).collect();
for key in keys {
std::env::remove_var(&key);
}
std::env::set_var("PATH", "/usr/bin:/bin:/usr/sbin:/sbin");
for (key, val) in saved {
std::env::set_var(&key, &val);
}
}
fn caller_is_root() -> bool {
nix::unistd::getuid().is_root()
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
#[uucore::main]
#[allow(clippy::too_many_lines)]
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
suppress_core_dumps();
raise_file_size_limit();
sanitize_env();
let matches = match uu_app().try_get_matches_from(args) {
Ok(m) => m,
Err(e) => {
e.print().ok();
if !e.use_stderr() {
return Ok(());
}
return Err(GroupmodError::AlreadyPrinted(exit_codes::BAD_SYNTAX).into());
}
};
if !caller_is_root() {
uucore::show_error!("Permission denied.");
return Err(GroupmodError::AlreadyPrinted(1).into());
}
let group_name = matches
.get_one::<String>(options::GROUP)
.ok_or_else(|| GroupmodError::BadSyntax("group name required".into()))?;
let new_gid = matches.get_one::<String>(options::GID);
let new_name = matches.get_one::<String>(options::NEW_NAME);
let non_unique = matches.get_flag(options::NON_UNIQUE);
let new_password = matches.get_one::<String>(options::PASSWORD);
let prefix = matches.get_one::<String>(options::PREFIX).map(Path::new);
let root_dir = matches.get_one::<String>(options::ROOT).map(Path::new);
let root = SysRoot::new(prefix.or(root_dir));
// Validate new name if provided.
if let Some(name) = new_name {
shadow_core::validate::validate_username(name)
.map_err(|e| GroupmodError::BadArgument(format!("{e}")))?;
}
// Parse new GID if provided.
let parsed_gid: Option<u32> = new_gid
.map(|s| {
s.parse::<u32>()
.map_err(|_| GroupmodError::BadArgument(format!("invalid GID '{s}'")))
})
.transpose()?;
// Lock and read /etc/group.
let group_path = root.group_path();
let group_lock = FileLock::acquire(&group_path).map_err(|e| {
GroupmodError::CantUpdate(format!("cannot lock {}: {e}", group_path.display()))
})?;
let mut entries = group::read_group_file(&group_path).map_err(|e| {
GroupmodError::CantUpdate(format!("cannot read {}: {e}", group_path.display()))
})?;
// Find the target group.
let idx = entries
.iter()
.position(|g| g.name == *group_name)
.ok_or_else(|| {
GroupmodError::GroupNotFound(format!("group '{group_name}' does not exist"))
})?;
// Check GID collision.
if let Some(gid) = parsed_gid {
if !non_unique
&& entries
.iter()
.any(|g| g.gid == gid && g.name != *group_name)
{
drop(group_lock);
return Err(GroupmodError::GidInUse(format!("GID '{gid}' already exists")).into());
}
entries[idx].gid = gid;
}
// Check name collision.
if let Some(name) = new_name {
if entries
.iter()
.any(|g| g.name == *name && g.name != *group_name)
{
drop(group_lock);
return Err(GroupmodError::NameInUse(format!("group '{name}' already exists")).into());
}
entries[idx].name.clone_from(name);
}
// Write /etc/group.
atomic::atomic_write(&group_path, |f| group::write_group(&entries, f)).map_err(|e| {
GroupmodError::CantUpdate(format!("cannot write {}: {e}", group_path.display()))
})?;
drop(group_lock);
// Update /etc/gshadow.
let gshadow_path = root.gshadow_path();
if gshadow_path.exists() && (new_name.is_some() || new_password.is_some()) {
let gs_lock = FileLock::acquire(&gshadow_path).map_err(|e| {
GroupmodError::CantUpdate(format!("cannot lock {}: {e}", gshadow_path.display()))
})?;
let mut gs_entries = gshadow::read_gshadow_file(&gshadow_path).map_err(|e| {
GroupmodError::CantUpdate(format!("cannot read {}: {e}", gshadow_path.display()))
})?;
if let Some(gs) = gs_entries.iter_mut().find(|g| g.name == *group_name) {
if let Some(name) = new_name {
gs.name.clone_from(name);
}
if let Some(pw) = new_password {
gs.passwd.clone_from(pw);
}
}
atomic::atomic_write(&gshadow_path, |f| gshadow::write_gshadow(&gs_entries, f)).map_err(
|e| GroupmodError::CantUpdate(format!("cannot write {}: {e}", gshadow_path.display())),
)?;
drop(gs_lock);
}
nscd::invalidate_cache("group");
Ok(())
}
#[must_use]
pub fn uu_app() -> Command {
Command::new("groupmod")
.about("Modify a group definition")
.override_usage("groupmod [options] GROUP")
.arg(
Arg::new(options::GID)
.short('g')
.long("gid")
.value_name("GID")
.help("Change the group ID to GID"),
)
.arg(
Arg::new(options::NEW_NAME)
.short('n')
.long("new-name")
.value_name("NEW_GROUP")
.help("Change the name of the group to NEW_GROUP"),
)
.arg(
Arg::new(options::NON_UNIQUE)
.short('o')
.long("non-unique")
.help("Allow using a non-unique GID with -g")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new(options::PASSWORD)
.short('p')
.long("password")
.value_name("PASSWORD")
.help("Change the password to encrypted PASSWORD"),
)
.arg(
Arg::new(options::ROOT)
.short('R')
.long("root")
.value_name("CHROOT_DIR")
.help("Apply changes in the CHROOT_DIR directory"),
)
.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("Name of the group to modify"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_builds() {
uu_app().debug_assert();
}
#[test]
fn test_group_required() {
assert!(uu_app().try_get_matches_from(["groupmod"]).is_err());
}
#[test]
fn test_rename_flag() {
let m = uu_app()
.try_get_matches_from(["groupmod", "-n", "newname", "oldname"])
.expect("valid args");
assert_eq!(
m.get_one::<String>(options::NEW_NAME).map(String::as_str),
Some("newname")
);
}
#[test]
fn test_gid_flag() {
let m = uu_app()
.try_get_matches_from(["groupmod", "-g", "5000", "mygrp"])
.expect("valid args");
assert_eq!(
m.get_one::<String>(options::GID).map(String::as_str),
Some("5000")
);
}
#[test]
fn test_non_unique_flag() {
let m = uu_app()
.try_get_matches_from(["groupmod", "-o", "-g", "0", "mygrp"])
.expect("valid args");
assert!(m.get_flag(options::NON_UNIQUE));
}
fn skip_unless_root() -> bool {
!nix::unistd::geteuid().is_root()
}
#[test]
fn test_change_gid() {
if skip_unless_root() {
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let etc = dir.path().join("etc");
std::fs::create_dir_all(&etc).expect("create etc");
std::fs::write(etc.join("group"), "testgrp:x:1000:\n").expect("write group");
let code = uumain(
vec![
"groupmod".into(),
"-g".into(),
"2000".into(),
"-P".into(),
dir.path().as_os_str().to_owned(),
"testgrp".into(),
]
.into_iter(),
);
assert_eq!(code, 0);
let content = std::fs::read_to_string(etc.join("group")).expect("read group");
assert!(content.contains("testgrp:x:2000:"));
}
#[test]
fn test_rename_group() {
if skip_unless_root() {
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let etc = dir.path().join("etc");
std::fs::create_dir_all(&etc).expect("create etc");
std::fs::write(etc.join("group"), "oldgrp:x:1000:\n").expect("write group");
let code = uumain(
vec![
"groupmod".into(),
"-n".into(),
"newgrp".into(),
"-P".into(),
dir.path().as_os_str().to_owned(),
"oldgrp".into(),
]
.into_iter(),
);
assert_eq!(code, 0);
let content = std::fs::read_to_string(etc.join("group")).expect("read group");
assert!(content.contains("newgrp:x:1000:"));
assert!(!content.contains("oldgrp"));
}
#[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("create etc");
std::fs::write(etc.join("group"), "root:x:0:\n").expect("write group");
let code = uumain(
vec![
"groupmod".into(),
"-g".into(),
"5000".into(),
"-P".into(),
dir.path().as_os_str().to_owned(),
"nogroup".into(),
]
.into_iter(),
);
assert_ne!(code, 0);
}
#[test]
fn test_gid_collision_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("create etc");
std::fs::write(etc.join("group"), "grp1:x:1000:\ngrp2:x:2000:\n").expect("write group");
let code = uumain(
vec![
"groupmod".into(),
"-g".into(),
"2000".into(),
"-P".into(),
dir.path().as_os_str().to_owned(),
"grp1".into(),
]
.into_iter(),
);
assert_ne!(code, 0);
}
}
+6
View File
@@ -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_groupmod);
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "uu_grpck"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "grpck command (shadow-rs)"
[lib]
path = "src/grpck.rs"
[[bin]]
name = "grpck"
path = "src/main.rs"
[dependencies]
clap = { workspace = true }
libc = { workspace = true }
nix = { workspace = true }
shadow-core = { workspace = true, features = ["shadow", "group", "gshadow", "login-defs"] }
uucore = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true

Some files were not shown because too many files have changed in this diff Show More