Files
Pierre Warnier 32c94fbc76 shadow-core, tools: complete nix removal — phase 2 (#140)
Fully remove nix as a direct dependency of the shadow-rs workspace.

New module shadow_core::process provides process-wide POSIX wrappers
via libc for setuid/setgid/initgroups/execv/sigprocmask. These MUST
use libc (not rustix) because rustix intentionally provides only
per-thread versions, which are incorrect for setuid-root tools.

Migrations:
- hardening.rs: setrlimit → rustix::process, sigprocmask → process
  module, User::from_uid → /etc/passwd parser lookup
- pam.rs: nix::sys::termios → rustix::termios
- passwd.rs: full migration (seteuid, setuid, getuid, User lookup)
- newgrp.rs: full migration (termios, User lookup, setgid, initgroups,
  execv)
- chpasswd/chage/chfn/chsh: setuid → shadow_core::process
- lock.rs tests: fcntl_getfd → rustix::io

libc stays for: PAM FFI, crypt(3) FFI, process-wide POSIX wrappers.
nix remains only as a transitive dep of uucore (upstream).
2026-04-22 11:26:49 +02:00

369 lines
11 KiB
Rust

// 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 `common::skip_unless_root()` and run inside
//! Docker CI containers. Non-root tests exercise clap parsing and error paths
//! that do not need privilege.
use std::ffi::OsString;
#[path = "../common/mod.rs"]
mod common;
/// 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 rustix::process::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 common::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 common::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 common::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 common::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 common::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 common::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 common::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 common::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 common::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 common::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 common::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}"
);
}