diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93c4551 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +Cargo.lock +CLAUDE.md +PLAN-shadow-rs.md diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c4ff154 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..05df148 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aa64aa5 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine new file mode 100644 index 0000000..edf6f26 --- /dev/null +++ b/docker/Dockerfile.alpine @@ -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"] diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian new file mode 100644 index 0000000..385d0d7 --- /dev/null +++ b/docker/Dockerfile.debian @@ -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"] diff --git a/docker/Dockerfile.fedora b/docker/Dockerfile.fedora new file mode 100644 index 0000000..0156100 --- /dev/null +++ b/docker/Dockerfile.fedora @@ -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"] diff --git a/src/bin/shadow-rs.rs b/src/bin/shadow-rs.rs new file mode 100644 index 0000000..ec52735 --- /dev/null +++ b/src/bin/shadow-rs.rs @@ -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 `, uses the first argument instead. + +use std::path::Path; + +fn main() { + let args: Vec = 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 [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 [arguments...]"); + eprintln!("Run 'shadow-rs --list' for available utilities."); + std::process::exit(1); +} + +fn dispatch(name: &str, args: &[std::ffi::OsString]) -> Option { + 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"); +} diff --git a/src/shadow-core/Cargo.toml b/src/shadow-core/Cargo.toml new file mode 100644 index 0000000..28f45be --- /dev/null +++ b/src/shadow-core/Cargo.toml @@ -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 diff --git a/src/shadow-core/src/atomic.rs b/src/shadow-core/src/atomic.rs new file mode 100644 index 0000000..7287704 --- /dev/null +++ b/src/shadow-core/src/atomic.rs @@ -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(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")); + } +} diff --git a/src/shadow-core/src/error.rs b/src/shadow-core/src/error.rs new file mode 100644 index 0000000..16e256b --- /dev/null +++ b/src/shadow-core/src/error.rs @@ -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 = Result; + +/// 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 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)*)); + }; +} diff --git a/src/shadow-core/src/group.rs b/src/shadow-core/src/group.rs new file mode 100644 index 0000000..509b3e7 --- /dev/null +++ b/src/shadow-core/src/group.rs @@ -0,0 +1,6 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Parser and writer for `/etc/group`. diff --git a/src/shadow-core/src/gshadow.rs b/src/shadow-core/src/gshadow.rs new file mode 100644 index 0000000..8d6986d --- /dev/null +++ b/src/shadow-core/src/gshadow.rs @@ -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`. diff --git a/src/shadow-core/src/lib.rs b/src/shadow-core/src/lib.rs new file mode 100644 index 0000000..86db52f --- /dev/null +++ b/src/shadow-core/src/lib.rs @@ -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; diff --git a/src/shadow-core/src/lock.rs b/src/shadow-core/src/lock.rs new file mode 100644 index 0000000..ec38c96 --- /dev/null +++ b/src/shadow-core/src/lock.rs @@ -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. diff --git a/src/shadow-core/src/login_defs.rs b/src/shadow-core/src/login_defs.rs new file mode 100644 index 0000000..b1afbc8 --- /dev/null +++ b/src/shadow-core/src/login_defs.rs @@ -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. diff --git a/src/shadow-core/src/nscd.rs b/src/shadow-core/src/nscd.rs new file mode 100644 index 0000000..be10af4 --- /dev/null +++ b/src/shadow-core/src/nscd.rs @@ -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. diff --git a/src/shadow-core/src/pam.rs b/src/shadow-core/src/pam.rs new file mode 100644 index 0000000..c9315f2 --- /dev/null +++ b/src/shadow-core/src/pam.rs @@ -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. diff --git a/src/shadow-core/src/passwd.rs b/src/shadow-core/src/passwd.rs new file mode 100644 index 0000000..205a19b --- /dev/null +++ b/src/shadow-core/src/passwd.rs @@ -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 { + 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::() + .map_err(|e| ShadowError::Parse(format!("invalid UID '{}': {e}", fields[2])))?; + let gid = fields[3] + .parse::() + .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, 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(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::(); + assert!(result.is_err()); + } + + #[test] + fn test_parse_invalid_uid() { + let result = "root:x:abc:0:root:/root:/bin/bash".parse::(); + 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::() + .unwrap(), + "nobody:x:65534:65534::/nonexistent:/usr/sbin/nologin" + .parse::() + .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" + ); + } +} diff --git a/src/shadow-core/src/selinux.rs b/src/shadow-core/src/selinux.rs new file mode 100644 index 0000000..8cb760b --- /dev/null +++ b/src/shadow-core/src/selinux.rs @@ -0,0 +1,6 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! SELinux security context handling for file operations. diff --git a/src/shadow-core/src/shadow.rs b/src/shadow-core/src/shadow.rs new file mode 100644 index 0000000..f35a441 --- /dev/null +++ b/src/shadow-core/src/shadow.rs @@ -0,0 +1,186 @@ +// 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 lstchg + +//! Parser and writer for `/etc/shadow`. +//! +//! File format (man 5 shadow): +//! ```text +//! username:password:lstchg:min:max:warn:inactive:expire:reserved +//! ``` +//! +//! All date fields are in days since epoch (1970-01-01). + +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/shadow`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShadowEntry { + /// Login name (must match an `/etc/passwd` entry). + pub name: String, + /// Encrypted password hash (or `!`/`*` for locked/no-login). + pub passwd: String, + /// Date of last password change (days since epoch), or empty. + pub last_change: Option, + /// Minimum days between password changes, or empty. + pub min_age: Option, + /// Maximum days a password is valid, or empty. + pub max_age: Option, + /// Days before expiry to warn user, or empty. + pub warn_days: Option, + /// Days after expiry until account is disabled, or empty. + pub inactive_days: Option, + /// Account expiration date (days since epoch), or empty. + pub expire_date: Option, + /// Reserved field (unused). + pub reserved: String, +} + +/// Parse an optional numeric field — empty string becomes `None`. +fn parse_optional_field(field: &str) -> Result, ShadowError> { + if field.is_empty() { + Ok(None) + } else { + field + .parse::() + .map(Some) + .map_err(|e| ShadowError::Parse(format!("invalid numeric field '{field}': {e}"))) + } +} + +/// Format an optional numeric field — `None` becomes empty string. +fn fmt_optional(val: Option) -> String { + match val { + Some(v) => v.to_string(), + None => String::new(), + } +} + +impl fmt::Display for ShadowEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}:{}:{}:{}:{}:{}:{}:{}:{}", + self.name, + self.passwd, + fmt_optional(self.last_change), + fmt_optional(self.min_age), + fmt_optional(self.max_age), + fmt_optional(self.warn_days), + fmt_optional(self.inactive_days), + fmt_optional(self.expire_date), + self.reserved, + ) + } +} + +impl FromStr for ShadowEntry { + type Err = ShadowError; + + fn from_str(line: &str) -> Result { + let fields: Vec<&str> = line.split(':').collect(); + if fields.len() != 9 { + return Err(ShadowError::Parse(format!( + "expected 9 colon-separated fields, got {}", + fields.len() + ))); + } + + Ok(Self { + name: fields[0].to_string(), + passwd: fields[1].to_string(), + last_change: parse_optional_field(fields[2])?, + min_age: parse_optional_field(fields[3])?, + max_age: parse_optional_field(fields[4])?, + warn_days: parse_optional_field(fields[5])?, + inactive_days: parse_optional_field(fields[6])?, + expire_date: parse_optional_field(fields[7])?, + reserved: fields[8].to_string(), + }) + } +} + +/// Read all entries from an `/etc/shadow`-formatted file. +/// +/// # Errors +/// +/// Returns `ShadowError` if the file cannot be opened or contains malformed entries. +pub fn read_shadow_file(path: &Path) -> Result, ShadowError> { + let file = std::fs::File::open(path).map_err(|e| ShadowError::IoPath(e, path.to_owned()))?; + let reader = io::BufReader::new(file); + let mut entries = Vec::new(); + + for line in reader.lines() { + let line = line?; + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + entries.push(trimmed.parse()?); + } + + Ok(entries) +} + +/// Write entries to an `/etc/shadow`-formatted file. +/// +/// # Errors +/// +/// Returns `ShadowError` on I/O write failure. +pub fn write_shadow(entries: &[ShadowEntry], 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: ShadowEntry = "root:$6$hash:19000:0:99999:7:::".parse().unwrap(); + assert_eq!(entry.name, "root"); + assert_eq!(entry.passwd, "$6$hash"); + assert_eq!(entry.last_change, Some(19000)); + assert_eq!(entry.min_age, Some(0)); + assert_eq!(entry.max_age, Some(99999)); + assert_eq!(entry.warn_days, Some(7)); + assert_eq!(entry.inactive_days, None); + assert_eq!(entry.expire_date, None); + assert_eq!(entry.reserved, ""); + } + + #[test] + fn test_parse_locked_account() { + let entry: ShadowEntry = "locked:!:19000::::::".parse().unwrap(); + assert_eq!(entry.passwd, "!"); + assert_eq!(entry.min_age, None); + } + + #[test] + fn test_roundtrip() { + let line = "testuser:$6$rounds=5000$salt$hash:19500:0:99999:7:30::"; + let entry: ShadowEntry = line.parse().unwrap(); + assert_eq!(entry.to_string(), line); + } + + #[test] + fn test_all_empty_optional_fields() { + let entry: ShadowEntry = "svc:*:::::::".parse().unwrap(); + assert_eq!(entry.last_change, None); + assert_eq!(entry.min_age, None); + assert_eq!(entry.max_age, None); + assert_eq!(entry.warn_days, None); + assert_eq!(entry.inactive_days, None); + assert_eq!(entry.expire_date, None); + } +} diff --git a/src/shadow-core/src/subid.rs b/src/shadow-core/src/subid.rs new file mode 100644 index 0000000..6f8460d --- /dev/null +++ b/src/shadow-core/src/subid.rs @@ -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 subuid subgid subid + +//! Parser for `/etc/subuid` and `/etc/subgid` (subordinate ID ranges). diff --git a/src/shadow-core/src/uid_alloc.rs b/src/shadow-core/src/uid_alloc.rs new file mode 100644 index 0000000..babd0fa --- /dev/null +++ b/src/shadow-core/src/uid_alloc.rs @@ -0,0 +1,6 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! UID and GID allocation from ranges defined in `/etc/login.defs`. diff --git a/src/shadow-core/src/validate.rs b/src/shadow-core/src/validate.rs new file mode 100644 index 0000000..6b6e417 --- /dev/null +++ b/src/shadow-core/src/validate.rs @@ -0,0 +1,121 @@ +// 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. + +//! Username and groupname validation rules. +//! +//! Based on POSIX Portable Filename Character Set plus Linux extensions. +//! See: POSIX 3.437 (User Name), man 8 useradd. + +use crate::error::ShadowError; + +/// Maximum username length on Linux. +const MAX_USERNAME_LEN: usize = 32; + +/// Validate a username according to Linux conventions. +/// +/// Rules (from man 8 useradd, POSIX): +/// - Must not be empty +/// - Must not exceed 32 characters +/// - First character must be a lowercase letter or underscore +/// - Remaining characters: lowercase letters, digits, underscores, hyphens, periods +/// - Must not end with a period (historically problematic) +/// - Must not consist of only dots +/// +/// # Errors +/// +/// Returns `ShadowError::Validation` if the username violates any rule. +pub fn validate_username(name: &str) -> Result<(), ShadowError> { + if name.is_empty() { + return Err(ShadowError::Validation("username must not be empty".into())); + } + + if name.len() > MAX_USERNAME_LEN { + return Err(ShadowError::Validation(format!( + "username '{name}' exceeds maximum length of {MAX_USERNAME_LEN} characters" + ))); + } + + let mut chars = name.chars(); + // Name is guaranteed non-empty by the check above. + let Some(first) = chars.next() else { + unreachable!("empty name already rejected"); + }; + + if !first.is_ascii_lowercase() && first != '_' { + return Err(ShadowError::Validation(format!( + "username '{name}' must start with a lowercase letter or underscore" + ))); + } + + for ch in chars { + if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '_' && ch != '-' && ch != '.' { + return Err(ShadowError::Validation(format!( + "username '{name}' contains invalid character '{ch}'" + ))); + } + } + + if name.ends_with('.') { + return Err(ShadowError::Validation(format!( + "username '{name}' must not end with a period" + ))); + } + + if name.chars().all(|c| c == '.') { + return Err(ShadowError::Validation(format!( + "username '{name}' must not consist only of periods" + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_usernames() { + assert!(validate_username("root").is_ok()); + assert!(validate_username("_apt").is_ok()); + assert!(validate_username("user123").is_ok()); + assert!(validate_username("test-user").is_ok()); + assert!(validate_username("test.user").is_ok()); + assert!(validate_username("a").is_ok()); + } + + #[test] + fn test_empty_username() { + assert!(validate_username("").is_err()); + } + + #[test] + fn test_too_long() { + let long_name = "a".repeat(33); + assert!(validate_username(&long_name).is_err()); + let max_name = "a".repeat(32); + assert!(validate_username(&max_name).is_ok()); + } + + #[test] + fn test_invalid_first_char() { + assert!(validate_username("1user").is_err()); + assert!(validate_username("-user").is_err()); + assert!(validate_username(".user").is_err()); + assert!(validate_username("User").is_err()); + } + + #[test] + fn test_invalid_chars() { + assert!(validate_username("user@name").is_err()); + assert!(validate_username("user name").is_err()); + assert!(validate_username("user:name").is_err()); + } + + #[test] + fn test_trailing_period() { + assert!(validate_username("user.").is_err()); + } +} diff --git a/src/uu/passwd/Cargo.toml b/src/uu/passwd/Cargo.toml new file mode 100644 index 0000000..c0b437c --- /dev/null +++ b/src/uu/passwd/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "uu_passwd" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "passwd command — change user password (shadow-rs)" + +[lib] +path = "src/passwd.rs" + +[[bin]] +name = "passwd" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +shadow-core = { workspace = true, features = ["shadow", "pam"] } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/src/uu/passwd/src/main.rs b/src/uu/passwd/src/main.rs new file mode 100644 index 0000000..80ff3a3 --- /dev/null +++ b/src/uu/passwd/src/main.rs @@ -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. + +fn main() { + let code = uu_passwd::uumain(std::env::args_os()); + std::process::exit(code); +} diff --git a/src/uu/passwd/src/passwd.rs b/src/uu/passwd/src/passwd.rs new file mode 100644 index 0000000..fbea2ad --- /dev/null +++ b/src/uu/passwd/src/passwd.rs @@ -0,0 +1,88 @@ +// 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. + +//! `passwd` — change user password. + +use clap::{Arg, ArgAction, Command}; + +mod options { + pub const USER: &str = "user"; + pub const STATUS: &str = "status"; + pub const LOCK: &str = "lock"; + pub const UNLOCK: &str = "unlock"; + pub const DELETE: &str = "delete"; +} + +/// Entry point for the `passwd` utility. +pub fn uumain(args: impl IntoIterator) -> i32 { + let matches = uu_app().try_get_matches_from(args); + + let matches = match matches { + Ok(m) => m, + Err(e) => { + e.print().ok(); + return i32::from(e.use_stderr()); + } + }; + + if matches.get_flag(options::STATUS) { + eprintln!("passwd: --status not yet implemented"); + return 1; + } + + eprintln!("passwd: not yet implemented"); + 1 +} + +/// Build the clap `Command` for `passwd`. +#[must_use] +pub fn uu_app() -> Command { + Command::new("passwd") + .version(env!("CARGO_PKG_VERSION")) + .about("Change user password") + .arg( + Arg::new(options::STATUS) + .short('S') + .long("status") + .help("Display account status information") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::LOCK) + .short('l') + .long("lock") + .help("Lock the password of the named account") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::UNLOCK) + .short('u') + .long("unlock") + .help("Unlock the password of the named account") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::DELETE) + .short('d') + .long("delete") + .help("Delete the password of the named account") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new(options::USER) + .help("Username to change password for") + .index(1), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_app_builds() { + uu_app().debug_assert(); + } +}