phase2: shadow-core modules + tool scaffolds for user management

Fixes #58 — uid_alloc: UID/GID allocation from login.defs ranges.
Fixes #59 — skel: recursive /etc/skel copy with chown.
Fixes #60 — gshadow: /etc/gshadow parser with proptest.
Fixes #61 — subid: /etc/subuid + /etc/subgid parser with proptest.

Scaffolded 5 Phase 2 tool crates (useradd, userdel, usermod,
chpasswd, chage) as stubs. Updated workspace, multicall binary,
and feature flags.

240 tests on Debian, zero clippy warnings.
This commit is contained in:
Pierre Warnier
2026-03-24 10:07:13 +01:00
parent 1f89e36ea1
commit b0cbe5c8de
22 changed files with 1169 additions and 5 deletions
+20 -3
View File
@@ -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"
+260
View File
@@ -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<String>,
/// Comma-separated list of group members.
pub members: Vec<String>,
}
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<Self, Self::Err> {
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<Vec<GshadowEntry>, 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<W: Write>(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::<GshadowEntry>().is_err());
}
#[test]
fn test_parse_too_many_fields() {
assert!("root:*:::extra".parse::<GshadowEntry>().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<Value = GshadowEntry> {
(
"[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);
}
}
}
+3
View File
@@ -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;
+1 -1
View File
@@ -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.
+147
View File
@@ -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");
}
}
+227 -1
View File
@@ -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<Self, Self::Err> {
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::<u64>().map_err(|e| {
ShadowError::Parse(format!("invalid subid start '{start_str}': {e}").into())
})?;
let count = count_str.parse::<u64>().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<Vec<SubIdEntry>, 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<W: Write>(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::<SubIdEntry>().is_err());
}
#[test]
fn test_parse_too_many_fields() {
assert!("alice:100000:65536:extra".parse::<SubIdEntry>().is_err());
}
#[test]
fn test_parse_invalid_start() {
assert!("alice:abc:65536".parse::<SubIdEntry>().is_err());
}
#[test]
fn test_parse_invalid_count() {
assert!("alice:100000:xyz".parse::<SubIdEntry>().is_err());
}
#[test]
fn test_parse_negative_start() {
assert!("alice:-1:65536".parse::<SubIdEntry>().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<Value = SubIdEntry> {
("[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);
}
}
}
+241
View File
@@ -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<u32, ShadowError> {
let used: HashSet<u32> = 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<u32, ShadowError> {
let used: HashSet<u32> = 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<u32> {
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<PasswdEntry> {
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<GroupEntry> {
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<PasswdEntry> = 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<PasswdEntry> = 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<GroupEntry> = 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));
}
}
+27
View File
@@ -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
+21
View File
@@ -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")
}
+6
View File
@@ -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);
+27
View File
@@ -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
+21
View File
@@ -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")
}
+6
View File
@@ -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);
+27
View File
@@ -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
+6
View File
@@ -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);
+21
View File
@@ -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")
}
+27
View File
@@ -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
+6
View File
@@ -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);
+21
View File
@@ -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")
}
+27
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More