mirror of
https://github.com/uutils/shadow.git
synced 2026-06-10 16:14:57 -07:00
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:
+20
-3
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user