mirror of
https://github.com/uutils/shadow.git
synced 2026-06-10 16:14:57 -07:00
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:
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
CLAUDE.md
|
||||
PLAN-shadow-rs.md
|
||||
+74
@@ -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
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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)*));
|
||||
};
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user