From 7bd47ff3d0490ae1bcdaaf458a940bf623a68a36 Mon Sep 17 00:00:00 2001 From: Pierre Warnier Date: Tue, 24 Mar 2026 15:59:04 +0100 Subject: [PATCH] tests: add integration tests for all 14 tools, add CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 45 ++++ Cargo.toml | 41 ++++ tests/by-util/test_groupadd.rs | 239 +++++++++++++++++++++ tests/by-util/test_groupdel.rs | 255 ++++++++++++++++++++++ tests/by-util/test_groupmod.rs | 280 ++++++++++++++++++++++++ tests/by-util/test_grpck.rs | 213 ++++++++++++++++++ tests/by-util/test_pwck.rs | 382 +++++++++++++++++++++++++++++++++ tests/by-util/test_useradd.rs | 370 +++++++++++++++++++++++++++++++ tests/by-util/test_userdel.rs | 316 +++++++++++++++++++++++++++ tests/by-util/test_usermod.rs | 371 ++++++++++++++++++++++++++++++++ 10 files changed, 2512 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 tests/by-util/test_groupadd.rs create mode 100644 tests/by-util/test_groupdel.rs create mode 100644 tests/by-util/test_groupmod.rs create mode 100644 tests/by-util/test_grpck.rs create mode 100644 tests/by-util/test_pwck.rs create mode 100644 tests/by-util/test_useradd.rs create mode 100644 tests/by-util/test_userdel.rs create mode 100644 tests/by-util/test_usermod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7bbe85f --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 2adf406..fec7961 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/tests/by-util/test_groupadd.rs b/tests/by-util/test_groupadd.rs new file mode 100644 index 0000000..d856811 --- /dev/null +++ b/tests/by-util/test_groupadd.rs @@ -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 = 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}" + ); +} diff --git a/tests/by-util/test_groupdel.rs b/tests/by-util/test_groupdel.rs new file mode 100644 index 0000000..95ca55f --- /dev/null +++ b/tests/by-util/test_groupdel.rs @@ -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 = 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"); +} diff --git a/tests/by-util/test_groupmod.rs b/tests/by-util/test_groupmod.rs new file mode 100644 index 0000000..264dfe4 --- /dev/null +++ b/tests/by-util/test_groupmod.rs @@ -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 = 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" + ); +} diff --git a/tests/by-util/test_grpck.rs b/tests/by-util/test_grpck.rs new file mode 100644 index 0000000..3bfdc63 --- /dev/null +++ b/tests/by-util/test_grpck.rs @@ -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 = 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"); +} diff --git a/tests/by-util/test_pwck.rs b/tests/by-util/test_pwck.rs new file mode 100644 index 0000000..018efd0 --- /dev/null +++ b/tests/by-util/test_pwck.rs @@ -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 = 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)" + ); +} diff --git a/tests/by-util/test_useradd.rs b/tests/by-util/test_useradd.rs new file mode 100644 index 0000000..85e895e --- /dev/null +++ b/tests/by-util/test_useradd.rs @@ -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 = 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}" + ); +} diff --git a/tests/by-util/test_userdel.rs b/tests/by-util/test_userdel.rs new file mode 100644 index 0000000..c546398 --- /dev/null +++ b/tests/by-util/test_userdel.rs @@ -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 = 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}" + ); +} diff --git a/tests/by-util/test_usermod.rs b/tests/by-util/test_usermod.rs new file mode 100644 index 0000000..d011955 --- /dev/null +++ b/tests/by-util/test_usermod.rs @@ -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 = 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"); +}