tests: add integration tests for all 14 tools, add CHANGELOG

Add integration tests for the 8 remaining tools:
- useradd, userdel, usermod (user management)
- groupadd, groupdel, groupmod (group management)
- pwck, grpck (file consistency checkers)

101 new tests covering clap parsing, flag conflicts, error paths,
and root-only file mutation operations with synthetic /etc/ files.

Total test count: 461 → 562.

Also adds CHANGELOG.md for v0.1.0 release.
This commit is contained in:
Pierre Warnier
2026-03-24 15:59:04 +01:00
parent caac89485d
commit 7bd47ff3d0
10 changed files with 2512 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-03-24
### Added
- All 14 shadow-utils tools implemented as drop-in replacements:
`passwd`, `useradd`, `userdel`, `usermod`, `groupadd`, `groupdel`,
`groupmod`, `pwck`, `grpck`, `chage`, `chpasswd`, `chfn`, `chsh`, `newgrp`
- Single multicall binary with symlink dispatch (894 KB stripped)
- PAM integration for password authentication and changes
- Atomic file writes with lock-via-hard-link pattern (TOCTOU resistant)
- Stale lock detection via ESRCH-only PID checking
- Password zeroing via `zeroize` crate
- Core dump suppression and file size limit hardening
- Environment sanitization (safe for setuid-root context)
- Signal blocking during critical file operations
- SELinux file context support (best-effort via external tools)
- Audit logging to syslog and auditd
- subuid/subgid allocation for rootless containers (useradd)
- Recursive chown on UID change (usermod)
- Proper date validation with leap year and month-length rules
- GNU-compatible output and exit codes for all tools
- 460+ unit tests, property-based tests (proptest), 4 fuzz targets
- Integration tests for 14 tools
- Docker test matrix: Debian (glibc), Alpine (musl), Fedora (SELinux)
- CI gates: fmt, clippy, test, MSRV (1.94.0), cargo-deny
- Debian and RPM packaging
- Man pages for all 14 tools
- GNU compatibility test suite and PAM end-to-end test
### Security
- `unsafe_code = "deny"` enforced at workspace level (only PAM/crypt FFI exempted)
- `dead_code = "deny"` enforced at workspace level
- O_EXCL temp files (symlink attack prevention)
- Umask guard (RAII) for restrictive file permissions
- GPL clean-room development (MIT license, no GPL source referenced)
- Reviewed by GitHub Copilot (automated) and Google Gemini CLI (manual)
- 20+ security findings addressed across 4 review rounds
+41
View File
@@ -133,12 +133,53 @@ path = "tests/by-util/test_chsh.rs"
name = "test_newgrp"
path = "tests/by-util/test_newgrp.rs"
[[test]]
name = "test_groupdel"
path = "tests/by-util/test_groupdel.rs"
[[test]]
name = "test_groupmod"
path = "tests/by-util/test_groupmod.rs"
[[test]]
name = "test_useradd"
path = "tests/by-util/test_useradd.rs"
[[test]]
name = "test_userdel"
path = "tests/by-util/test_userdel.rs"
[[test]]
name = "test_pwck"
path = "tests/by-util/test_pwck.rs"
[[test]]
name = "test_grpck"
path = "tests/by-util/test_grpck.rs"
[[test]]
name = "test_usermod"
path = "tests/by-util/test_usermod.rs"
[[test]]
name = "test_groupadd"
path = "tests/by-util/test_groupadd.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" }
groupadd = { version = "0.0.1", package = "uu_groupadd", path = "src/uu/groupadd" }
groupdel = { version = "0.0.1", package = "uu_groupdel", path = "src/uu/groupdel" }
groupmod = { version = "0.0.1", package = "uu_groupmod", path = "src/uu/groupmod" }
newgrp = { version = "0.0.1", package = "uu_newgrp", path = "src/uu/newgrp" }
passwd = { version = "0.0.1", package = "uu_passwd", path = "src/uu/passwd" }
useradd = { version = "0.0.1", package = "uu_useradd", path = "src/uu/useradd" }
userdel = { version = "0.0.1", package = "uu_userdel", path = "src/uu/userdel" }
usermod = { version = "0.0.1", package = "uu_usermod", path = "src/uu/usermod" }
pwck = { version = "0.0.1", package = "uu_pwck", path = "src/uu/pwck" }
grpck = { version = "0.0.1", package = "uu_grpck", path = "src/uu/grpck" }
shadow-core = { path = "src/shadow-core", features = ["shadow"] }
nix = { workspace = true }
tempfile = { workspace = true }
+239
View File
@@ -0,0 +1,239 @@
// This file is part of the shadow-rs package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore groupadd gshadow nscd sysroot
//! Integration tests for the `groupadd` utility.
//!
//! Tests that require root are guarded by `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;
/// Skip the test when not running as root (euid != 0).
fn skip_unless_root() -> bool {
!nix::unistd::geteuid().is_root()
}
/// Run `uumain` with the given args, returning the exit code.
fn run(args: &[&str]) -> i32 {
let os_args: Vec<OsString> = args.iter().map(|s| (*s).into()).collect();
groupadd::uumain(os_args.into_iter())
}
/// Helper to create a temp dir with `etc/group`, `etc/gshadow`, and
/// `etc/login.defs`.
fn setup_prefix(group_content: &str, login_defs_content: &str) -> 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"), group_content).expect("failed to write group file");
std::fs::write(etc.join("gshadow"), "").expect("failed to write gshadow file");
std::fs::write(etc.join("login.defs"), login_defs_content)
.expect("failed to write login.defs file");
dir
}
/// Read the group file content back from a prefix dir.
fn read_group(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/group")).expect("failed to read group file")
}
/// 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!["groupadd", "-P", prefix_str];
args.extend_from_slice(extra_args);
run(&args)
}
// ---------------------------------------------------------------------------
// Non-root tests -- exercise clap parsing and error paths
// ---------------------------------------------------------------------------
#[test]
fn test_help_exits_zero() {
let code = run(&["groupadd", "--help"]);
assert_eq!(code, 0, "--help should exit 0");
}
#[test]
fn test_unknown_flag_exits_error() {
let code = run(&["groupadd", "--bogus", "somegroup"]);
assert_eq!(code, 2, "unknown flag should exit 2");
}
#[test]
fn test_missing_group_name_exits_error() {
let code = run(&["groupadd"]);
assert_eq!(code, 2, "missing GROUP should exit 2");
}
// ---------------------------------------------------------------------------
// Root-only tests -- exercise real operations via --prefix
// ---------------------------------------------------------------------------
#[test]
fn test_create_group_basic() {
if skip_unless_root() {
return;
}
let login_defs = "GID_MIN 1000\nGID_MAX 60000\n";
let dir = setup_prefix("root:x:0:\n", login_defs);
let code = run_with_prefix(&dir, &["newgroup"]);
assert_eq!(code, 0, "creating a basic group should succeed");
let content = read_group(&dir);
assert!(
content.contains("newgroup:x:"),
"newgroup should appear in /etc/group, got: {content}"
);
// The original root entry should be preserved.
assert!(
content.contains("root:x:0:"),
"root entry should be preserved, got: {content}"
);
}
#[test]
fn test_create_group_with_gid() {
if skip_unless_root() {
return;
}
let dir = setup_prefix("root:x:0:\n", "");
let code = run_with_prefix(&dir, &["-g", "5000", "devgrp"]);
assert_eq!(code, 0, "creating group with explicit GID should succeed");
let content = read_group(&dir);
assert!(
content.contains("devgrp:x:5000:"),
"devgrp should have GID 5000, got: {content}"
);
}
#[test]
fn test_create_group_system() {
if skip_unless_root() {
return;
}
let login_defs = "SYS_GID_MIN 100\nSYS_GID_MAX 999\nGID_MIN 1000\nGID_MAX 60000\n";
let dir = setup_prefix("root:x:0:\n", login_defs);
let code = run_with_prefix(&dir, &["-r", "sysgrp"]);
assert_eq!(code, 0, "creating system group should succeed");
let content = read_group(&dir);
assert!(
content.contains("sysgrp:x:"),
"sysgrp should appear in /etc/group, got: {content}"
);
// Extract the GID and verify it is in the system range (< 1000).
let gid_str = content
.lines()
.find(|l| l.starts_with("sysgrp:"))
.expect("sysgrp line should exist")
.split(':')
.nth(2)
.expect("GID field should exist");
let gid: u32 = gid_str.parse().expect("GID should be numeric");
assert!(gid < 1000, "system group GID should be < 1000, got: {gid}");
}
#[test]
fn test_create_group_non_unique() {
if skip_unless_root() {
return;
}
let dir = setup_prefix("root:x:0:\n", "");
// -o allows non-unique GID; -g 0 duplicates root's GID.
let code = run_with_prefix(&dir, &["-o", "-g", "0", "dupgrp"]);
assert_eq!(code, 0, "creating group with non-unique GID should succeed");
let content = read_group(&dir);
assert!(
content.contains("dupgrp:x:0:"),
"dupgrp should have GID 0, got: {content}"
);
}
#[test]
fn test_duplicate_group_fails() {
if skip_unless_root() {
return;
}
let dir = setup_prefix("mygrp:x:1000:\n", "");
let code = run_with_prefix(&dir, &["mygrp"]);
assert_eq!(
code, 9,
"adding existing group should exit 9 (GROUP_IN_USE)"
);
}
#[test]
fn test_duplicate_gid_fails() {
if skip_unless_root() {
return;
}
let dir = setup_prefix("existing:x:5000:\n", "");
// Without -o, a duplicate GID should fail.
let code = run_with_prefix(&dir, &["-g", "5000", "newgrp"]);
assert_eq!(code, 4, "duplicate GID should exit 4 (GID_IN_USE)");
}
#[test]
fn test_force_on_existing_succeeds() {
if skip_unless_root() {
return;
}
let dir = setup_prefix("mygrp:x:1000:\n", "");
// -f should exit successfully even if the group already exists.
let code = run_with_prefix(&dir, &["-f", "mygrp"]);
assert_eq!(code, 0, "-f on existing group should exit 0");
}
#[test]
fn test_other_entries_preserved() {
if skip_unless_root() {
return;
}
let login_defs = "GID_MIN 1000\nGID_MAX 60000\n";
let dir = setup_prefix("root:x:0:\nadm:x:4:syslog\nstaff:x:50:\n", login_defs);
let code = run_with_prefix(&dir, &["freshgrp"]);
assert_eq!(code, 0);
let content = read_group(&dir);
assert!(
content.contains("root:x:0:"),
"root should be preserved, got: {content}"
);
assert!(
content.contains("adm:x:4:syslog"),
"adm should be preserved, got: {content}"
);
assert!(
content.contains("staff:x:50:"),
"staff should be preserved, got: {content}"
);
assert!(
content.contains("freshgrp:x:"),
"freshgrp should be added, got: {content}"
);
}
+255
View File
@@ -0,0 +1,255 @@
// 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 testgrp testuser nogrp
//! Integration tests for the `groupdel` utility.
//!
//! Tests that require root are guarded by `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;
/// Skip the test when not running as root (euid != 0).
fn skip_unless_root() -> bool {
!nix::unistd::geteuid().is_root()
}
/// Run `uumain` with the given args, returning the exit code.
fn run(args: &[&str]) -> i32 {
let os_args: Vec<OsString> = args.iter().map(|s| (*s).into()).collect();
groupdel::uumain(os_args.into_iter())
}
/// Helper to create a temp dir with `etc/group`, `etc/gshadow`, and `etc/passwd`.
fn setup_root(
group_content: &str,
gshadow_content: &str,
passwd_content: &str,
) -> 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"), group_content).expect("failed to write group file");
std::fs::write(etc.join("gshadow"), gshadow_content).expect("failed to write gshadow file");
std::fs::write(etc.join("passwd"), passwd_content).expect("failed to write passwd file");
dir
}
/// Read the group file content back from a prefix dir.
fn read_group(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/group")).expect("failed to read group file")
}
/// Read the gshadow file content back from a prefix dir.
fn read_gshadow(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/gshadow")).expect("failed to read gshadow file")
}
// ---------------------------------------------------------------------------
// Non-root tests — exercise clap parsing and error paths
// ---------------------------------------------------------------------------
#[test]
fn test_help_exits_zero() {
let code = run(&["groupdel", "--help"]);
assert_eq!(code, 0, "--help should exit 0");
}
#[test]
fn test_unknown_flag_exits_error() {
let code = run(&["groupdel", "--nonexistent-flag"]);
assert_eq!(code, 2, "unknown flag should exit 2");
}
#[test]
fn test_missing_group_name_exits_error() {
let code = run(&["groupdel"]);
assert_eq!(code, 2, "missing group name should exit 2");
}
// ---------------------------------------------------------------------------
// Root-only tests — exercise real operations via -P (prefix) flag
// ---------------------------------------------------------------------------
#[test]
fn test_delete_group_basic() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"root:x:0:\ntestgrp:x:1000:\nother:x:1001:\n",
"root:!::\ntestgrp:!::\nother:!::\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupdel", "-P", prefix, "testgrp"]);
assert_eq!(code, 0, "deleting an existing group should exit 0");
let content = read_group(&dir);
assert!(
!content.contains("testgrp"),
"testgrp should be removed from group file"
);
}
#[test]
fn test_delete_nonexistent_group_fails() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"root:x:0:\nother:x:1001:\n",
"root:!::\nother:!::\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupdel", "-P", prefix, "nogrp"]);
assert_eq!(
code, 6,
"deleting a nonexistent group should exit 6 (GROUP_NOT_FOUND)"
);
}
#[test]
fn test_delete_group_preserves_others() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"root:x:0:\nalpha:x:1000:\nbeta:x:1001:\ngamma:x:1002:\n",
"root:!::\nalpha:!::\nbeta:!::\ngamma:!::\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupdel", "-P", prefix, "beta"]);
assert_eq!(code, 0);
let content = read_group(&dir);
assert!(!content.contains("beta"), "beta should be removed");
assert!(
content.contains("root:x:0:"),
"root group should be preserved"
);
assert!(
content.contains("alpha:x:1000:"),
"alpha group should be preserved"
);
assert!(
content.contains("gamma:x:1002:"),
"gamma group should be preserved"
);
}
#[test]
fn test_delete_primary_group_fails() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"testgrp:x:1000:\n",
"testgrp:!::\n",
"testuser:x:1000:1000:Test User:/home/testuser:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupdel", "-P", prefix, "testgrp"]);
assert_eq!(
code, 8,
"deleting a primary group should exit 8 (PRIMARY_GROUP)"
);
// Group should still exist since deletion was refused.
let content = read_group(&dir);
assert!(
content.contains("testgrp:x:1000:"),
"testgrp should still exist after failed deletion"
);
}
#[test]
fn test_delete_group_removes_gshadow() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"root:x:0:\ntestgrp:x:1000:\nother:x:1001:\n",
"root:!::\ntestgrp:!::\nother:!::\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupdel", "-P", prefix, "testgrp"]);
assert_eq!(code, 0);
let gs_content = read_gshadow(&dir);
assert!(
!gs_content.contains("testgrp"),
"testgrp should be removed from gshadow file"
);
assert!(
gs_content.contains("root:"),
"root gshadow entry should be preserved"
);
assert!(
gs_content.contains("other:"),
"other gshadow entry should be preserved"
);
}
#[test]
fn test_delete_with_root_flag() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"root:x:0:\ntarget:x:2000:\n",
"root:!::\ntarget:!::\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let root_path = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupdel", "-R", root_path, "target"]);
assert_eq!(code, 0, "-R flag should work the same as -P");
let content = read_group(&dir);
assert!(
!content.contains("target"),
"target should be removed using -R flag"
);
}
#[test]
fn test_delete_group_with_members() {
if skip_unless_root() {
return;
}
// Group has supplementary members but is not anyone's primary group.
let dir = setup_root(
"root:x:0:\ndevteam:x:1500:alice,bob\n",
"root:!::\ndevteam:!::alice,bob\n",
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\nbob:x:1001:1001:Bob:/home/bob:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupdel", "-P", prefix, "devteam"]);
assert_eq!(
code, 0,
"deleting a group with supplementary members should succeed"
);
let content = read_group(&dir);
assert!(!content.contains("devteam"), "devteam should be removed");
}
+280
View File
@@ -0,0 +1,280 @@
// 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 testgrp newgrp oldgrp nogroup
//! Integration tests for the `groupmod` utility.
//!
//! Tests that require root are guarded by `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;
/// Skip the test when not running as root (euid != 0).
fn skip_unless_root() -> bool {
!nix::unistd::geteuid().is_root()
}
/// Run `uumain` with the given args, returning the exit code.
fn run(args: &[&str]) -> i32 {
let os_args: Vec<OsString> = args.iter().map(|s| (*s).into()).collect();
groupmod::uumain(os_args.into_iter())
}
/// Helper to create a temp dir with `etc/group`, `etc/gshadow`, and `etc/passwd`.
fn setup_root(
group_content: &str,
gshadow_content: &str,
passwd_content: &str,
) -> 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"), group_content).expect("failed to write group file");
std::fs::write(etc.join("gshadow"), gshadow_content).expect("failed to write gshadow file");
std::fs::write(etc.join("passwd"), passwd_content).expect("failed to write passwd file");
dir
}
/// Read the group file content back from a prefix dir.
fn read_group(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/group")).expect("failed to read group file")
}
/// Read the gshadow file content back from a prefix dir.
fn read_gshadow(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/gshadow")).expect("failed to read gshadow file")
}
// ---------------------------------------------------------------------------
// Non-root tests — exercise clap parsing and error paths
// ---------------------------------------------------------------------------
#[test]
fn test_help_exits_zero() {
let code = run(&["groupmod", "--help"]);
assert_eq!(code, 0, "--help should exit 0");
}
#[test]
fn test_unknown_flag_exits_error() {
let code = run(&["groupmod", "--nonexistent-flag"]);
assert_eq!(code, 2, "unknown flag should exit 2");
}
#[test]
fn test_missing_group_name_exits_error() {
let code = run(&["groupmod"]);
assert_eq!(code, 2, "missing group name should exit 2");
}
// ---------------------------------------------------------------------------
// Root-only tests — exercise real operations via -P (prefix) flag
// ---------------------------------------------------------------------------
#[test]
fn test_change_gid() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"testgrp:x:1000:\n",
"testgrp:!::\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupmod", "-g", "9999", "-P", prefix, "testgrp"]);
assert_eq!(code, 0, "changing GID should exit 0");
let content = read_group(&dir);
assert!(
content.contains("testgrp:x:9999:"),
"GID should be changed to 9999, got: {content}"
);
}
#[test]
fn test_change_name() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"oldgrp:x:1000:\n",
"oldgrp:!::\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupmod", "-n", "newname", "-P", prefix, "oldgrp"]);
assert_eq!(code, 0, "renaming group should exit 0");
let content = read_group(&dir);
assert!(
content.contains("newname:x:1000:"),
"group should be renamed to newname, got: {content}"
);
assert!(
!content.contains("oldgrp"),
"old group name should not remain"
);
}
#[test]
fn test_non_unique_gid() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"root:x:0:\ntestgrp:x:1000:\n",
"root:!::\ntestgrp:!::\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
// Without -o, setting GID to 0 (already used by root) should fail.
let code = run(&["groupmod", "-g", "0", "-P", prefix, "testgrp"]);
assert_eq!(
code, 4,
"duplicate GID without -o should exit 4 (GID_IN_USE)"
);
// With -o, it should succeed.
let code = run(&["groupmod", "-o", "-g", "0", "-P", prefix, "testgrp"]);
assert_eq!(code, 0, "duplicate GID with -o should exit 0");
let content = read_group(&dir);
assert!(
content.contains("testgrp:x:0:"),
"GID should be changed to 0 with -o, got: {content}"
);
}
#[test]
fn test_nonexistent_group_fails() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"root:x:0:\n",
"root:!::\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupmod", "-g", "5000", "-P", prefix, "nogroup"]);
assert_eq!(
code, 6,
"modifying a nonexistent group should exit 6 (GROUP_NOT_FOUND)"
);
}
#[test]
fn test_rename_preserves_members() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"devteam:x:1500:alice,bob\n",
"devteam:!::alice,bob\n",
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\nbob:x:1001:1001:Bob:/home/bob:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupmod", "-n", "engineering", "-P", prefix, "devteam"]);
assert_eq!(code, 0, "renaming group with members should exit 0");
let content = read_group(&dir);
assert!(
content.contains("engineering:x:1500:alice,bob"),
"members should be preserved after rename, got: {content}"
);
assert!(
!content.contains("devteam"),
"old group name should not remain"
);
}
#[test]
fn test_rename_updates_gshadow() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"mygrp:x:2000:user1\n",
"mygrp:!::user1\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupmod", "-n", "renamed", "-P", prefix, "mygrp"]);
assert_eq!(code, 0, "renaming should update gshadow");
let gs_content = read_gshadow(&dir);
assert!(
gs_content.contains("renamed:"),
"gshadow should reflect the new name, got: {gs_content}"
);
assert!(
!gs_content.contains("mygrp"),
"old name should not remain in gshadow"
);
}
#[test]
fn test_name_collision_fails() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"grp1:x:1000:\ngrp2:x:2000:\n",
"grp1:!::\ngrp2:!::\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&["groupmod", "-n", "grp2", "-P", prefix, "grp1"]);
assert_eq!(
code, 9,
"renaming to an existing group name should exit 9 (NAME_IN_USE)"
);
}
#[test]
fn test_change_gid_and_name_together() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"oldgrp:x:1000:member1\n",
"oldgrp:!::member1\n",
"root:x:0:0:root:/root:/bin/bash\n",
);
let prefix = dir.path().to_str().expect("temp dir path is valid UTF-8");
let code = run(&[
"groupmod", "-g", "7777", "-n", "freshgrp", "-P", prefix, "oldgrp",
]);
assert_eq!(code, 0, "changing both GID and name should exit 0");
let content = read_group(&dir);
assert!(
content.contains("freshgrp:x:7777:member1"),
"both GID and name should be updated, got: {content}"
);
assert!(
!content.contains("oldgrp"),
"old group name should not remain"
);
}
+213
View File
@@ -0,0 +1,213 @@
// 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 grpck gshadow
//! Integration tests for the `grpck` utility.
//!
//! Tests that require root are guarded by `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;
/// Skip the test when not running as root (euid != 0).
fn skip_unless_root() -> bool {
!nix::unistd::geteuid().is_root()
}
/// Run `uumain` with the given args, returning the exit code.
fn run(args: &[&str]) -> i32 {
let os_args: Vec<OsString> = args.iter().map(|s| (*s).into()).collect();
grpck::uumain(os_args.into_iter())
}
/// Helper to create a temp dir with group and gshadow files at known paths.
/// Returns (`TempDir`, `group_path_string`, `gshadow_path_string`).
fn setup_files(group_content: &str, gshadow_content: &str) -> (tempfile::TempDir, String, String) {
let dir = tempfile::tempdir().expect("failed to create temp dir");
let group_path = dir.path().join("group");
let gshadow_path = dir.path().join("gshadow");
std::fs::write(&group_path, group_content).expect("failed to write group file");
std::fs::write(&gshadow_path, gshadow_content).expect("failed to write gshadow file");
let gp = group_path.to_str().expect("non-utf8 path").to_owned();
let gsp = gshadow_path.to_str().expect("non-utf8 path").to_owned();
(dir, gp, gsp)
}
// ---------------------------------------------------------------------------
// Non-root tests -- exercise clap parsing and error paths
// ---------------------------------------------------------------------------
#[test]
fn test_help_exits_zero() {
let code = run(&["grpck", "--help"]);
assert_eq!(code, 0, "--help should exit 0");
}
#[test]
fn test_unknown_flag_exits_error() {
let code = run(&["grpck", "--bogus"]);
assert!(code != 0, "unknown flag should exit non-zero");
}
#[test]
fn test_read_only_mode() {
// -r with a nonexistent file still exits 3 (cant open), but -r is accepted.
let code = run(&["grpck", "-r", "/nonexistent/group"]);
assert_eq!(
code, 3,
"-r with nonexistent file should exit 3 (cant open)"
);
}
// ---------------------------------------------------------------------------
// Root-only tests -- exercise full checks via positional file arguments
// ---------------------------------------------------------------------------
#[test]
fn test_valid_files_exits_zero() {
if skip_unless_root() {
return;
}
let (_dir, gp, gsp) = setup_files("root:x:0:\nusers:x:1000:\n", "root:!::\nusers:!::\n");
let code = run(&["grpck", "-r", &gp, &gsp]);
assert_eq!(code, 0, "consistent group+gshadow should return 0");
}
#[test]
fn test_missing_gshadow_entry() {
if skip_unless_root() {
return;
}
let (_dir, gp, gsp) = setup_files(
"root:x:0:\nusers:x:1000:\n",
// gshadow only has root, not users.
"root:!::\n",
);
let code = run(&["grpck", "-r", &gp, &gsp]);
assert_eq!(
code, 2,
"group without gshadow entry should be detected (exit 2)"
);
}
#[test]
fn test_extra_gshadow_entry() {
if skip_unless_root() {
return;
}
let (_dir, gp, gsp) = setup_files(
"root:x:0:\n",
// gshadow has root + orphan (no matching group entry).
"root:!::\norphan:!::\n",
);
let code = run(&["grpck", "-r", &gp, &gsp]);
assert_eq!(
code, 2,
"gshadow without matching group entry should be detected (exit 2)"
);
}
#[test]
fn test_invalid_gid() {
if skip_unless_root() {
return;
}
let (_dir, gp, gsp) = setup_files(
// GID field is "abc" -- not a valid number.
"badgroup:x:abc:\n",
"",
);
let code = run(&["grpck", "-r", &gp, &gsp]);
assert_eq!(
code, 2,
"non-numeric GID should be detected as invalid (exit 2)"
);
}
#[test]
fn test_duplicate_group_name() {
if skip_unless_root() {
return;
}
let (_dir, gp, gsp) = setup_files("users:x:1000:\nusers:x:1001:\n", "users:!::\n");
let code = run(&["grpck", "-r", &gp, &gsp]);
assert_eq!(code, 2, "duplicate group name should be detected (exit 2)");
}
#[test]
fn test_empty_group_name() {
if skip_unless_root() {
return;
}
let (_dir, gp, gsp) = setup_files(
// Empty group name: line starts with ":"
":x:1000:\n",
"",
);
let code = run(&["grpck", "-r", &gp, &gsp]);
assert_eq!(
code, 2,
"empty group name should be detected as invalid (exit 2)"
);
}
#[test]
fn test_malformed_group_line() {
if skip_unless_root() {
return;
}
let (_dir, gp, gsp) = setup_files(
// Only 2 fields instead of 4.
"badentry:x\n",
"",
);
let code = run(&["grpck", "-r", &gp, &gsp]);
assert_eq!(code, 2, "malformed group line should be detected (exit 2)");
}
#[test]
fn test_nonexistent_group_exits_cant_open() {
let code = run(&["grpck", "-r", "/nonexistent/group"]);
assert_eq!(
code, 3,
"nonexistent group file should return exit code 3 (cant open)"
);
}
#[test]
fn test_valid_group_without_gshadow_file() {
if skip_unless_root() {
return;
}
// When gshadow file does not exist, grpck should succeed if group is valid.
let dir = tempfile::tempdir().expect("failed to create temp dir");
let group_path = dir.path().join("group");
let gshadow_path = dir.path().join("gshadow_nonexistent");
std::fs::write(&group_path, "root:x:0:\nusers:x:1000:\n").expect("write group");
let code = run(&[
"grpck",
"-r",
group_path.to_str().expect("non-utf8 path"),
gshadow_path.to_str().expect("non-utf8 path"),
]);
assert_eq!(code, 0, "valid group without gshadow file should return 0");
}
+382
View File
@@ -0,0 +1,382 @@
// 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 pwck nologin gecos
//! Integration tests for the `pwck` utility.
//!
//! Tests that require root are guarded by `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;
/// Skip the test when not running as root (euid != 0).
fn skip_unless_root() -> bool {
!nix::unistd::geteuid().is_root()
}
/// Run `uumain` with the given args, returning the exit code.
fn run(args: &[&str]) -> i32 {
let os_args: Vec<OsString> = args.iter().map(|s| (*s).into()).collect();
pwck::uumain(os_args.into_iter())
}
/// Helper to create a temp dir with `etc/passwd`, `etc/shadow`, `etc/group`,
/// and `etc/shells` files, plus the required home directory.
fn setup_root(
passwd_content: &str,
shadow_content: &str,
group_content: &str,
) -> 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("passwd"), passwd_content).expect("failed to write passwd file");
std::fs::write(etc.join("shadow"), shadow_content).expect("failed to write shadow file");
std::fs::write(etc.join("group"), group_content).expect("failed to write group file");
std::fs::write(etc.join("shells"), "/bin/bash\n/bin/sh\n").expect("failed to write shells");
dir
}
// ---------------------------------------------------------------------------
// Non-root tests -- exercise clap parsing and error paths
// ---------------------------------------------------------------------------
#[test]
fn test_help_exits_zero() {
let code = run(&["pwck", "--help"]);
assert_eq!(code, 0, "--help should exit 0");
}
#[test]
fn test_unknown_flag_exits_error() {
let code = run(&["pwck", "--bogus"]);
assert!(code != 0, "unknown flag should exit non-zero");
}
#[test]
fn test_read_only_mode() {
// -r with a nonexistent file still exits 3 (cant open), but -r is accepted.
let code = run(&["pwck", "-r", "/nonexistent/passwd"]);
assert_eq!(
code, 3,
"-r with nonexistent file should exit 3 (cant open)"
);
}
// ---------------------------------------------------------------------------
// Root-only tests -- exercise full checks via -R/--root with temp dirs
// ---------------------------------------------------------------------------
#[test]
fn test_valid_files_exits_zero() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"root:x:0:0:root:/root:/bin/bash\n",
"root:$6$hash:19000:0:99999:7:::\n",
"root:x:0:\n",
);
// Create the home directory that pwck checks for.
std::fs::create_dir_all(dir.path().join("root")).expect("failed to create root home");
let passwd_path = dir.path().join("etc/passwd");
let shadow_path = dir.path().join("etc/shadow");
let code = run(&[
"pwck",
"-r",
"-R",
dir.path().to_str().expect("non-utf8 path"),
passwd_path.to_str().expect("non-utf8 path"),
shadow_path.to_str().expect("non-utf8 path"),
]);
assert_eq!(code, 0, "consistent passwd+shadow should return 0");
}
#[test]
fn test_missing_shadow_entry() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"root:x:0:0:root:/root:/bin/bash\nalice:x:1000:1000::/home/alice:/bin/bash\n",
// Shadow only has root, not alice.
"root:$6$hash:19000:0:99999:7:::\n",
"root:x:0:\nusers:x:1000:\n",
);
std::fs::create_dir_all(dir.path().join("root")).expect("mkdir root");
std::fs::create_dir_all(dir.path().join("home/alice")).expect("mkdir alice home");
let passwd_path = dir.path().join("etc/passwd");
let shadow_path = dir.path().join("etc/shadow");
let code = run(&[
"pwck",
"-r",
"-R",
dir.path().to_str().expect("non-utf8 path"),
passwd_path.to_str().expect("non-utf8 path"),
shadow_path.to_str().expect("non-utf8 path"),
]);
assert_eq!(
code, 2,
"user in passwd but not shadow should be detected (exit 2)"
);
}
#[test]
fn test_extra_shadow_entry() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"root:x:0:0:root:/root:/bin/bash\n",
// Shadow has root + ghost (no matching passwd entry).
"root:$6$hash:19000:0:99999:7:::\nghost:$6$hash:19000:0:99999:7:::\n",
"root:x:0:\n",
);
std::fs::create_dir_all(dir.path().join("root")).expect("mkdir root");
let passwd_path = dir.path().join("etc/passwd");
let shadow_path = dir.path().join("etc/shadow");
let code = run(&[
"pwck",
"-r",
"-R",
dir.path().to_str().expect("non-utf8 path"),
passwd_path.to_str().expect("non-utf8 path"),
shadow_path.to_str().expect("non-utf8 path"),
]);
assert_eq!(
code, 2,
"entry in shadow but not passwd should be detected (exit 2)"
);
}
#[test]
fn test_invalid_uid() {
if skip_unless_root() {
return;
}
let dir = setup_root(
// UID field is "abc" -- not a valid number.
"baduser:x:abc:0:bad:/home/bad:/bin/bash\n",
"",
"root:x:0:\n",
);
let passwd_path = dir.path().join("etc/passwd");
let shadow_path = dir.path().join("etc/shadow");
let code = run(&[
"pwck",
"-r",
"-R",
dir.path().to_str().expect("non-utf8 path"),
passwd_path.to_str().expect("non-utf8 path"),
shadow_path.to_str().expect("non-utf8 path"),
]);
assert_eq!(
code, 2,
"non-numeric UID should be detected as invalid (exit 2)"
);
}
#[test]
fn test_invalid_gid() {
if skip_unless_root() {
return;
}
let dir = setup_root(
// GID field is "xyz" -- not a valid number.
"baduser:x:1000:xyz:bad:/home/bad:/bin/bash\n",
"",
"root:x:0:\n",
);
let passwd_path = dir.path().join("etc/passwd");
let shadow_path = dir.path().join("etc/shadow");
let code = run(&[
"pwck",
"-r",
"-R",
dir.path().to_str().expect("non-utf8 path"),
passwd_path.to_str().expect("non-utf8 path"),
shadow_path.to_str().expect("non-utf8 path"),
]);
assert_eq!(
code, 2,
"non-numeric GID should be detected as invalid (exit 2)"
);
}
#[test]
fn test_duplicate_username() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"alice:x:1000:1000::/home/alice:/bin/bash\nalice:x:1001:1000::/home/alice2:/bin/bash\n",
"alice:$6$hash:19000:0:99999:7:::\n",
"users:x:1000:\n",
);
std::fs::create_dir_all(dir.path().join("home/alice")).expect("mkdir alice home");
std::fs::create_dir_all(dir.path().join("home/alice2")).expect("mkdir alice2 home");
let passwd_path = dir.path().join("etc/passwd");
let shadow_path = dir.path().join("etc/shadow");
let code = run(&[
"pwck",
"-r",
"-R",
dir.path().to_str().expect("non-utf8 path"),
passwd_path.to_str().expect("non-utf8 path"),
shadow_path.to_str().expect("non-utf8 path"),
]);
assert_eq!(code, 2, "duplicate username should be detected (exit 2)");
}
#[test]
fn test_duplicate_uid() {
if skip_unless_root() {
return;
}
// Duplicate UIDs are detected by pwck. Two different users with same UID.
let dir = setup_root(
"alice:x:1000:1000::/home/alice:/bin/bash\nbob:x:1000:1000::/home/bob:/bin/bash\n",
"alice:$6$hash:19000:0:99999:7:::\nbob:$6$hash:19000:0:99999:7:::\n",
"users:x:1000:\n",
);
std::fs::create_dir_all(dir.path().join("home/alice")).expect("mkdir alice home");
std::fs::create_dir_all(dir.path().join("home/bob")).expect("mkdir bob home");
let passwd_path = dir.path().join("etc/passwd");
let shadow_path = dir.path().join("etc/shadow");
// Duplicate UIDs produce warnings (not errors) in pwck -- verify it does
// not crash and completes. Exit code 0 is acceptable since duplicate UIDs
// are only a warning in most pwck implementations.
let code = run(&[
"pwck",
"-r",
"-R",
dir.path().to_str().expect("non-utf8 path"),
passwd_path.to_str().expect("non-utf8 path"),
shadow_path.to_str().expect("non-utf8 path"),
]);
// Duplicate UIDs are a warning, not an error -- exit 0 is valid.
assert!(
code == 0 || code == 2,
"duplicate UID should either warn (exit 0) or error (exit 2), got {code}"
);
}
#[test]
fn test_empty_username() {
if skip_unless_root() {
return;
}
let dir = setup_root(
// Empty username: line starts with ":"
":x:1000:1000::/home/empty:/bin/bash\n",
"",
"users:x:1000:\n",
);
let passwd_path = dir.path().join("etc/passwd");
let shadow_path = dir.path().join("etc/shadow");
let code = run(&[
"pwck",
"-r",
"-R",
dir.path().to_str().expect("non-utf8 path"),
passwd_path.to_str().expect("non-utf8 path"),
shadow_path.to_str().expect("non-utf8 path"),
]);
assert_eq!(
code, 2,
"empty username should be detected as invalid (exit 2)"
);
}
#[test]
fn test_missing_home_dir() {
if skip_unless_root() {
return;
}
let dir = setup_root(
"alice:x:1000:1000::/home/nonexistent:/bin/bash\n",
"alice:$6$hash:19000:0:99999:7:::\n",
"users:x:1000:\n",
);
// Deliberately do NOT create /home/nonexistent.
let passwd_path = dir.path().join("etc/passwd");
let shadow_path = dir.path().join("etc/shadow");
let code = run(&[
"pwck",
"-r",
"-R",
dir.path().to_str().expect("non-utf8 path"),
passwd_path.to_str().expect("non-utf8 path"),
shadow_path.to_str().expect("non-utf8 path"),
]);
assert_eq!(
code, 2,
"missing home directory should be detected (exit 2)"
);
}
#[test]
fn test_malformed_passwd_line() {
if skip_unless_root() {
return;
}
let dir = setup_root(
// Only 4 fields instead of 7.
"root:x:0:0\n",
"",
"root:x:0:\n",
);
let passwd_path = dir.path().join("etc/passwd");
let shadow_path = dir.path().join("etc/shadow");
let code = run(&[
"pwck",
"-r",
"-R",
dir.path().to_str().expect("non-utf8 path"),
passwd_path.to_str().expect("non-utf8 path"),
shadow_path.to_str().expect("non-utf8 path"),
]);
assert_eq!(code, 2, "malformed passwd line should be detected (exit 2)");
}
#[test]
fn test_nonexistent_passwd_exits_cant_open() {
let code = run(&["pwck", "-r", "/nonexistent/passwd"]);
assert_eq!(
code, 3,
"nonexistent passwd file should return exit code 3 (cant open)"
);
}
+370
View File
@@ -0,0 +1,370 @@
// 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 useradd nologin skel gecos gshadow
//! Integration tests for the `useradd` utility.
//!
//! Tests that require root are guarded by `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;
/// Skip the test when not running as root (uid != 0).
fn skip_unless_root() -> bool {
!nix::unistd::getuid().is_root()
}
/// Run `uumain` with the given args, returning the exit code.
fn run(args: &[&str]) -> i32 {
let os_args: Vec<OsString> = args.iter().map(|s| (*s).into()).collect();
useradd::uumain(os_args.into_iter())
}
/// Helper to create a temp dir with the basic files useradd needs:
/// - etc/passwd (with root entry)
/// - etc/shadow (with root entry)
/// - etc/group (with root group)
/// - etc/login.defs (with UID/GID ranges)
fn setup_root_dir() -> 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("passwd"), "root:x:0:0:root:/root:/bin/bash\n")
.expect("failed to write passwd file");
std::fs::write(etc.join("shadow"), "root:$6$hash:19500:0:99999:7:::\n")
.expect("failed to write shadow file");
std::fs::write(etc.join("group"), "root:x:0:\n").expect("failed to write group file");
std::fs::write(
etc.join("login.defs"),
"\
UID_MIN 1000\n\
UID_MAX 60000\n\
SYS_UID_MIN 100\n\
SYS_UID_MAX 999\n\
GID_MIN 1000\n\
GID_MAX 60000\n\
SYS_GID_MIN 100\n\
SYS_GID_MAX 999\n\
USERGROUPS_ENAB yes\n\
CREATE_HOME no\n\
",
)
.expect("failed to write login.defs");
dir
}
/// Run `uumain` with a `--root` dir prepended to the args.
fn run_with_root(dir: &tempfile::TempDir, extra_args: &[&str]) -> i32 {
let root_str = dir.path().to_str().expect("non-UTF-8 temp path");
let mut args = vec!["useradd", "-R", root_str];
args.extend_from_slice(extra_args);
run(&args)
}
/// Read the passwd file content back from a root dir.
fn read_passwd(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/passwd")).expect("failed to read passwd file")
}
/// Read the shadow file content back from a root dir.
fn read_shadow(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/shadow")).expect("failed to read shadow file")
}
/// Read the group file content back from a root dir.
fn read_group(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/group")).expect("failed to read group file")
}
// ---------------------------------------------------------------------------
// Non-root tests -- exercise clap parsing and error paths
// ---------------------------------------------------------------------------
#[test]
fn test_help_exits_zero() {
let code = run(&["useradd", "--help"]);
assert_eq!(code, 0, "--help should exit 0");
}
#[test]
fn test_unknown_flag_exits_error() {
let code = run(&["useradd", "--bogus"]);
assert_eq!(code, 2, "unknown flag should exit 2");
}
#[test]
fn test_missing_login_exits_error() {
let code = run(&["useradd"]);
assert_eq!(code, 2, "missing LOGIN should exit 2");
}
#[test]
fn test_defaults_flag() {
// -D should print defaults; requires root for login.defs read on real system,
// but we only care that clap parses it without error. If not root, we expect
// exit 1 (permission denied).
let code = run(&["useradd", "-D"]);
if nix::unistd::getuid().is_root() {
assert_eq!(code, 0, "-D should exit 0 when root");
} else {
assert_eq!(code, 1, "-D should exit 1 when not root");
}
}
#[test]
fn test_conflicting_create_no_create_home() {
let code = run(&["useradd", "-m", "-M", "testuser"]);
assert_eq!(code, 2, "-m -M conflict should exit 2");
}
#[test]
fn test_conflicting_user_group_no_user_group() {
let code = run(&["useradd", "-U", "-N", "testuser"]);
assert_eq!(code, 2, "-U -N conflict should exit 2");
}
// ---------------------------------------------------------------------------
// Root-only tests -- exercise real operations via --root
// ---------------------------------------------------------------------------
#[test]
fn test_create_user_basic() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["-N", "testuser"]);
assert_eq!(code, 0, "basic useradd should exit 0");
let passwd = read_passwd(&dir);
assert!(
passwd.contains("testuser:"),
"passwd should contain testuser entry, got: {passwd}"
);
let shadow = read_shadow(&dir);
assert!(
shadow.contains("testuser:"),
"shadow should contain testuser entry, got: {shadow}"
);
}
#[test]
fn test_create_user_with_home() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
// Create skel directory so -m does not fail on missing skel.
let skel = dir.path().join("etc/skel");
std::fs::create_dir_all(&skel).expect("failed to create skel dir");
// Create /home base directory so create_dir (not create_dir_all) succeeds.
let home_base = dir.path().join("home");
std::fs::create_dir_all(&home_base).expect("failed to create home base dir");
let code = run_with_root(&dir, &["-m", "-N", "homeuser"]);
assert_eq!(code, 0, "useradd -m should exit 0");
let passwd = read_passwd(&dir);
assert!(
passwd.contains("homeuser:"),
"passwd should contain homeuser, got: {passwd}"
);
// Verify home directory was created.
let home_path = dir.path().join("home/homeuser");
assert!(
home_path.exists(),
"home directory should have been created at {}",
home_path.display()
);
}
#[test]
fn test_create_user_with_uid() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["-u", "5000", "-N", "uiduser"]);
assert_eq!(code, 0, "useradd -u 5000 should exit 0");
let passwd = read_passwd(&dir);
assert!(
passwd.contains("uiduser:x:5000:"),
"passwd should contain UID 5000, got: {passwd}"
);
}
#[test]
fn test_create_user_with_shell() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["-s", "/bin/zsh", "-N", "shelluser"]);
assert_eq!(code, 0, "useradd -s /bin/zsh should exit 0");
let passwd = read_passwd(&dir);
assert!(
passwd.contains(":/bin/zsh\n") || passwd.contains(":/bin/zsh"),
"passwd should contain /bin/zsh as shell, got: {passwd}"
);
}
#[test]
fn test_create_user_system() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["-r", "-N", "sysuser"]);
assert_eq!(code, 0, "useradd -r should exit 0");
let passwd = read_passwd(&dir);
// Parse the UID from the passwd entry for sysuser.
let sysuser_line = passwd
.lines()
.find(|l| l.starts_with("sysuser:"))
.expect("sysuser entry should exist in passwd");
let fields: Vec<&str> = sysuser_line.split(':').collect();
let uid: u32 = fields[2].parse().expect("UID should be a valid number");
assert!(
(100..=999).contains(&uid),
"system user UID should be in range 100-999, got: {uid}"
);
}
#[test]
fn test_create_user_with_group() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
// Use numeric GID to avoid needing the group to exist by name.
let code = run_with_root(&dir, &["-g", "1000", "-N", "grpuser"]);
assert_eq!(code, 0, "useradd -g 1000 should exit 0");
let passwd = read_passwd(&dir);
// The GID (4th field) should be 1000.
let grpuser_line = passwd
.lines()
.find(|l| l.starts_with("grpuser:"))
.expect("grpuser entry should exist in passwd");
let fields: Vec<&str> = grpuser_line.split(':').collect();
assert_eq!(
fields[3], "1000",
"primary GID should be 1000, got: {}",
fields[3]
);
}
#[test]
fn test_duplicate_user_fails() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
// First creation should succeed.
let code = run_with_root(&dir, &["-N", "dupuser"]);
assert_eq!(code, 0, "first useradd should succeed");
// Second creation with same name should fail (exit 9).
let code = run_with_root(&dir, &["-N", "dupuser"]);
assert_eq!(code, 9, "duplicate user should exit 9");
}
#[test]
fn test_create_user_with_comment() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["-c", "Test User", "-N", "commentuser"]);
assert_eq!(code, 0, "useradd -c should exit 0");
let passwd = read_passwd(&dir);
assert!(
passwd.contains("Test User"),
"GECOS should contain comment, got: {passwd}"
);
}
#[test]
fn test_create_user_creates_user_group() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
// Default behavior: create a user group with same name.
let code = run_with_root(&dir, &["grpuser2"]);
assert_eq!(code, 0, "useradd with user group should exit 0");
let group = read_group(&dir);
assert!(
group.contains("grpuser2:"),
"group file should contain user group entry, got: {group}"
);
}
#[test]
fn test_create_user_preserves_existing_entries() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["-N", "newuser"]);
assert_eq!(code, 0, "useradd should succeed");
let passwd = read_passwd(&dir);
assert!(
passwd.contains("root:x:0:0:root:/root:/bin/bash"),
"root entry should be preserved, got: {passwd}"
);
assert!(
passwd.contains("newuser:"),
"newuser entry should be added, got: {passwd}"
);
let shadow = read_shadow(&dir);
assert!(
shadow.contains("root:$6$hash:19500:0:99999:7:::"),
"root shadow entry should be preserved, got: {shadow}"
);
}
#[test]
fn test_create_user_with_home_dir_flag() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["-d", "/custom/home", "-N", "customhome"]);
assert_eq!(code, 0, "useradd -d should exit 0");
let passwd = read_passwd(&dir);
assert!(
passwd.contains(":/custom/home:"),
"passwd should contain custom home path, got: {passwd}"
);
}
+316
View File
@@ -0,0 +1,316 @@
// 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 userdel gshadow
//! Integration tests for the `userdel` utility.
//!
//! Tests that require root are guarded by `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;
/// Skip the test when not running as root (uid != 0).
fn skip_unless_root() -> bool {
!nix::unistd::getuid().is_root()
}
/// Run `uumain` with the given args, returning the exit code.
fn run(args: &[&str]) -> i32 {
let os_args: Vec<OsString> = args.iter().map(|s| (*s).into()).collect();
userdel::uumain(os_args.into_iter())
}
/// Helper to create a temp dir with pre-populated files for userdel tests:
/// - etc/passwd with root + testuser
/// - etc/shadow with root + testuser
/// - etc/group with root group + a group containing testuser as member
fn setup_root_dir() -> 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("passwd"),
"\
root:x:0:0:root:/root:/bin/bash\n\
testuser:x:1000:1000:Test User:/home/testuser:/bin/bash\n\
otheruser:x:1001:1001:Other User:/home/otheruser:/bin/bash\n",
)
.expect("failed to write passwd file");
std::fs::write(
etc.join("shadow"),
"\
root:$6$roothash:19500:0:99999:7:::\n\
testuser:$6$testhash:19500:0:99999:7:::\n\
otheruser:$6$otherhash:19500:0:99999:7:::\n",
)
.expect("failed to write shadow file");
std::fs::write(
etc.join("group"),
"\
root:x:0:\n\
testgroup:x:1000:testuser\n\
othergroup:x:1001:otheruser\n\
shared:x:1002:testuser,otheruser\n",
)
.expect("failed to write group file");
dir
}
/// Run `uumain` with a `--root` dir prepended to the args.
fn run_with_root(dir: &tempfile::TempDir, extra_args: &[&str]) -> i32 {
let root_str = dir.path().to_str().expect("non-UTF-8 temp path");
let mut args = vec!["userdel", "-R", root_str];
args.extend_from_slice(extra_args);
run(&args)
}
/// Read the passwd file content back from a root dir.
fn read_passwd(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/passwd")).expect("failed to read passwd file")
}
/// Read the shadow file content back from a root dir.
fn read_shadow(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/shadow")).expect("failed to read shadow file")
}
/// Read the group file content back from a root dir.
fn read_group(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/group")).expect("failed to read group file")
}
// ---------------------------------------------------------------------------
// Non-root tests -- exercise clap parsing and error paths
// ---------------------------------------------------------------------------
#[test]
fn test_help_exits_zero() {
let code = run(&["userdel", "--help"]);
assert_eq!(code, 0, "--help should exit 0");
}
#[test]
fn test_unknown_flag_exits_error() {
let code = run(&["userdel", "--bogus"]);
assert_eq!(code, 2, "unknown flag should exit 2");
}
#[test]
fn test_missing_login_exits_error() {
let code = run(&["userdel"]);
assert_eq!(code, 2, "missing LOGIN should exit 2");
}
// ---------------------------------------------------------------------------
// Root-only tests -- exercise real operations via --root
// ---------------------------------------------------------------------------
#[test]
fn test_delete_user_basic() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["testuser"]);
assert_eq!(code, 0, "userdel testuser should exit 0");
let passwd = read_passwd(&dir);
assert!(
!passwd.contains("testuser:"),
"passwd should not contain testuser after deletion, got: {passwd}"
);
let shadow = read_shadow(&dir);
assert!(
!shadow.contains("testuser:"),
"shadow should not contain testuser after deletion, got: {shadow}"
);
}
#[test]
fn test_delete_user_remove_home() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
// Create the home directory that userdel -r should remove.
let home_path = dir.path().join("home/testuser");
std::fs::create_dir_all(&home_path).expect("failed to create home dir");
std::fs::write(home_path.join("somefile.txt"), "content")
.expect("failed to write file in home");
// Update passwd to point home to the temp dir path.
let passwd_path = dir.path().join("etc/passwd");
let home_str = "/home/testuser";
let passwd_content = format!(
"\
root:x:0:0:root:/root:/bin/bash\n\
testuser:x:1000:1000:Test User:{home_str}:/bin/bash\n\
otheruser:x:1001:1001:Other User:/home/otheruser:/bin/bash\n"
);
std::fs::write(&passwd_path, &passwd_content).expect("failed to rewrite passwd");
let code = run_with_root(&dir, &["-r", "testuser"]);
assert_eq!(code, 0, "userdel -r testuser should exit 0");
let passwd = read_passwd(&dir);
assert!(
!passwd.contains("testuser:"),
"passwd should not contain testuser, got: {passwd}"
);
// The home directory should be removed.
assert!(
!home_path.exists(),
"home directory should have been removed at {}",
home_path.display()
);
}
#[test]
fn test_delete_nonexistent_user_fails() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["nouser"]);
assert_ne!(code, 0, "deleting nonexistent user should fail");
}
#[test]
fn test_delete_user_preserves_others() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["testuser"]);
assert_eq!(code, 0, "userdel testuser should exit 0");
let passwd = read_passwd(&dir);
assert!(
passwd.contains("root:x:0:0:root:/root:/bin/bash"),
"root entry should be preserved, got: {passwd}"
);
assert!(
passwd.contains("otheruser:x:1001:1001:Other User:/home/otheruser:/bin/bash"),
"otheruser entry should be preserved, got: {passwd}"
);
assert!(
!passwd.contains("testuser:"),
"testuser should be removed, got: {passwd}"
);
let shadow = read_shadow(&dir);
assert!(
shadow.contains("root:"),
"root shadow entry should be preserved, got: {shadow}"
);
assert!(
shadow.contains("otheruser:"),
"otheruser shadow entry should be preserved, got: {shadow}"
);
}
#[test]
fn test_delete_user_removes_group_membership() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["testuser"]);
assert_eq!(code, 0, "userdel testuser should exit 0");
let group = read_group(&dir);
// testuser should be removed from all group membership lists.
assert!(
!group.contains("testuser"),
"testuser should be removed from group membership lists, got: {group}"
);
// otheruser should still be a member of shared group.
assert!(
group.contains("otheruser"),
"otheruser should remain in group membership lists, got: {group}"
);
}
#[test]
fn test_delete_user_shadow_entry_removed() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["testuser"]);
assert_eq!(code, 0, "userdel should exit 0");
let shadow = read_shadow(&dir);
assert!(
!shadow.contains("testuser:"),
"shadow entry should be removed, got: {shadow}"
);
assert!(
shadow.contains("otheruser:$6$otherhash:"),
"otheruser shadow entry should be intact, got: {shadow}"
);
}
#[test]
fn test_delete_user_force_flag_accepted() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
// -f is accepted without error (force removal even if user is logged in).
let code = run_with_root(&dir, &["-f", "testuser"]);
assert_eq!(code, 0, "userdel -f should exit 0");
let passwd = read_passwd(&dir);
assert!(
!passwd.contains("testuser:"),
"testuser should be deleted with -f, got: {passwd}"
);
}
#[test]
fn test_delete_multiple_users_sequentially() {
if skip_unless_root() {
return;
}
let dir = setup_root_dir();
let code = run_with_root(&dir, &["testuser"]);
assert_eq!(code, 0, "first userdel should succeed");
let code = run_with_root(&dir, &["otheruser"]);
assert_eq!(code, 0, "second userdel should succeed");
let passwd = read_passwd(&dir);
assert!(
!passwd.contains("testuser:"),
"testuser should be gone, got: {passwd}"
);
assert!(
!passwd.contains("otheruser:"),
"otheruser should be gone, got: {passwd}"
);
assert!(
passwd.contains("root:"),
"root should remain, got: {passwd}"
);
}
+371
View File
@@ -0,0 +1,371 @@
// 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 usermod gecos gshadow
//! Integration tests for the `usermod` utility.
//!
//! Tests that require root are guarded by `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;
/// Skip the test when not running as root (euid != 0).
fn skip_unless_root() -> bool {
!nix::unistd::geteuid().is_root()
}
/// Run `uumain` with the given args, returning the exit code.
fn run(args: &[&str]) -> i32 {
let os_args: Vec<OsString> = args.iter().map(|s| (*s).into()).collect();
usermod::uumain(os_args.into_iter())
}
/// Helper to create a temp dir with `etc/passwd`, `etc/shadow`, `etc/group`,
/// `etc/gshadow`, and `etc/login.defs`.
fn setup_prefix(
passwd_content: &str,
shadow_content: &str,
group_content: &str,
) -> 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("passwd"), passwd_content).expect("failed to write passwd file");
std::fs::write(etc.join("shadow"), shadow_content).expect("failed to write shadow file");
std::fs::write(etc.join("group"), group_content).expect("failed to write group file");
std::fs::write(etc.join("gshadow"), "").expect("failed to write gshadow file");
std::fs::write(etc.join("login.defs"), "").expect("failed to write login.defs file");
dir
}
/// Read the passwd file content back from a prefix dir.
fn read_passwd(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/passwd")).expect("failed to read passwd file")
}
/// Read the shadow file content back from a prefix dir.
fn read_shadow(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/shadow")).expect("failed to read shadow file")
}
/// Read the group file content back from a prefix dir.
fn read_group(dir: &tempfile::TempDir) -> String {
std::fs::read_to_string(dir.path().join("etc/group")).expect("failed to read group file")
}
/// 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!["usermod", "-P", prefix_str];
args.extend_from_slice(extra_args);
run(&args)
}
// ---------------------------------------------------------------------------
// Non-root tests -- exercise clap parsing and error paths
// ---------------------------------------------------------------------------
#[test]
fn test_help_exits_zero() {
let code = run(&["usermod", "--help"]);
assert_eq!(code, 0, "--help should exit 0");
}
#[test]
fn test_unknown_flag_exits_error() {
let code = run(&["usermod", "--bogus", "someuser"]);
assert_eq!(code, 2, "unknown flag should exit 2");
}
#[test]
fn test_missing_login_exits_error() {
let code = run(&["usermod"]);
assert_eq!(code, 2, "missing LOGIN should exit 2");
}
#[test]
fn test_lock_unlock_conflict() {
// -L and -U conflict; clap reports ArgumentConflict which maps to exit 2.
let code = run(&["usermod", "-L", "-U", "someuser"]);
assert_eq!(code, 2, "conflicting -L -U flags should exit 2");
}
// ---------------------------------------------------------------------------
// Root-only tests -- exercise real operations via --prefix
// ---------------------------------------------------------------------------
#[test]
fn test_change_shell() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Test User:/home/testuser:/bin/bash\n",
"testuser:$6$hash:19500:0:99999:7:::\n",
"testuser:x:1000:\n",
);
let code = run_with_prefix(&dir, &["-s", "/bin/zsh", "testuser"]);
assert_eq!(code, 0, "changing shell should succeed");
let content = read_passwd(&dir);
assert!(
content.contains(":/bin/zsh"),
"shell should be /bin/zsh, got: {content}"
);
// Verify the rest of the entry is intact.
assert!(
content.contains("testuser:x:1000:1000:Test User:/home/testuser:/bin/zsh"),
"full passwd entry should be correct, got: {content}"
);
}
#[test]
fn test_change_comment() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Old Name:/home/testuser:/bin/bash\n",
"testuser:$6$hash:19500:0:99999:7:::\n",
"testuser:x:1000:\n",
);
let code = run_with_prefix(&dir, &["-c", "New Name", "testuser"]);
assert_eq!(code, 0, "changing GECOS comment should succeed");
let content = read_passwd(&dir);
assert!(
content.contains(":New Name:"),
"GECOS should be 'New Name', got: {content}"
);
}
#[test]
fn test_change_home() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
"testuser:$6$hash:19500:0:99999:7:::\n",
"testuser:x:1000:\n",
);
let code = run_with_prefix(&dir, &["-d", "/new/home", "testuser"]);
assert_eq!(code, 0, "changing home directory should succeed");
let content = read_passwd(&dir);
assert!(
content.contains(":/new/home:"),
"home should be /new/home, got: {content}"
);
}
#[test]
fn test_change_uid() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
"testuser:$6$hash:19500:0:99999:7:::\n",
"testuser:x:1000:\n",
);
let code = run_with_prefix(&dir, &["-u", "9999", "testuser"]);
assert_eq!(code, 0, "changing UID should succeed");
let content = read_passwd(&dir);
assert!(
content.contains("testuser:x:9999:"),
"UID should be 9999, got: {content}"
);
}
#[test]
fn test_add_to_groups() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
"testuser:$6$hash:19500:0:99999:7:::\n",
"testuser:x:1000:\ngroup1:x:2001:\ngroup2:x:2002:\n",
);
let code = run_with_prefix(&dir, &["-G", "group1,group2", "testuser"]);
assert_eq!(code, 0, "adding to supplementary groups should succeed");
let content = read_group(&dir);
assert!(
content.contains("group1:x:2001:testuser"),
"testuser should be in group1, got: {content}"
);
assert!(
content.contains("group2:x:2002:testuser"),
"testuser should be in group2, got: {content}"
);
}
#[test]
fn test_lock_user() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
"testuser:$6$hash:19500:0:99999:7:::\n",
"testuser:x:1000:\n",
);
let code = run_with_prefix(&dir, &["-L", "testuser"]);
assert_eq!(code, 0, "locking user should succeed");
let content = read_shadow(&dir);
assert!(
content.contains("testuser:!$6$hash:"),
"after lock, expected '!' prefix on password, got: {content}"
);
}
#[test]
fn test_unlock_user() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
"testuser:!$6$hash:19500:0:99999:7:::\n",
"testuser:x:1000:\n",
);
let code = run_with_prefix(&dir, &["-U", "testuser"]);
assert_eq!(code, 0, "unlocking user should succeed");
let content = read_shadow(&dir);
assert!(
content.contains("testuser:$6$hash:"),
"after unlock, expected no '!' prefix, got: {content}"
);
}
#[test]
fn test_nonexistent_user_fails() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Test:/home/testuser:/bin/bash\n",
"testuser:$6$hash:19500:0:99999:7:::\n",
"testuser:x:1000:\n",
);
let code = run_with_prefix(&dir, &["-s", "/bin/zsh", "nosuchuser"]);
assert_eq!(code, 6, "modifying nonexistent user should exit 6");
}
#[test]
fn test_multiple_modifications_combined() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"testuser:x:1000:1000:Old Name:/home/testuser:/bin/bash\n",
"testuser:$6$hash:19500:0:99999:7:::\n",
"testuser:x:1000:\n",
);
let code = run_with_prefix(
&dir,
&[
"-c",
"New Name",
"-s",
"/bin/zsh",
"-d",
"/new/home",
"testuser",
],
);
assert_eq!(code, 0, "combined modifications should succeed");
let content = read_passwd(&dir);
assert!(
content.contains(":New Name:"),
"GECOS should be updated, got: {content}"
);
assert!(
content.contains(":/bin/zsh"),
"shell should be updated, got: {content}"
);
assert!(
content.contains(":/new/home:"),
"home should be updated, got: {content}"
);
}
#[test]
fn test_other_users_unchanged() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"alice:x:1001:1001:Alice:/home/alice:/bin/bash\n\
bob:x:1002:1002:Bob:/home/bob:/bin/bash\n\
charlie:x:1003:1003:Charlie:/home/charlie:/bin/bash\n",
"alice:$6$ahash:19500:0:99999:7:::\n\
bob:$6$bhash:19500:0:99999:7:::\n\
charlie:$6$chash:19500:0:99999:7:::\n",
"alice:x:1001:\nbob:x:1002:\ncharlie:x:1003:\n",
);
let code = run_with_prefix(&dir, &["-c", "Robert", "bob"]);
assert_eq!(code, 0);
let content = read_passwd(&dir);
assert!(
content.contains("alice:x:1001:1001:Alice:/home/alice:/bin/bash"),
"alice should be unchanged, got: {content}"
);
assert!(
content.contains("charlie:x:1003:1003:Charlie:/home/charlie:/bin/bash"),
"charlie should be unchanged, got: {content}"
);
assert!(
content.contains(":Robert:"),
"bob's GECOS should be updated, got: {content}"
);
}
#[test]
fn test_uid_collision_fails() {
if skip_unless_root() {
return;
}
let dir = setup_prefix(
"alice:x:1001:1001:Alice:/home/alice:/bin/bash\n\
bob:x:1002:1002:Bob:/home/bob:/bin/bash\n",
"alice:$6$ahash:19500:0:99999:7:::\n\
bob:$6$bhash:19500:0:99999:7:::\n",
"alice:x:1001:\nbob:x:1002:\n",
);
// Try to set bob's UID to alice's UID -- should fail.
let code = run_with_prefix(&dir, &["-u", "1001", "bob"]);
assert_eq!(code, 4, "UID collision should exit 4");
}