diff --git a/Cargo.toml b/Cargo.toml index 75864db..fc3ea0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,11 @@ members = [ "src/shadow-core", "src/uu/passwd", "src/uu/pwck", + "src/uu/useradd", + "src/uu/userdel", + "src/uu/usermod", + "src/uu/chpasswd", + "src/uu/chage", ] [workspace.package] @@ -53,16 +58,28 @@ tempfile = "3" # Tool crates (optional, enabled by features) passwd = { optional = true, version = "0.0.1", package = "uu_passwd", path = "src/uu/passwd" } pwck = { optional = true, version = "0.0.1", package = "uu_pwck", path = "src/uu/pwck" } +useradd = { optional = true, version = "0.0.1", package = "uu_useradd", path = "src/uu/useradd" } +userdel = { optional = true, version = "0.0.1", package = "uu_userdel", path = "src/uu/userdel" } +usermod = { optional = true, version = "0.0.1", package = "uu_usermod", path = "src/uu/usermod" } +chpasswd = { optional = true, version = "0.0.1", package = "uu_chpasswd", path = "src/uu/chpasswd" } +chage = { optional = true, version = "0.0.1", package = "uu_chage", path = "src/uu/chage" } [features] -default = ["passwd", "pwck"] +default = ["passwd", "pwck", "useradd", "userdel", "usermod", "chpasswd", "chage"] # Individual tools feat_passwd = ["passwd"] feat_pwck = ["pwck"] +feat_useradd = ["useradd"] +feat_userdel = ["userdel"] +feat_usermod = ["usermod"] +feat_chpasswd = ["chpasswd"] +feat_chage = ["chage"] -# Grouped features (add tools as implemented) -feat_common = ["feat_passwd", "feat_pwck"] +# Grouped features +feat_phase1 = ["feat_passwd", "feat_pwck"] +feat_phase2 = ["feat_useradd", "feat_userdel", "feat_usermod", "feat_chpasswd", "feat_chage"] +feat_common = ["feat_phase1", "feat_phase2"] [[bin]] name = "shadow-rs" diff --git a/src/shadow-core/src/gshadow.rs b/src/shadow-core/src/gshadow.rs index 8d6986d..98ffc5d 100644 --- a/src/shadow-core/src/gshadow.rs +++ b/src/shadow-core/src/gshadow.rs @@ -5,3 +5,263 @@ // spell-checker:ignore gshadow //! Parser and writer for `/etc/gshadow`. +//! +//! File format (man 5 gshadow): +//! ```text +//! groupname:password:admins:members +//! ``` +//! +//! `admins` and `members` are comma-separated lists of usernames. + +use std::fmt; +use std::io::{self, BufRead, Write}; +use std::path::Path; +use std::str::FromStr; + +use crate::error::ShadowError; + +/// A single entry from `/etc/gshadow`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GshadowEntry { + /// Group name (must match an `/etc/group` entry). + pub name: String, + /// Encrypted group password (or `!`/`*` for no group password). + pub passwd: String, + /// Comma-separated list of group administrators. + pub admins: Vec, + /// Comma-separated list of group members. + pub members: Vec, +} + +impl fmt::Display for GshadowEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}:{}:{}:{}", + self.name, + self.passwd, + self.admins.join(","), + self.members.join(",") + ) + } +} + +impl FromStr for GshadowEntry { + type Err = ShadowError; + + fn from_str(line: &str) -> Result { + let mut fields = line.splitn(5, ':'); + + let name = fields + .next() + .ok_or_else(|| ShadowError::Parse("missing gshadow group name".into()))?; + let passwd = fields + .next() + .ok_or_else(|| ShadowError::Parse("missing gshadow password".into()))?; + let admins_str = fields + .next() + .ok_or_else(|| ShadowError::Parse("missing gshadow admins".into()))?; + let members_str = fields + .next() + .ok_or_else(|| ShadowError::Parse("missing gshadow members".into()))?; + + if fields.next().is_some() { + return Err(ShadowError::Parse( + "too many fields in gshadow entry".into(), + )); + } + + let admins = if admins_str.is_empty() { + Vec::new() + } else { + admins_str.split(',').map(ToString::to_string).collect() + }; + + let members = if members_str.is_empty() { + Vec::new() + } else { + members_str.split(',').map(ToString::to_string).collect() + }; + + Ok(Self { + name: name.to_string(), + passwd: passwd.to_string(), + admins, + members, + }) + } +} + +/// Read all entries from an `/etc/gshadow`-formatted file. +/// +/// Skips blank lines and lines starting with `#`. +/// +/// # Errors +/// +/// Returns `ShadowError` if the file cannot be opened or contains malformed entries. +pub fn read_gshadow_file(path: &Path) -> Result, ShadowError> { + let file = std::fs::File::open(path).map_err(|e| ShadowError::IoPath(e, path.to_owned()))?; + let reader = io::BufReader::new(file); + let mut entries = Vec::new(); + + for line in reader.lines() { + let line = line?; + let trimmed = line.trim_start(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + entries.push(line.parse()?); + } + + Ok(entries) +} + +/// Write entries to an `/etc/gshadow`-formatted file. +/// +/// # Errors +/// +/// Returns `ShadowError` on I/O write failure. +pub fn write_gshadow(entries: &[GshadowEntry], mut writer: W) -> Result<(), ShadowError> { + for entry in entries { + writeln!(writer, "{entry}")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid_entry() { + let entry: GshadowEntry = "root:*::".parse().unwrap(); + assert_eq!(entry.name, "root"); + assert_eq!(entry.passwd, "*"); + assert!(entry.admins.is_empty()); + assert!(entry.members.is_empty()); + } + + #[test] + fn test_parse_with_admins_and_members() { + let entry: GshadowEntry = "sudo:!:alice,bob:alice,bob,charlie".parse().unwrap(); + assert_eq!(entry.name, "sudo"); + assert_eq!(entry.passwd, "!"); + assert_eq!(entry.admins, vec!["alice", "bob"]); + assert_eq!(entry.members, vec!["alice", "bob", "charlie"]); + } + + #[test] + fn test_parse_admins_only() { + let entry: GshadowEntry = "wheel:!:root:".parse().unwrap(); + assert_eq!(entry.admins, vec!["root"]); + assert!(entry.members.is_empty()); + } + + #[test] + fn test_parse_members_only() { + let entry: GshadowEntry = "docker:!::deploy,ci".parse().unwrap(); + assert!(entry.admins.is_empty()); + assert_eq!(entry.members, vec!["deploy", "ci"]); + } + + #[test] + fn test_roundtrip() { + let line = "sudo:!:alice,bob:alice,bob,charlie"; + let entry: GshadowEntry = line.parse().unwrap(); + assert_eq!(entry.to_string(), line); + } + + #[test] + fn test_roundtrip_empty_lists() { + let line = "root:*::"; + let entry: GshadowEntry = line.parse().unwrap(); + assert_eq!(entry.to_string(), line); + } + + #[test] + fn test_parse_too_few_fields() { + assert!("root:*:".parse::().is_err()); + } + + #[test] + fn test_parse_too_many_fields() { + assert!("root:*:::extra".parse::().is_err()); + } + + #[test] + fn test_write_read_roundtrip_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("gshadow"); + + let entries = vec![ + GshadowEntry { + name: "root".into(), + passwd: "*".into(), + admins: vec![], + members: vec![], + }, + GshadowEntry { + name: "sudo".into(), + passwd: "!".into(), + admins: vec!["alice".into()], + members: vec!["alice".into(), "bob".into()], + }, + ]; + + let file = std::fs::File::create(&path).unwrap(); + write_gshadow(&entries, file).unwrap(); + + let read_back = read_gshadow_file(&path).unwrap(); + assert_eq!(entries, read_back); + } + + #[test] + fn test_empty_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("gshadow"); + std::fs::write(&path, "").unwrap(); + let entries = read_gshadow_file(&path).unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn test_comments_and_blanks_skipped() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("gshadow"); + std::fs::write( + &path, + "# comment\n\nroot:*::\n# another\nsudo:!:admin:alice\n", + ) + .unwrap(); + let entries = read_gshadow_file(&path).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].name, "root"); + assert_eq!(entries[1].name, "sudo"); + } + + use proptest::prelude::*; + + fn arb_gshadow_entry() -> impl Strategy { + ( + "[a-z_][a-z0-9_-]{0,31}", + "(\\*|!|!!|\\$6\\$[a-z]{4})", + proptest::collection::vec("[a-z_][a-z0-9_]{0,15}", 0..5), + proptest::collection::vec("[a-z_][a-z0-9_]{0,15}", 0..5), + ) + .prop_map(|(name, passwd, admins, members)| GshadowEntry { + name, + passwd, + admins, + members, + }) + } + + proptest! { + #[test] + fn test_gshadow_roundtrip(entry in arb_gshadow_entry()) { + let line = entry.to_string(); + let parsed: GshadowEntry = line.parse().unwrap(); + prop_assert_eq!(parsed, entry); + } + } +} diff --git a/src/shadow-core/src/lib.rs b/src/shadow-core/src/lib.rs index 056adc8..13fc466 100644 --- a/src/shadow-core/src/lib.rs +++ b/src/shadow-core/src/lib.rs @@ -36,5 +36,8 @@ pub mod selinux; pub mod atomic; pub mod lock; pub mod nscd; +pub mod skel; pub mod sysroot; + +#[cfg(all(feature = "group", feature = "login-defs"))] pub mod uid_alloc; diff --git a/src/shadow-core/src/selinux.rs b/src/shadow-core/src/selinux.rs index 8cb760b..07c1ee7 100644 --- a/src/shadow-core/src/selinux.rs +++ b/src/shadow-core/src/selinux.rs @@ -3,4 +3,4 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -//! SELinux security context handling for file operations. +//! `SELinux` security context handling for file operations. diff --git a/src/shadow-core/src/skel.rs b/src/shadow-core/src/skel.rs new file mode 100644 index 0000000..f1847fc --- /dev/null +++ b/src/shadow-core/src/skel.rs @@ -0,0 +1,147 @@ +// 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. + +//! Copy `/etc/skel` directory contents into a new home directory. +//! +//! When `useradd -m` creates a home directory, it populates it with +//! files from the skeleton directory (typically `/etc/skel`). + +use std::path::Path; + +use crate::error::ShadowError; + +/// Recursively copy the skeleton directory into the target home directory. +/// +/// Sets ownership of all copied files and directories to the given `uid`/`gid`. +/// Preserves file permissions. Recreates symlinks without following them. +/// +/// If `skel_dir` does not exist, returns `Ok(())` (nothing to copy). +/// +/// # Errors +/// +/// Returns `ShadowError` on I/O failures or if ownership cannot be set. +pub fn copy_skel(skel_dir: &Path, home_dir: &Path, uid: u32, gid: u32) -> Result<(), ShadowError> { + if !skel_dir.exists() { + return Ok(()); + } + + copy_dir_recursive(skel_dir, home_dir, uid, gid) +} + +fn copy_dir_recursive(src: &Path, dst: &Path, uid: u32, gid: u32) -> Result<(), ShadowError> { + let entries = std::fs::read_dir(src).map_err(|e| ShadowError::IoPath(e, src.to_owned()))?; + + for entry in entries { + let entry = entry.map_err(|e| ShadowError::IoPath(e, src.to_owned()))?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + let file_type = entry + .file_type() + .map_err(|e| ShadowError::IoPath(e, src_path.clone()))?; + + if file_type.is_dir() { + std::fs::create_dir_all(&dst_path) + .map_err(|e| ShadowError::IoPath(e, dst_path.clone()))?; + copy_dir_recursive(&src_path, &dst_path, uid, gid)?; + } else if file_type.is_symlink() { + let target = std::fs::read_link(&src_path) + .map_err(|e| ShadowError::IoPath(e, src_path.clone()))?; + std::os::unix::fs::symlink(&target, &dst_path) + .map_err(|e| ShadowError::IoPath(e, dst_path.clone()))?; + } else { + std::fs::copy(&src_path, &dst_path) + .map_err(|e| ShadowError::IoPath(e, dst_path.clone()))?; + } + + // Set ownership (lchown for symlinks — doesn't follow the target). + std::os::unix::fs::lchown(&dst_path, Some(uid), Some(gid)) + .map_err(|e| ShadowError::IoPath(e, dst_path))?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nonexistent_skel_is_ok() { + let dir = tempfile::tempdir().unwrap(); + let result = copy_skel( + &dir.path().join("no-such-skel"), + &dir.path().join("home"), + 1000, + 1000, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_copy_files() { + if !nix::unistd::geteuid().is_root() { + return; + } + + let dir = tempfile::tempdir().unwrap(); + let skel = dir.path().join("skel"); + let home = dir.path().join("home"); + std::fs::create_dir_all(&skel).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + + std::fs::write(skel.join(".bashrc"), "# bashrc\n").unwrap(); + std::fs::write(skel.join(".profile"), "# profile\n").unwrap(); + + copy_skel(&skel, &home, 1000, 1000).unwrap(); + + assert!(home.join(".bashrc").exists()); + assert!(home.join(".profile").exists()); + assert_eq!( + std::fs::read_to_string(home.join(".bashrc")).unwrap(), + "# bashrc\n" + ); + } + + #[test] + fn test_copy_subdirectory() { + if !nix::unistd::geteuid().is_root() { + return; + } + + let dir = tempfile::tempdir().unwrap(); + let skel = dir.path().join("skel"); + let home = dir.path().join("home"); + std::fs::create_dir_all(skel.join(".config/subdir")).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + + std::fs::write(skel.join(".config/subdir/file.txt"), "content").unwrap(); + + copy_skel(&skel, &home, 1000, 1000).unwrap(); + + assert!(home.join(".config/subdir/file.txt").exists()); + } + + #[test] + fn test_copy_symlink() { + if !nix::unistd::geteuid().is_root() { + return; + } + + let dir = tempfile::tempdir().unwrap(); + let skel = dir.path().join("skel"); + let home = dir.path().join("home"); + std::fs::create_dir_all(&skel).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + + std::fs::write(skel.join("real_file"), "data").unwrap(); + std::os::unix::fs::symlink("real_file", skel.join("link")).unwrap(); + + copy_skel(&skel, &home, 1000, 1000).unwrap(); + + assert!(home.join("link").exists()); + let target = std::fs::read_link(home.join("link")).unwrap(); + assert_eq!(target.to_str().unwrap(), "real_file"); + } +} diff --git a/src/shadow-core/src/subid.rs b/src/shadow-core/src/subid.rs index 6f8460d..1915c0b 100644 --- a/src/shadow-core/src/subid.rs +++ b/src/shadow-core/src/subid.rs @@ -4,4 +4,230 @@ // file that was distributed with this source code. // spell-checker:ignore subuid subgid subid -//! Parser for `/etc/subuid` and `/etc/subgid` (subordinate ID ranges). +//! Parser and writer for `/etc/subuid` and `/etc/subgid` (subordinate ID ranges). +//! +//! File format (man 5 subuid / man 5 subgid): +//! ```text +//! username:start:count +//! ``` +//! +//! Each line grants the named user (or UID) a contiguous block of +//! subordinate UIDs (or GIDs) starting at `start` with `count` entries. +//! These ranges are used by `newuidmap` / `newgidmap` for user-namespace +//! ID mapping (rootless containers). + +use std::fmt; +use std::io::{self, BufRead, Write}; +use std::path::Path; +use std::str::FromStr; + +use crate::error::ShadowError; + +/// A single entry from `/etc/subuid` or `/etc/subgid`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SubIdEntry { + /// Login name (or numeric UID/GID as a string). + pub name: String, + /// First subordinate ID in the range. + pub start: u64, + /// Number of subordinate IDs allocated. + pub count: u64, +} + +impl fmt::Display for SubIdEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}:{}", self.name, self.start, self.count) + } +} + +impl FromStr for SubIdEntry { + type Err = ShadowError; + + fn from_str(line: &str) -> Result { + let mut fields = line.splitn(4, ':'); + + let name = fields + .next() + .ok_or_else(|| ShadowError::Parse("missing subid name".into()))?; + let start_str = fields + .next() + .ok_or_else(|| ShadowError::Parse("missing subid start".into()))?; + let count_str = fields + .next() + .ok_or_else(|| ShadowError::Parse("missing subid count".into()))?; + + if fields.next().is_some() { + return Err(ShadowError::Parse("too many fields in subid entry".into())); + } + + let start = start_str.parse::().map_err(|e| { + ShadowError::Parse(format!("invalid subid start '{start_str}': {e}").into()) + })?; + let count = count_str.parse::().map_err(|e| { + ShadowError::Parse(format!("invalid subid count '{count_str}': {e}").into()) + })?; + + Ok(Self { + name: name.to_string(), + start, + count, + }) + } +} + +/// Read all entries from an `/etc/subuid` or `/etc/subgid`-formatted file. +/// +/// Skips blank lines and lines starting with `#`. +/// +/// # Errors +/// +/// Returns `ShadowError` if the file cannot be opened or contains malformed entries. +pub fn read_subid_file(path: &Path) -> Result, ShadowError> { + let file = std::fs::File::open(path).map_err(|e| ShadowError::IoPath(e, path.to_owned()))?; + let reader = io::BufReader::new(file); + let mut entries = Vec::new(); + + for line in reader.lines() { + let line = line?; + let trimmed = line.trim_start(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + entries.push(line.parse()?); + } + + Ok(entries) +} + +/// Write entries to an `/etc/subuid` or `/etc/subgid`-formatted file. +/// +/// # Errors +/// +/// Returns `ShadowError` on I/O write failure. +pub fn write_subid(entries: &[SubIdEntry], mut writer: W) -> Result<(), ShadowError> { + for entry in entries { + writeln!(writer, "{entry}")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid_entry() { + let entry: SubIdEntry = "alice:100000:65536".parse().unwrap(); + assert_eq!(entry.name, "alice"); + assert_eq!(entry.start, 100_000); + assert_eq!(entry.count, 65536); + } + + #[test] + fn test_parse_numeric_name() { + let entry: SubIdEntry = "1000:200000:65536".parse().unwrap(); + assert_eq!(entry.name, "1000"); + assert_eq!(entry.start, 200_000); + } + + #[test] + fn test_roundtrip() { + let line = "bob:165536:65536"; + let entry: SubIdEntry = line.parse().unwrap(); + assert_eq!(entry.to_string(), line); + } + + #[test] + fn test_parse_too_few_fields() { + assert!("alice:100000".parse::().is_err()); + } + + #[test] + fn test_parse_too_many_fields() { + assert!("alice:100000:65536:extra".parse::().is_err()); + } + + #[test] + fn test_parse_invalid_start() { + assert!("alice:abc:65536".parse::().is_err()); + } + + #[test] + fn test_parse_invalid_count() { + assert!("alice:100000:xyz".parse::().is_err()); + } + + #[test] + fn test_parse_negative_start() { + assert!("alice:-1:65536".parse::().is_err()); + } + + #[test] + fn test_write_read_roundtrip_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("subuid"); + + let entries = vec![ + SubIdEntry { + name: "alice".into(), + start: 100_000, + count: 65536, + }, + SubIdEntry { + name: "bob".into(), + start: 165_536, + count: 65536, + }, + ]; + + let file = std::fs::File::create(&path).unwrap(); + write_subid(&entries, file).unwrap(); + + let read_back = read_subid_file(&path).unwrap(); + assert_eq!(entries, read_back); + } + + #[test] + fn test_empty_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("subuid"); + std::fs::write(&path, "").unwrap(); + let entries = read_subid_file(&path).unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn test_comments_and_blanks_skipped() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("subuid"); + std::fs::write(&path, "# subordinate UIDs\n\nalice:100000:65536\n# end\n").unwrap(); + let entries = read_subid_file(&path).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "alice"); + } + + #[test] + fn test_large_values() { + let line = "root:4294967296:4294967296"; + let entry: SubIdEntry = line.parse().unwrap(); + assert_eq!(entry.start, 4_294_967_296); + assert_eq!(entry.count, 4_294_967_296); + assert_eq!(entry.to_string(), line); + } + + use proptest::prelude::*; + + fn arb_subid_entry() -> impl Strategy { + ("[a-z_][a-z0-9_-]{0,31}", 0u64..1_000_000_000, 1u64..200_000) + .prop_map(|(name, start, count)| SubIdEntry { name, start, count }) + } + + proptest! { + #[test] + fn test_subid_roundtrip(entry in arb_subid_entry()) { + let line = entry.to_string(); + let parsed: SubIdEntry = line.parse().unwrap(); + prop_assert_eq!(parsed, entry); + } + } +} diff --git a/src/shadow-core/src/uid_alloc.rs b/src/shadow-core/src/uid_alloc.rs index babd0fa..ac2849f 100644 --- a/src/shadow-core/src/uid_alloc.rs +++ b/src/shadow-core/src/uid_alloc.rs @@ -4,3 +4,244 @@ // file that was distributed with this source code. //! UID and GID allocation from ranges defined in `/etc/login.defs`. +//! +//! Finds the next available UID or GID by scanning existing entries and +//! returning the lowest unused value in the configured range. Range +//! boundaries come from `login.defs` keys (`UID_MIN`, `UID_MAX`, +//! `SYS_UID_MIN`, `SYS_UID_MAX`, and the GID equivalents). +//! +//! Default ranges follow the Debian/upstream convention: +//! - Regular users: 1000 -- 60000 +//! - System accounts: 101 -- 999 + +use std::collections::HashSet; + +use crate::error::ShadowError; +use crate::group::GroupEntry; +use crate::login_defs::LoginDefs; +use crate::passwd::PasswdEntry; + +/// Find the next available UID in the given range. +/// +/// Scans `existing` entries and returns the lowest UID in `[min, max]` +/// that is not already in use. +/// +/// # Errors +/// +/// Returns `ShadowError::Other` if every UID in the range is taken. +pub fn next_uid(existing: &[PasswdEntry], min: u32, max: u32) -> Result { + let used: HashSet = existing.iter().map(|e| e.uid).collect(); + (min..=max) + .find(|uid| !used.contains(uid)) + .ok_or_else(|| ShadowError::Other(format!("no available UID in range {min}-{max}").into())) +} + +/// Find the next available GID in the given range. +/// +/// Scans `existing` entries and returns the lowest GID in `[min, max]` +/// that is not already in use. +/// +/// # Errors +/// +/// Returns `ShadowError::Other` if every GID in the range is taken. +pub fn next_gid(existing: &[GroupEntry], min: u32, max: u32) -> Result { + let used: HashSet = existing.iter().map(|e| e.gid).collect(); + (min..=max) + .find(|gid| !used.contains(gid)) + .ok_or_else(|| ShadowError::Other(format!("no available GID in range {min}-{max}").into())) +} + +/// Read a `login.defs` key as `u32`, ignoring negative or overflowing values. +fn get_u32(defs: &LoginDefs, key: &str) -> Option { + defs.get_i64(key).and_then(|v| u32::try_from(v).ok()) +} + +/// Get the UID allocation range from `login.defs`. +/// +/// Returns `(min, max)`. When `system` is `true`, uses `SYS_UID_MIN` / +/// `SYS_UID_MAX` (defaults 101 / 999). Otherwise uses `UID_MIN` / +/// `UID_MAX` (defaults 1000 / 60000). +#[must_use] +pub fn uid_range(defs: &LoginDefs, system: bool) -> (u32, u32) { + if system { + let min = get_u32(defs, "SYS_UID_MIN").unwrap_or(101); + let max = get_u32(defs, "SYS_UID_MAX").unwrap_or(999); + (min, max) + } else { + let min = get_u32(defs, "UID_MIN").unwrap_or(1000); + let max = get_u32(defs, "UID_MAX").unwrap_or(60000); + (min, max) + } +} + +/// Get the GID allocation range from `login.defs`. +/// +/// Returns `(min, max)`. When `system` is `true`, uses `SYS_GID_MIN` / +/// `SYS_GID_MAX` (defaults 101 / 999). Otherwise uses `GID_MIN` / +/// `GID_MAX` (defaults 1000 / 60000). +#[must_use] +pub fn gid_range(defs: &LoginDefs, system: bool) -> (u32, u32) { + if system { + let min = get_u32(defs, "SYS_GID_MIN").unwrap_or(101); + let max = get_u32(defs, "SYS_GID_MAX").unwrap_or(999); + (min, max) + } else { + let min = get_u32(defs, "GID_MIN").unwrap_or(1000); + let max = get_u32(defs, "GID_MAX").unwrap_or(60000); + (min, max) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn make_passwd_entries(uids: &[u32]) -> Vec { + uids.iter() + .map(|&uid| PasswdEntry { + name: format!("user{uid}"), + passwd: "x".into(), + uid, + gid: uid, + gecos: String::new(), + home: format!("/home/user{uid}"), + shell: "/bin/bash".into(), + }) + .collect() + } + + fn make_group_entries(gids: &[u32]) -> Vec { + gids.iter() + .map(|&gid| GroupEntry { + name: format!("group{gid}"), + passwd: "x".into(), + gid, + members: vec![], + }) + .collect() + } + + // --- next_uid --- + + #[test] + fn test_next_uid_empty() { + let entries: Vec = vec![]; + assert_eq!(next_uid(&entries, 1000, 1005).unwrap(), 1000); + } + + #[test] + fn test_next_uid_finds_first_gap() { + let entries = make_passwd_entries(&[1000, 1001, 1003]); + assert_eq!(next_uid(&entries, 1000, 1005).unwrap(), 1002); + } + + #[test] + fn test_next_uid_skips_used_start() { + let entries = make_passwd_entries(&[1000]); + assert_eq!(next_uid(&entries, 1000, 1005).unwrap(), 1001); + } + + #[test] + fn test_next_uid_exhausted() { + let entries = make_passwd_entries(&[100, 101, 102]); + let result = next_uid(&entries, 100, 102); + assert!(result.is_err()); + } + + #[test] + fn test_next_uid_single_value_range() { + let entries: Vec = vec![]; + assert_eq!(next_uid(&entries, 500, 500).unwrap(), 500); + } + + #[test] + fn test_next_uid_single_value_range_taken() { + let entries = make_passwd_entries(&[500]); + assert!(next_uid(&entries, 500, 500).is_err()); + } + + // --- next_gid --- + + #[test] + fn test_next_gid_empty() { + let entries: Vec = vec![]; + assert_eq!(next_gid(&entries, 1000, 1005).unwrap(), 1000); + } + + #[test] + fn test_next_gid_finds_first_gap() { + let entries = make_group_entries(&[1000, 1002]); + assert_eq!(next_gid(&entries, 1000, 1005).unwrap(), 1001); + } + + #[test] + fn test_next_gid_exhausted() { + let entries = make_group_entries(&[10, 11, 12]); + assert!(next_gid(&entries, 10, 12).is_err()); + } + + // --- uid_range --- + + #[test] + fn test_uid_range_defaults_regular() { + let defs = LoginDefs::load(Path::new("/nonexistent/login.defs")).unwrap(); + assert_eq!(uid_range(&defs, false), (1000, 60000)); + } + + #[test] + fn test_uid_range_defaults_system() { + let defs = LoginDefs::load(Path::new("/nonexistent/login.defs")).unwrap(); + assert_eq!(uid_range(&defs, true), (101, 999)); + } + + #[test] + fn test_uid_range_from_login_defs() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("login.defs"); + std::fs::write(&path, "UID_MIN 500\nUID_MAX 50000\n").unwrap(); + let defs = LoginDefs::load(&path).unwrap(); + assert_eq!(uid_range(&defs, false), (500, 50000)); + } + + #[test] + fn test_uid_range_system_from_login_defs() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("login.defs"); + std::fs::write(&path, "SYS_UID_MIN 200\nSYS_UID_MAX 499\n").unwrap(); + let defs = LoginDefs::load(&path).unwrap(); + assert_eq!(uid_range(&defs, true), (200, 499)); + } + + // --- gid_range --- + + #[test] + fn test_gid_range_defaults_regular() { + let defs = LoginDefs::load(Path::new("/nonexistent/login.defs")).unwrap(); + assert_eq!(gid_range(&defs, false), (1000, 60000)); + } + + #[test] + fn test_gid_range_defaults_system() { + let defs = LoginDefs::load(Path::new("/nonexistent/login.defs")).unwrap(); + assert_eq!(gid_range(&defs, true), (101, 999)); + } + + #[test] + fn test_gid_range_from_login_defs() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("login.defs"); + std::fs::write(&path, "GID_MIN 500\nGID_MAX 50000\n").unwrap(); + let defs = LoginDefs::load(&path).unwrap(); + assert_eq!(gid_range(&defs, false), (500, 50000)); + } + + #[test] + fn test_gid_range_system_from_login_defs() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("login.defs"); + std::fs::write(&path, "SYS_GID_MIN 200\nSYS_GID_MAX 499\n").unwrap(); + let defs = LoginDefs::load(&path).unwrap(); + assert_eq!(gid_range(&defs, true), (200, 499)); + } +} diff --git a/src/uu/chage/Cargo.toml b/src/uu/chage/Cargo.toml new file mode 100644 index 0000000..72c2d6e --- /dev/null +++ b/src/uu/chage/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "uu_chage" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "chage command (shadow-rs)" + +[lib] +path = "src/chage.rs" + +[[bin]] +name = "chage" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +nix = { workspace = true } +shadow-core = { workspace = true, features = ["shadow", "group", "gshadow", "login-defs", "subid"] } +uucore = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/uu/chage/src/chage.rs b/src/uu/chage/src/chage.rs new file mode 100644 index 0000000..9a133b5 --- /dev/null +++ b/src/uu/chage/src/chage.rs @@ -0,0 +1,21 @@ +// 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. + +//! `chage` — stub (not yet implemented). + +use clap::Command; +use uucore::error::UResult; + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let _matches = uu_app().try_get_matches_from(args)?; + eprintln!("chage: not yet implemented"); + Ok(()) +} + +#[must_use] +pub fn uu_app() -> Command { + Command::new("chage").about("chage — not yet implemented") +} diff --git a/src/uu/chage/src/main.rs b/src/uu/chage/src/main.rs new file mode 100644 index 0000000..2265ded --- /dev/null +++ b/src/uu/chage/src/main.rs @@ -0,0 +1,6 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +uucore::bin!(uu_chage); diff --git a/src/uu/chpasswd/Cargo.toml b/src/uu/chpasswd/Cargo.toml new file mode 100644 index 0000000..96018c6 --- /dev/null +++ b/src/uu/chpasswd/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "uu_chpasswd" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "chpasswd command (shadow-rs)" + +[lib] +path = "src/chpasswd.rs" + +[[bin]] +name = "chpasswd" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +nix = { workspace = true } +shadow-core = { workspace = true, features = ["shadow", "group", "gshadow", "login-defs", "subid"] } +uucore = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/uu/chpasswd/src/chpasswd.rs b/src/uu/chpasswd/src/chpasswd.rs new file mode 100644 index 0000000..3ea80a5 --- /dev/null +++ b/src/uu/chpasswd/src/chpasswd.rs @@ -0,0 +1,21 @@ +// 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. + +//! `chpasswd` — stub (not yet implemented). + +use clap::Command; +use uucore::error::UResult; + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let _matches = uu_app().try_get_matches_from(args)?; + eprintln!("chpasswd: not yet implemented"); + Ok(()) +} + +#[must_use] +pub fn uu_app() -> Command { + Command::new("chpasswd").about("chpasswd — not yet implemented") +} diff --git a/src/uu/chpasswd/src/main.rs b/src/uu/chpasswd/src/main.rs new file mode 100644 index 0000000..ad8f13d --- /dev/null +++ b/src/uu/chpasswd/src/main.rs @@ -0,0 +1,6 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +uucore::bin!(uu_chpasswd); diff --git a/src/uu/useradd/Cargo.toml b/src/uu/useradd/Cargo.toml new file mode 100644 index 0000000..50585a1 --- /dev/null +++ b/src/uu/useradd/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "uu_useradd" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "useradd command (shadow-rs)" + +[lib] +path = "src/useradd.rs" + +[[bin]] +name = "useradd" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +nix = { workspace = true } +shadow-core = { workspace = true, features = ["shadow", "group", "gshadow", "login-defs", "subid"] } +uucore = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/uu/useradd/src/main.rs b/src/uu/useradd/src/main.rs new file mode 100644 index 0000000..03fc8b7 --- /dev/null +++ b/src/uu/useradd/src/main.rs @@ -0,0 +1,6 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +uucore::bin!(uu_useradd); diff --git a/src/uu/useradd/src/useradd.rs b/src/uu/useradd/src/useradd.rs new file mode 100644 index 0000000..ca24519 --- /dev/null +++ b/src/uu/useradd/src/useradd.rs @@ -0,0 +1,21 @@ +// 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. + +//! `useradd` — stub (not yet implemented). + +use clap::Command; +use uucore::error::UResult; + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let _matches = uu_app().try_get_matches_from(args)?; + eprintln!("useradd: not yet implemented"); + Ok(()) +} + +#[must_use] +pub fn uu_app() -> Command { + Command::new("useradd").about("useradd — not yet implemented") +} diff --git a/src/uu/userdel/Cargo.toml b/src/uu/userdel/Cargo.toml new file mode 100644 index 0000000..30179a5 --- /dev/null +++ b/src/uu/userdel/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "uu_userdel" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "userdel command (shadow-rs)" + +[lib] +path = "src/userdel.rs" + +[[bin]] +name = "userdel" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +nix = { workspace = true } +shadow-core = { workspace = true, features = ["shadow", "group", "gshadow", "login-defs", "subid"] } +uucore = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/uu/userdel/src/main.rs b/src/uu/userdel/src/main.rs new file mode 100644 index 0000000..91245c9 --- /dev/null +++ b/src/uu/userdel/src/main.rs @@ -0,0 +1,6 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +uucore::bin!(uu_userdel); diff --git a/src/uu/userdel/src/userdel.rs b/src/uu/userdel/src/userdel.rs new file mode 100644 index 0000000..0a46caa --- /dev/null +++ b/src/uu/userdel/src/userdel.rs @@ -0,0 +1,21 @@ +// 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. + +//! `userdel` — stub (not yet implemented). + +use clap::Command; +use uucore::error::UResult; + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let _matches = uu_app().try_get_matches_from(args)?; + eprintln!("userdel: not yet implemented"); + Ok(()) +} + +#[must_use] +pub fn uu_app() -> Command { + Command::new("userdel").about("userdel — not yet implemented") +} diff --git a/src/uu/usermod/Cargo.toml b/src/uu/usermod/Cargo.toml new file mode 100644 index 0000000..e1df2f9 --- /dev/null +++ b/src/uu/usermod/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "uu_usermod" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "usermod command (shadow-rs)" + +[lib] +path = "src/usermod.rs" + +[[bin]] +name = "usermod" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +nix = { workspace = true } +shadow-core = { workspace = true, features = ["shadow", "group", "gshadow", "login-defs", "subid"] } +uucore = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/uu/usermod/src/main.rs b/src/uu/usermod/src/main.rs new file mode 100644 index 0000000..163ba19 --- /dev/null +++ b/src/uu/usermod/src/main.rs @@ -0,0 +1,6 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +uucore::bin!(uu_usermod); diff --git a/src/uu/usermod/src/usermod.rs b/src/uu/usermod/src/usermod.rs new file mode 100644 index 0000000..1262236 --- /dev/null +++ b/src/uu/usermod/src/usermod.rs @@ -0,0 +1,21 @@ +// 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. + +//! `usermod` — stub (not yet implemented). + +use clap::Command; +use uucore::error::UResult; + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let _matches = uu_app().try_get_matches_from(args)?; + eprintln!("usermod: not yet implemented"); + Ok(()) +} + +#[must_use] +pub fn uu_app() -> Command { + Command::new("usermod").about("usermod — not yet implemented") +}