scaffold: workspace, shadow-core, passwd tool, Docker test matrix

Cargo workspace with three-layer architecture mirroring uutils conventions:
- shadow-core: /etc/passwd and /etc/shadow parsers, atomic file writes,
  username validation, error types, feature-gated module stubs (PAM,
  SELinux, group, gshadow, login.defs, subid, nscd, lock, uid_alloc)
- uu_passwd: tool skeleton with clap CLI, uumain/uu_app pattern
- multicall binary: argv[0] dispatch with --list support

Docker test matrix (Debian Trixie, Alpine musl, Fedora SELinux):
all three pass clippy -D warnings, 21 tests, and cargo fmt --check.

21 unit tests covering:
- passwd/shadow file parsing and roundtrip serialization
- atomic file write with fsync + rename + failure rollback
- username validation rules (length, charset, edge cases)
- clap command definition
This commit is contained in:
Pierre Warnier
2026-03-23 11:57:10 +01:00
parent eabec36e06
commit 97130fea20
27 changed files with 1292 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/target
Cargo.lock
CLAUDE.md
PLAN-shadow-rs.md
+74
View File
@@ -0,0 +1,74 @@
# spell-checker:ignore uutils
[package]
name = "shadow-rs"
version = "0.0.1"
edition = "2021"
license = "MIT"
description = "Memory-safe Rust reimplementation of Linux shadow-utils"
repository = "https://github.com/shadow-utils-rs/shadow-rs"
readme = "README.md"
keywords = ["coreutils", "shadow", "passwd", "useradd", "linux"]
categories = ["command-line-utilities", "os::unix-apis"]
[workspace]
members = [
"src/shadow-core",
"src/uu/passwd",
]
[workspace.package]
version = "0.0.1"
edition = "2021"
license = "MIT"
authors = ["shadow-rs contributors"]
repository = "https://github.com/shadow-utils-rs/shadow-rs"
[workspace.dependencies]
# Internal crates
shadow-core = { path = "src/shadow-core" }
# CLI
clap = { version = "4", features = ["derive", "wrap_help"] }
# Error handling
thiserror = "2"
# Unix/Linux
nix = { version = "0.29", features = ["user", "fs", "process", "signal"] }
libc = "0.2"
# Testing
proptest = "1"
tempfile = "3"
[dependencies]
# Tool crates (optional, enabled by features)
passwd = { optional = true, version = "0.0.1", package = "uu_passwd", path = "src/uu/passwd" }
[features]
default = ["passwd"]
# Individual tools
feat_passwd = ["passwd"]
# Grouped features (add tools as implemented)
feat_common = ["feat_passwd"]
[[bin]]
name = "shadow-rs"
path = "src/bin/shadow-rs.rs"
[profile.release]
lto = true
strip = true
[workspace.lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(fuzzing)'] }
[workspace.lints.clippy]
all = { level = "deny" }
pedantic = { level = "warn" }
[lints]
workspace = true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 shadow-rs contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+55
View File
@@ -0,0 +1,55 @@
# Usage:
# docker compose run --rm debian # shell on Debian Trixie
# docker compose run --rm debian cargo test
# docker compose run --rm alpine cargo test
# docker compose run --rm fedora cargo test
# docker compose up --build # build all images
#
# The cargo registry cache is shared across Debian/Alpine (same CARGO_HOME path).
# Fedora uses a separate cache (rustup installs to /root/.cargo).
# Each distro gets its own target dir (different libc/toolchain = different binaries).
x-common: &common
working_dir: /workspace
security_opt:
- seccomp:unconfined
cap_add:
- SYS_ADMIN
services:
debian:
<<: *common
build:
context: .
dockerfile: docker/Dockerfile.debian
volumes:
- .:/workspace
- cargo-cache:/usr/local/cargo/registry
- target-debian:/workspace/target
alpine:
<<: *common
build:
context: .
dockerfile: docker/Dockerfile.alpine
volumes:
- .:/workspace
- cargo-cache:/usr/local/cargo/registry
- target-alpine:/workspace/target
fedora:
<<: *common
build:
context: .
dockerfile: docker/Dockerfile.fedora
volumes:
- .:/workspace
- cargo-cache-fedora:/root/.cargo/registry
- target-fedora:/workspace/target
volumes:
cargo-cache:
cargo-cache-fedora:
target-debian:
target-alpine:
target-fedora:
+31
View File
@@ -0,0 +1,31 @@
# shadow-rs development image — Alpine 3.23 (musl libc)
#
# PAM: linux-pam-dev (Linux-PAM, not OpenPAM)
# SELinux: not available (Alpine doesn't ship SELinux)
# Default shell: /bin/ash (busybox)
# Shadow-utils package: shadow
# Note: musl libc — different from glibc. Tests NSS/getpwent differences.
# No SELinux. Busybox shadow-utils may differ from GNU shadow-utils.
FROM rust:alpine
RUN apk add --no-cache \
musl-dev \
linux-pam-dev \
linux-headers \
pkgconf \
shadow \
bash \
curl
RUN rustup component add clippy rustfmt \
&& cargo install cargo-deny \
&& curl -LsSf https://get.nexte.st/latest/linux-musl | tar zxf - -C /usr/local/cargo/bin
RUN adduser -D -s /bin/bash testuser \
&& adduser -D -s /bin/sh testuser2 \
&& addgroup testgroup \
&& addgroup testuser testgroup
WORKDIR /workspace
CMD ["/bin/bash"]
+28
View File
@@ -0,0 +1,28 @@
# shadow-rs development image — Debian Trixie (13)
#
# PAM: libpam0g-dev (Linux-PAM)
# SELinux: libselinux1-dev
# Default shell: /bin/bash
# Shadow-utils package: login + passwd
# Note: Trixie is now the default Debian for the Rust Docker image.
FROM rust:latest
RUN apt-get update && apt-get install -y --no-install-recommends \
libpam0g-dev \
libselinux1-dev \
libaudit-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
RUN rustup component add clippy rustfmt \
&& cargo install cargo-deny \
&& curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C /usr/local/cargo/bin
RUN useradd -m -s /bin/bash testuser \
&& useradd -m -s /bin/sh testuser2 \
&& groupadd testgroup \
&& usermod -aG testgroup testuser
WORKDIR /workspace
CMD ["/bin/bash"]
+36
View File
@@ -0,0 +1,36 @@
# shadow-rs development image — Fedora 45
#
# PAM: pam-devel (Linux-PAM)
# SELinux: libselinux-devel — SELinux is ENFORCING by default
# Default shell: /bin/bash
# Shadow-utils package: shadow-utils
# Note: Fedora has different PAM stack config and login.defs defaults than Debian.
# SELinux is enforcing, which affects file context on /etc/passwd, /etc/shadow.
FROM fedora:latest
RUN dnf install -y \
gcc \
pam-devel \
libselinux-devel \
audit-libs-devel \
pkgconf-pkg-config \
shadow-utils \
&& dnf clean all
# Install Rust via rustup
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
--default-toolchain stable \
--component clippy,rustfmt
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install cargo-deny \
&& curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C /root/.cargo/bin
RUN useradd -m -s /bin/bash testuser \
&& useradd -m -s /bin/sh testuser2 \
&& groupadd testgroup \
&& usermod -aG testgroup testuser
WORKDIR /workspace
CMD ["/bin/bash"]
+66
View File
@@ -0,0 +1,66 @@
// 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.
//! Multicall binary entry point for shadow-rs.
//!
//! Dispatches to the appropriate utility based on `argv[0]`.
//! When invoked as `shadow-rs <util>`, uses the first argument instead.
use std::path::Path;
fn main() {
let args: Vec<std::ffi::OsString> = std::env::args_os().collect();
let binary_name = args
.first()
.and_then(|a| {
Path::new(a)
.file_name()
.map(|n| n.to_string_lossy().to_string())
})
.unwrap_or_default();
// Direct invocation via symlink (e.g., argv[0] = "passwd")
if let Some(code) = dispatch(&binary_name, &args) {
std::process::exit(code);
}
// Multicall: `shadow-rs <util> [args...]`
if args.len() > 1 {
let util_name = args[1].to_string_lossy().to_string();
if util_name == "--list" {
print_available_utils();
std::process::exit(0);
}
if let Some(code) = dispatch(&util_name, &args[1..]) {
std::process::exit(code);
}
eprintln!("shadow-rs: unknown utility '{util_name}'");
eprintln!("Run 'shadow-rs --list' for available utilities.");
std::process::exit(1);
}
eprintln!("Usage: shadow-rs <utility> [arguments...]");
eprintln!("Run 'shadow-rs --list' for available utilities.");
std::process::exit(1);
}
fn dispatch(name: &str, args: &[std::ffi::OsString]) -> Option<i32> {
match name {
#[cfg(feature = "passwd")]
"passwd" => Some(passwd::uumain(args.iter().cloned())),
_ => None,
}
}
fn print_available_utils() {
println!("Available utilities:");
#[cfg(feature = "passwd")]
println!(" passwd");
}
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "shadow-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "Core library for shadow-rs: parsers, atomic file ops, locking, PAM integration"
[lib]
path = "src/lib.rs"
[dependencies]
libc = { workspace = true }
nix = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
tempfile = { workspace = true }
[features]
default = []
pam = []
selinux = []
shadow = []
group = []
gshadow = []
login-defs = []
subid = []
[lints]
workspace = true
+143
View File
@@ -0,0 +1,143 @@
// This file is part of the shadow-rs package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore fsync
//! Atomic file replacement.
//!
//! Implements the write-tmp-then-rename pattern:
//! 1. Write to a temporary file in the same directory as the target
//! 2. `fsync` the temporary file
//! 3. `rename` the temporary file over the target (atomic on POSIX)
use std::fs::{self, File};
use std::io::{self, Write};
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use crate::error::ShadowError;
/// Atomically replace a file's contents.
///
/// Creates a temporary file in the same directory, writes content via the
/// provided closure, fsyncs, then renames over the target. If any step
/// fails, the temporary file is cleaned up and the original is untouched.
///
/// # Errors
///
/// Returns `ShadowError` if any I/O operation fails, or if the closure returns an error.
pub fn atomic_write<F>(target: &Path, f: F) -> Result<(), ShadowError>
where
F: FnOnce(&mut File) -> Result<(), ShadowError>,
{
let dir = target.parent().ok_or_else(|| {
ShadowError::Other(format!("no parent directory for {}", target.display()))
})?;
let tmp_path = tmp_path_for(target);
let mut tmp_file =
File::create(&tmp_path).map_err(|e| ShadowError::IoPath(e, tmp_path.clone()))?;
// Preserve original file permissions if the target exists.
if let Ok(meta) = fs::metadata(target) {
let permissions = meta.permissions();
tmp_file
.set_permissions(permissions)
.map_err(|e| ShadowError::IoPath(e, tmp_path.clone()))?;
}
let result = f(&mut tmp_file);
if result.is_err() {
let _ = fs::remove_file(&tmp_path);
return result;
}
// Flush and fsync.
tmp_file
.flush()
.map_err(|e| ShadowError::IoPath(e, tmp_path.clone()))?;
nix::unistd::fsync(tmp_file.as_raw_fd())
.map_err(|e| ShadowError::IoPath(io::Error::from(e), tmp_path.clone()))?;
// Atomic rename.
fs::rename(&tmp_path, target).map_err(|e| ShadowError::IoPath(e, target.to_owned()))?;
// Fsync the parent directory to ensure the rename is durable.
if let Ok(dir_fd) = File::open(dir) {
let _ = nix::unistd::fsync(dir_fd.as_raw_fd());
}
Ok(())
}
/// Generate a temporary file path in the same directory as the target.
fn tmp_path_for(target: &Path) -> PathBuf {
let file_name = target
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let pid = std::process::id();
target.with_file_name(format!(".{file_name}.shadow-rs.{pid}.tmp"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_atomic_write_creates_file() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("test_file");
atomic_write(&target, |f| {
writeln!(f, "hello")?;
Ok(())
})
.unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "hello\n");
}
#[test]
fn test_atomic_write_replaces_file() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("test_file");
fs::write(&target, "old content").unwrap();
atomic_write(&target, |f| {
write!(f, "new content")?;
Ok(())
})
.unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "new content");
}
#[test]
fn test_atomic_write_failure_preserves_original() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("test_file");
fs::write(&target, "original").unwrap();
let result = atomic_write(&target, |_f| {
Err(ShadowError::Other("intentional failure".into()))
});
assert!(result.is_err());
assert_eq!(fs::read_to_string(&target).unwrap(), "original");
}
#[test]
fn test_tmp_path_is_hidden() {
let target = Path::new("/etc/passwd");
let tmp = tmp_path_for(target);
let name = tmp.file_name().unwrap().to_string_lossy();
assert!(name.starts_with('.'));
assert!(name.contains("shadow-rs"));
assert!(name.ends_with(".tmp"));
}
}
+83
View File
@@ -0,0 +1,83 @@
// 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.
//! Unified error types for shadow-rs utilities.
use std::fmt;
use std::io;
/// Result type alias used across all shadow-rs utilities.
pub type ShadowResult<T> = Result<T, ShadowError>;
/// Top-level error type for shadow-rs operations.
#[derive(Debug)]
pub enum ShadowError {
/// I/O error with optional context.
Io(io::Error),
/// I/O error with path context.
IoPath(io::Error, std::path::PathBuf),
/// File format parse error.
Parse(String),
/// Lock acquisition failed.
Lock(String),
/// Validation error (invalid username, UID range, etc.).
Validation(String),
/// Authentication error (PAM failure, wrong password, etc.).
Auth(String),
/// Permission denied.
Permission(String),
/// Generic error with message.
Other(String),
}
impl fmt::Display for ShadowError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(e) => write!(f, "{e}"),
Self::IoPath(e, path) => write!(f, "{}: {e}", path.display()),
Self::Parse(msg) => write!(f, "parse error: {msg}"),
Self::Lock(msg) => write!(f, "lock error: {msg}"),
Self::Auth(msg) => write!(f, "authentication error: {msg}"),
Self::Permission(msg) => write!(f, "permission denied: {msg}"),
Self::Validation(msg) | Self::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for ShadowError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) | Self::IoPath(e, _) => Some(e),
Self::Parse(_)
| Self::Lock(_)
| Self::Validation(_)
| Self::Auth(_)
| Self::Permission(_)
| Self::Other(_) => None,
}
}
}
impl From<io::Error> for ShadowError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
/// Print an error message prefixed with the utility name to stderr.
#[macro_export]
macro_rules! show_error {
($util:expr, $($arg:tt)*) => {
eprintln!("{}: {}", $util, format_args!($($arg)*));
};
}
/// Print a warning message prefixed with the utility name to stderr.
#[macro_export]
macro_rules! show_warning {
($util:expr, $($arg:tt)*) => {
eprintln!("{}: warning: {}", $util, format_args!($($arg)*));
};
}
+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.
//! Parser and writer for `/etc/group`.
+7
View File
@@ -0,0 +1,7 @@
// This file is part of the shadow-rs package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore gshadow
//! Parser and writer for `/etc/gshadow`.
+39
View File
@@ -0,0 +1,39 @@
// 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.
//! `shadow-core` — shared library for shadow-rs utilities.
//!
//! Provides file format parsers, atomic file operations, file locking,
//! validation, and platform integration (PAM, `nscd`, `SELinux`, audit).
pub mod error;
pub mod passwd;
pub mod validate;
#[cfg(feature = "shadow")]
pub mod shadow;
#[cfg(feature = "group")]
pub mod group;
#[cfg(feature = "gshadow")]
pub mod gshadow;
#[cfg(feature = "login-defs")]
pub mod login_defs;
#[cfg(feature = "subid")]
pub mod subid;
#[cfg(feature = "pam")]
pub mod pam;
#[cfg(feature = "selinux")]
pub mod selinux;
pub mod atomic;
pub mod lock;
pub mod nscd;
pub mod uid_alloc;
+9
View File
@@ -0,0 +1,9 @@
// 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.
//! File locking for `/etc/passwd`, `/etc/shadow`, etc.
//!
//! Uses `.lock` files (e.g., `/etc/passwd.lock`) with timeout and stale
//! lock detection, matching the convention used by GNU shadow-utils.
+7
View File
@@ -0,0 +1,7 @@
// This file is part of the shadow-rs package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore login_defs
//! Parser for `/etc/login.defs` configuration file.
+10
View File
@@ -0,0 +1,10 @@
// This file is part of the shadow-rs package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore nscd
//! nscd (Name Service Cache Daemon) cache invalidation.
//!
//! After modifying `/etc/passwd`, `/etc/shadow`, or `/etc/group`,
//! the nscd cache must be invalidated so lookups reflect the changes.
+9
View File
@@ -0,0 +1,9 @@
// 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.
//! PAM (Pluggable Authentication Modules) integration.
//!
//! Thin wrapper for authentication, account validation, and password
//! changes. Follows sudo-rs patterns for conversation functions.
+185
View File
@@ -0,0 +1,185 @@
// This file is part of the shadow-rs package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore gecos
//! Parser and writer for `/etc/passwd`.
//!
//! File format (man 5 passwd):
//! ```text
//! username:password:uid:gid:gecos:home:shell
//! ```
//!
//! Each field is colon-separated. The password field is typically `x`
//! (indicating the hash is in `/etc/shadow`).
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/passwd`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PasswdEntry {
/// Login name.
pub name: String,
/// Encrypted password (usually `x` — real hash in `/etc/shadow`).
pub passwd: String,
/// Numeric user ID.
pub uid: u32,
/// Numeric primary group ID.
pub gid: u32,
/// GECOS / comment field (real name, room, phone, etc.).
pub gecos: String,
/// Home directory.
pub home: String,
/// Login shell.
pub shell: String,
}
impl fmt::Display for PasswdEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}:{}:{}:{}:{}:{}:{}",
self.name, self.passwd, self.uid, self.gid, self.gecos, self.home, self.shell
)
}
}
impl FromStr for PasswdEntry {
type Err = ShadowError;
fn from_str(line: &str) -> Result<Self, Self::Err> {
let fields: Vec<&str> = line.split(':').collect();
if fields.len() != 7 {
return Err(ShadowError::Parse(format!(
"expected 7 colon-separated fields, got {}",
fields.len()
)));
}
let uid = fields[2]
.parse::<u32>()
.map_err(|e| ShadowError::Parse(format!("invalid UID '{}': {e}", fields[2])))?;
let gid = fields[3]
.parse::<u32>()
.map_err(|e| ShadowError::Parse(format!("invalid GID '{}': {e}", fields[3])))?;
Ok(Self {
name: fields[0].to_string(),
passwd: fields[1].to_string(),
uid,
gid,
gecos: fields[4].to_string(),
home: fields[5].to_string(),
shell: fields[6].to_string(),
})
}
}
/// Read all entries from an `/etc/passwd`-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_passwd_file(path: &Path) -> Result<Vec<PasswdEntry>, 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();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
entries.push(trimmed.parse()?);
}
Ok(entries)
}
/// Write entries to an `/etc/passwd`-formatted file.
///
/// This writes to the provided writer. For atomic file replacement,
/// use this with `atomic::AtomicFile`.
///
/// # Errors
///
/// Returns `ShadowError` on I/O write failure.
pub fn write_passwd<W: Write>(entries: &[PasswdEntry], 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: PasswdEntry = "root:x:0:0:root:/root:/bin/bash".parse().unwrap();
assert_eq!(entry.name, "root");
assert_eq!(entry.passwd, "x");
assert_eq!(entry.uid, 0);
assert_eq!(entry.gid, 0);
assert_eq!(entry.gecos, "root");
assert_eq!(entry.home, "/root");
assert_eq!(entry.shell, "/bin/bash");
}
#[test]
fn test_parse_empty_gecos() {
let entry: PasswdEntry = "nobody:x:65534:65534::/nonexistent:/usr/sbin/nologin"
.parse()
.unwrap();
assert_eq!(entry.name, "nobody");
assert_eq!(entry.gecos, "");
}
#[test]
fn test_parse_wrong_field_count() {
let result = "root:x:0:0".parse::<PasswdEntry>();
assert!(result.is_err());
}
#[test]
fn test_parse_invalid_uid() {
let result = "root:x:abc:0:root:/root:/bin/bash".parse::<PasswdEntry>();
assert!(result.is_err());
}
#[test]
fn test_roundtrip() {
let line = "testuser:x:1000:1000:Test User:/home/testuser:/bin/zsh";
let entry: PasswdEntry = line.parse().unwrap();
assert_eq!(entry.to_string(), line);
}
#[test]
fn test_write_passwd() {
let entries = vec![
"root:x:0:0:root:/root:/bin/bash"
.parse::<PasswdEntry>()
.unwrap(),
"nobody:x:65534:65534::/nonexistent:/usr/sbin/nologin"
.parse::<PasswdEntry>()
.unwrap(),
];
let mut buf = Vec::new();
write_passwd(&entries, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap();
assert_eq!(
output,
"root:x:0:0:root:/root:/bin/bash\nnobody:x:65534:65534::/nonexistent:/usr/sbin/nologin\n"
);
}
}
+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.
//! SELinux security context handling for file operations.

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