mirror of
https://github.com/uutils/shadow.git
synced 2026-06-10 16:14:57 -07:00
Merge pull request #107 from shadow-utils-rs/feat/integration-tests-release
tests: integration tests for all 14 tools + CHANGELOG for v0.1.0
This commit is contained in:
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user