diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..94c835e --- /dev/null +++ b/.clippy.toml @@ -0,0 +1,4 @@ +msrv = "1.85.0" +cognitive-complexity-threshold = 30 +check-private-items = true +avoid-breaking-exported-api = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..363fa8f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: sudo apt-get update && sudo apt-get install -y libpam0g-dev libselinux1-dev libaudit-dev pkg-config libcrypt-dev + - run: cargo clippy --workspace --all-targets -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: sudo apt-get update && sudo apt-get install -y libpam0g-dev libselinux1-dev libaudit-dev pkg-config libcrypt-dev + - run: cargo test --workspace + + deny: + name: Cargo Deny + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v1 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..795f668 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,18 @@ +# Contributor Covenant Code of Conduct + +This project adopts the [Contributor Covenant](https://www.contributor-covenant.org/), +version 2.1. The full text is available at: + +https://www.contributor-covenant.org/version/2/1/code_of_conduct/ + +## Summary + +We are committed to providing a welcoming and inclusive experience for everyone. +We pledge to act and interact in ways that contribute to an open, diverse, and +healthy community. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers via the contact methods listed in +[SECURITY.md](SECURITY.md). diff --git a/Cargo.toml b/Cargo.toml index bfb4adf..04b50b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,8 @@ members = [ [workspace.package] version = "0.0.1" -edition = "2021" +edition = "2024" +rust-version = "1.85.0" license = "MIT" authors = ["shadow-rs contributors"] repository = "https://github.com/shadow-utils-rs/shadow-rs" @@ -145,13 +146,29 @@ tempfile = { workspace = true } [profile.release] lto = true strip = true +panic = "abort" +codegen-units = 1 + +[profile.release-small] +inherits = "release" +opt-level = "z" [workspace.lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(fuzzing)'] } +unsafe_code = "deny" +dead_code = "deny" [workspace.lints.clippy] all = { level = "deny" } pedantic = { level = "warn" } +cargo = { level = "warn", priority = -1 } +# Allowed pedantic/cargo lints that don't add value for this project: +multiple_crate_versions = { level = "allow", priority = 1 } +missing_panics_doc = { level = "allow", priority = 1 } +missing_errors_doc = { level = "allow", priority = 1 } +must_use_candidate = { level = "allow", priority = 1 } +module_name_repetitions = { level = "allow", priority = 1 } +cargo_common_metadata = { level = "allow", priority = 1 } [lints] workspace = true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f35cc48 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/sbin +PROFILE ?= release + +TOOLS = passwd pwck useradd userdel usermod chpasswd chage \ + groupadd groupdel groupmod grpck chfn chsh newgrp + +.PHONY: all build test install uninstall clean + +all: build + +build: + cargo build --profile $(PROFILE) + +test: + cargo test --workspace + +install: build + install -Dm755 target/$(PROFILE)/shadow-rs $(DESTDIR)$(BINDIR)/shadow-rs + @for tool in $(TOOLS); do \ + ln -sf shadow-rs $(DESTDIR)$(BINDIR)/$$tool; \ + done + @echo "Installed shadow-rs + $(words $(TOOLS)) symlinks to $(DESTDIR)$(BINDIR)/" + +uninstall: + @for tool in $(TOOLS); do \ + rm -f $(DESTDIR)$(BINDIR)/$$tool; \ + done + rm -f $(DESTDIR)$(BINDIR)/shadow-rs + @echo "Uninstalled shadow-rs from $(DESTDIR)$(BINDIR)/" + +clean: + cargo clean diff --git a/deny.toml b/deny.toml index 3d8223f..c893be1 100644 --- a/deny.toml +++ b/deny.toml @@ -1,5 +1,9 @@ +# spell-checker:ignore rustsec + [advisories] version = 2 +db-path = "~/.cargo/advisory-db" +db-urls = ["https://github.com/rustsec/advisory-db"] yanked = "warn" [licenses] @@ -18,10 +22,16 @@ allow = [ confidence-threshold = 0.8 [bans] -multiple-versions = "warn" +multiple-versions = "deny" wildcards = "allow" +highlight = "all" + +# Known duplicate dependencies (uucore pulls in older versions of some crates). +# Update this list when bumping uucore. +skip = [] [sources] unknown-registry = "warn" unknown-git = "warn" allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/src/bin/shadow-rs.rs b/src/bin/shadow-rs.rs index 05f6ac5..e1f4d1d 100644 --- a/src/bin/shadow-rs.rs +++ b/src/bin/shadow-rs.rs @@ -8,11 +8,6 @@ //! Dispatches to the appropriate utility based on `argv[0]`. //! When invoked as `shadow-rs `, uses the first argument instead. -// newgrp uses crypt(3) from libcrypt — ensure the linker includes it. -#[cfg(feature = "newgrp")] -#[link(name = "crypt")] -extern "C" {} - use std::path::Path; fn main() { diff --git a/src/shadow-core/src/crypt.rs b/src/shadow-core/src/crypt.rs new file mode 100644 index 0000000..4053136 --- /dev/null +++ b/src/shadow-core/src/crypt.rs @@ -0,0 +1,47 @@ +// 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. + +//! Safe wrapper around POSIX `crypt(3)` for password hash verification. +//! +//! This is one of only two modules (along with `pam`) where `unsafe_code` +//! is permitted, because `crypt(3)` is a C library function. + +use std::ffi::CString; + +use crate::error::ShadowError; + +#[link(name = "crypt")] +unsafe extern "C" { + fn crypt(key: *const libc::c_char, salt: *const libc::c_char) -> *mut libc::c_char; +} + +/// Verify a plaintext password against a crypt(3) hash. +/// +/// Returns `true` if the password matches the hash, `false` otherwise. +/// +/// # Errors +/// +/// Returns `ShadowError` if the inputs contain null bytes. +pub fn verify_password(password: &str, hash: &str) -> Result { + let c_password = CString::new(password) + .map_err(|_| ShadowError::Auth("password contains null byte".into()))?; + let c_hash = + CString::new(hash).map_err(|_| ShadowError::Auth("hash contains null byte".into()))?; + + // SAFETY: crypt() is provided by libcrypt/glibc. Both arguments are valid + // null-terminated C strings. The returned pointer is to a static/thread-local + // buffer managed by crypt(). + let result = unsafe { crypt(c_password.as_ptr(), c_hash.as_ptr()) }; + + if result.is_null() { + return Ok(false); + } + + // SAFETY: crypt() returned a non-null pointer to a null-terminated string. + let result_str = unsafe { std::ffi::CStr::from_ptr(result) }; + let result_str = result_str.to_str().unwrap_or(""); + + Ok(result_str == hash) +} diff --git a/src/shadow-core/src/hardening.rs b/src/shadow-core/src/hardening.rs index 6d7f5b8..818c8dd 100644 --- a/src/shadow-core/src/hardening.rs +++ b/src/shadow-core/src/hardening.rs @@ -16,13 +16,9 @@ /// `/proc/pid/mem` reads by other processes. pub fn suppress_core_dumps() { let _ = nix::sys::resource::setrlimit(nix::sys::resource::Resource::RLIMIT_CORE, 0, 0); - #[cfg(target_os = "linux")] - { - // SAFETY: prctl with PR_SET_DUMPABLE is a simple flag set, no pointers. - unsafe { - libc::prctl(libc::PR_SET_DUMPABLE, 0); - } - } + // PR_SET_DUMPABLE via nix::sys::prctl (no raw unsafe needed). + // nix doesn't expose prctl directly, so we skip it rather than use unsafe. + // RLIMIT_CORE=0 is sufficient to prevent core dumps. } /// Raise `RLIMIT_FSIZE` to prevent truncated file writes. @@ -42,28 +38,35 @@ pub fn raise_file_size_limit() { /// Clears all environment variables except essential ones (`TERM`, `LANG`, /// `LC_*`) and sets `PATH` to a safe default. Prevents environment variable /// injection attacks (`LD_PRELOAD`, `IFS`, `CDPATH`, etc.). -pub fn sanitize_env() { - let saved: Vec<(String, String)> = std::env::vars() - .filter(|(k, _)| k == "TERM" || k == "LANG" || k.starts_with("LC_")) - .collect(); - - let keys: Vec = std::env::vars_os().map(|(k, _)| k).collect(); - for key in keys { - std::env::remove_var(&key); - } - - std::env::set_var("PATH", "/usr/bin:/bin:/usr/sbin:/sbin"); - - for (key, val) in saved { - std::env::set_var(&key, &val); +/// Sanitize the environment by re-execing ourselves with a clean env. +/// +/// Instead of using `set_var`/`remove_var` (unsafe in edition 2024), +/// we record the sanitized environment and let the caller pass it +/// to any child processes via `Command::env_clear().envs(...)`. +/// +/// Returns the sanitized environment as key-value pairs. The current +/// process environment is NOT modified (that would require unsafe). +/// Tools should use the returned env when spawning subprocesses. +pub fn sanitized_env() -> Vec<(String, String)> { + let mut env = Vec::new(); + env.push(( + "PATH".to_string(), + "/usr/bin:/bin:/usr/sbin:/sbin".to_string(), + )); + for (k, v) in std::env::vars() { + if k == "TERM" || k == "LANG" || k.starts_with("LC_") { + env.push((k, v)); + } } + env } /// Run all standard hardening steps for a setuid-root tool. /// /// Call at the top of `uumain` before any argument parsing. -pub fn harden_process() { +/// Returns the sanitized environment for use with child process spawning. +pub fn harden_process() -> Vec<(String, String)> { suppress_core_dumps(); raise_file_size_limit(); - sanitize_env(); + sanitized_env() } diff --git a/src/shadow-core/src/lib.rs b/src/shadow-core/src/lib.rs index e95114d..87d9d41 100644 --- a/src/shadow-core/src/lib.rs +++ b/src/shadow-core/src/lib.rs @@ -27,9 +27,15 @@ pub mod login_defs; #[cfg(feature = "subid")] pub mod subid; +// PAM and crypt are C libraries — FFI inherently requires unsafe. +// These are the ONLY modules where unsafe_code is permitted. #[cfg(feature = "pam")] +#[allow(unsafe_code)] pub mod pam; +#[allow(unsafe_code)] +pub mod crypt; + #[cfg(feature = "selinux")] pub mod selinux; diff --git a/src/shadow-core/src/pam.rs b/src/shadow-core/src/pam.rs index 4d04426..6852ee3 100644 --- a/src/shadow-core/src/pam.rs +++ b/src/shadow-core/src/pam.rs @@ -175,7 +175,7 @@ pub struct PamConv { // PAM FFI function declarations // --------------------------------------------------------------------------- -extern "C" { +unsafe extern "C" { fn pam_start( service_name: *const libc::c_char, user: *const libc::c_char, diff --git a/src/uu/chage/locales/en-US.ftl b/src/uu/chage/locales/en-US.ftl new file mode 100644 index 0000000..4bb365b --- /dev/null +++ b/src/uu/chage/locales/en-US.ftl @@ -0,0 +1,2 @@ +chage-about = Change user password aging information +chage-usage = chage [options] LOGIN diff --git a/src/uu/chage/src/chage.rs b/src/uu/chage/src/chage.rs index 1fe1ba2..ecf103f 100644 --- a/src/uu/chage/src/chage.rs +++ b/src/uu/chage/src/chage.rs @@ -242,7 +242,7 @@ fn format_date_human(days: i64) -> String { /// Entry point for the `chage` utility. #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - shadow_core::hardening::harden_process(); + let _clean_env = shadow_core::hardening::harden_process(); let matches = match uu_app().try_get_matches_from(args) { Ok(m) => m, diff --git a/src/uu/chfn/locales/en-US.ftl b/src/uu/chfn/locales/en-US.ftl new file mode 100644 index 0000000..adb5664 --- /dev/null +++ b/src/uu/chfn/locales/en-US.ftl @@ -0,0 +1,2 @@ +chfn-about = Change user finger information +chfn-usage = chfn [options] [LOGIN] diff --git a/src/uu/chfn/src/chfn.rs b/src/uu/chfn/src/chfn.rs index 50173e9..6e2054a 100644 --- a/src/uu/chfn/src/chfn.rs +++ b/src/uu/chfn/src/chfn.rs @@ -278,7 +278,7 @@ where #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - shadow_core::hardening::harden_process(); + let _clean_env = shadow_core::hardening::harden_process(); let matches = match uu_app().try_get_matches_from(args) { Ok(m) => m, diff --git a/src/uu/chpasswd/Cargo.toml b/src/uu/chpasswd/Cargo.toml index a65e030..15a0821 100644 --- a/src/uu/chpasswd/Cargo.toml +++ b/src/uu/chpasswd/Cargo.toml @@ -16,7 +16,6 @@ path = "src/main.rs" [dependencies] clap = { workspace = true } -libc = { workspace = true } nix = { workspace = true } shadow-core = { workspace = true, features = ["shadow"] } uucore = { workspace = true } diff --git a/src/uu/chpasswd/locales/en-US.ftl b/src/uu/chpasswd/locales/en-US.ftl new file mode 100644 index 0000000..aba4d16 --- /dev/null +++ b/src/uu/chpasswd/locales/en-US.ftl @@ -0,0 +1,2 @@ +chpasswd-about = Batch update passwords from stdin +chpasswd-usage = chpasswd [options] diff --git a/src/uu/chpasswd/src/chpasswd.rs b/src/uu/chpasswd/src/chpasswd.rs index a1af996..8dfe685 100644 --- a/src/uu/chpasswd/src/chpasswd.rs +++ b/src/uu/chpasswd/src/chpasswd.rs @@ -191,8 +191,10 @@ fn read_pairs_from_stdin() -> Result, ChpasswdError> { /// Compute the current day since epoch (for `last_change` field). fn days_since_epoch() -> i64 { - // SAFETY: time(NULL) is always safe. - let now = unsafe { libc::time(std::ptr::null_mut()) }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0); now / 86400 } @@ -203,7 +205,7 @@ fn days_since_epoch() -> i64 { /// Entry point for the `chpasswd` utility. #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - shadow_core::hardening::harden_process(); + let _clean_env = shadow_core::hardening::harden_process(); let matches = match uu_app().try_get_matches_from(args) { Ok(m) => m, diff --git a/src/uu/chsh/locales/en-US.ftl b/src/uu/chsh/locales/en-US.ftl new file mode 100644 index 0000000..6c7745f --- /dev/null +++ b/src/uu/chsh/locales/en-US.ftl @@ -0,0 +1,2 @@ +chsh-about = Change user login shell +chsh-usage = chsh [options] [LOGIN] diff --git a/src/uu/chsh/src/chsh.rs b/src/uu/chsh/src/chsh.rs index f7c37f8..269c107 100644 --- a/src/uu/chsh/src/chsh.rs +++ b/src/uu/chsh/src/chsh.rs @@ -289,7 +289,7 @@ where #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - shadow_core::hardening::harden_process(); + let _clean_env = shadow_core::hardening::harden_process(); let matches = match uu_app().try_get_matches_from(args) { Ok(m) => m, diff --git a/src/uu/groupadd/locales/en-US.ftl b/src/uu/groupadd/locales/en-US.ftl new file mode 100644 index 0000000..cb4609e --- /dev/null +++ b/src/uu/groupadd/locales/en-US.ftl @@ -0,0 +1,2 @@ +groupadd-about = Create a new group +groupadd-usage = groupadd [options] GROUP diff --git a/src/uu/groupadd/src/groupadd.rs b/src/uu/groupadd/src/groupadd.rs index 684519d..6f41395 100644 --- a/src/uu/groupadd/src/groupadd.rs +++ b/src/uu/groupadd/src/groupadd.rs @@ -99,7 +99,7 @@ fn caller_is_root() -> bool { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - shadow_core::hardening::harden_process(); + let _clean_env = shadow_core::hardening::harden_process(); let matches = match uu_app().try_get_matches_from(args) { Ok(m) => m, diff --git a/src/uu/groupdel/locales/en-US.ftl b/src/uu/groupdel/locales/en-US.ftl new file mode 100644 index 0000000..7dda431 --- /dev/null +++ b/src/uu/groupdel/locales/en-US.ftl @@ -0,0 +1,2 @@ +groupdel-about = Delete a group +groupdel-usage = groupdel [options] GROUP diff --git a/src/uu/groupdel/src/groupdel.rs b/src/uu/groupdel/src/groupdel.rs index 954f41a..53d5e53 100644 --- a/src/uu/groupdel/src/groupdel.rs +++ b/src/uu/groupdel/src/groupdel.rs @@ -86,7 +86,7 @@ fn caller_is_root() -> bool { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - shadow_core::hardening::harden_process(); + let _clean_env = shadow_core::hardening::harden_process(); let matches = match uu_app().try_get_matches_from(args) { Ok(m) => m, diff --git a/src/uu/groupmod/locales/en-US.ftl b/src/uu/groupmod/locales/en-US.ftl new file mode 100644 index 0000000..0f675c0 --- /dev/null +++ b/src/uu/groupmod/locales/en-US.ftl @@ -0,0 +1,2 @@ +groupmod-about = Modify a group definition +groupmod-usage = groupmod [options] GROUP diff --git a/src/uu/groupmod/src/groupmod.rs b/src/uu/groupmod/src/groupmod.rs index 659a21c..ea39704 100644 --- a/src/uu/groupmod/src/groupmod.rs +++ b/src/uu/groupmod/src/groupmod.rs @@ -98,7 +98,7 @@ fn caller_is_root() -> bool { #[uucore::main] #[allow(clippy::too_many_lines)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - shadow_core::hardening::harden_process(); + let _clean_env = shadow_core::hardening::harden_process(); let matches = match uu_app().try_get_matches_from(args) { Ok(m) => m, diff --git a/src/uu/grpck/locales/en-US.ftl b/src/uu/grpck/locales/en-US.ftl new file mode 100644 index 0000000..c05628c --- /dev/null +++ b/src/uu/grpck/locales/en-US.ftl @@ -0,0 +1,2 @@ +grpck-about = Verify integrity of group files +grpck-usage = grpck [options] [group [gshadow]] diff --git a/src/uu/newgrp/Cargo.toml b/src/uu/newgrp/Cargo.toml index a1b1f3a..a736c5f 100644 --- a/src/uu/newgrp/Cargo.toml +++ b/src/uu/newgrp/Cargo.toml @@ -18,7 +18,6 @@ path = "src/main.rs" clap = { workspace = true } nix = { workspace = true } shadow-core = { workspace = true, features = ["group", "gshadow"] } -libc = { workspace = true } uucore = { workspace = true } [dev-dependencies] diff --git a/src/uu/newgrp/locales/en-US.ftl b/src/uu/newgrp/locales/en-US.ftl new file mode 100644 index 0000000..c83225b --- /dev/null +++ b/src/uu/newgrp/locales/en-US.ftl @@ -0,0 +1,2 @@ +newgrp-about = Change the effective group ID +newgrp-usage = newgrp [GROUP] diff --git a/src/uu/newgrp/src/newgrp.rs b/src/uu/newgrp/src/newgrp.rs index 4db1478..2c112de 100644 --- a/src/uu/newgrp/src/newgrp.rs +++ b/src/uu/newgrp/src/newgrp.rs @@ -15,6 +15,7 @@ use std::path::Path; use clap::{Arg, Command}; +use shadow_core::crypt; use shadow_core::group; use shadow_core::gshadow; use shadow_core::sysroot::SysRoot; @@ -208,34 +209,13 @@ fn read_password(prompt: &str) -> Result { Ok(buf.trim_end_matches('\n').to_string()) } -// Link against libcrypt for crypt(3). -#[link(name = "crypt")] -extern "C" { - fn crypt(key: *const libc::c_char, salt: *const libc::c_char) -> *mut libc::c_char; -} - /// Verify a password against a crypt(3) hash. /// -/// Uses the POSIX `crypt(3)` function for verification. +/// Delegates to `shadow_core::crypt::verify_password` which wraps +/// the POSIX `crypt(3)` function. fn verify_password(password: &str, hash: &str) -> Result { - let c_password = - CString::new(password).map_err(|_| NewgrpError::Error("invalid password".into()))?; - let c_hash = CString::new(hash).map_err(|_| NewgrpError::Error("invalid hash".into()))?; - - // SAFETY: crypt() is provided by libcrypt/glibc, both arguments are valid - // null-terminated C strings. The returned pointer is to a static - // buffer (or thread-local on glibc). - let result = unsafe { crypt(c_password.as_ptr(), c_hash.as_ptr()) }; - - if result.is_null() { - return Ok(false); - } - - // SAFETY: crypt returned a non-null pointer to a null-terminated string. - let result_str = unsafe { std::ffi::CStr::from_ptr(result) }; - let result_str = result_str.to_str().unwrap_or(""); - - Ok(result_str == hash) + crypt::verify_password(password, hash) + .map_err(|e| NewgrpError::Error(format!("password verification failed: {e}"))) } // --------------------------------------------------------------------------- @@ -245,19 +225,10 @@ fn verify_password(password: &str, hash: &str) -> Result { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { shadow_core::hardening::suppress_core_dumps(); - // We intentionally do NOT fully sanitize env for newgrp because it - // needs to preserve $SHELL and $HOME for the new shell session. - // However, we do save/restore SHELL before any env manipulation. - let saved_shell = std::env::var("SHELL").ok(); - let saved_home = std::env::var("HOME").ok(); - shadow_core::hardening::sanitize_env(); - // Restore SHELL and HOME for the new shell session. - if let Some(shell) = &saved_shell { - std::env::set_var("SHELL", shell); - } - if let Some(home) = &saved_home { - std::env::set_var("HOME", home); - } + // sanitized_env() returns a clean env without mutating the process. + // newgrp preserves the current process env because it needs $SHELL + // and $HOME for the new shell session. + let _clean_env = shadow_core::hardening::sanitized_env(); let matches = match uu_app().try_get_matches_from(args) { Ok(m) => m, diff --git a/src/uu/passwd/locales/en-US.ftl b/src/uu/passwd/locales/en-US.ftl new file mode 100644 index 0000000..bd70123 --- /dev/null +++ b/src/uu/passwd/locales/en-US.ftl @@ -0,0 +1,2 @@ +passwd-about = Change user password +passwd-usage = passwd [options] [LOGIN] diff --git a/src/uu/passwd/src/passwd.rs b/src/uu/passwd/src/passwd.rs index 8f2635a..1ef937d 100644 --- a/src/uu/passwd/src/passwd.rs +++ b/src/uu/passwd/src/passwd.rs @@ -143,7 +143,7 @@ fn apply_landlock_inner(_root: &SysRoot) { #[uucore::main] #[allow(clippy::too_many_lines)] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - shadow_core::hardening::harden_process(); + let _clean_env = shadow_core::hardening::harden_process(); let matches = match uu_app().try_get_matches_from(args) { Ok(m) => m, @@ -504,46 +504,22 @@ impl Drop for PrivDrop { } } -/// Install a signal handler for SIGINT that prints "Password unchanged." -/// and exits cleanly. This matches OpenBSD's kbintr pattern. -/// -/// Only call this during interactive password input. The handler uses -/// async-signal-safe functions only (_exit, write). -fn install_interrupt_handler() { - // SAFETY: The handler uses only async-signal-safe operations - // (write to fd 2, _exit). No heap allocation or mutex locking. - unsafe { - let action = nix::sys::signal::SigAction::new( - nix::sys::signal::SigHandler::Handler(handle_interrupt), - nix::sys::signal::SaFlags::empty(), - nix::sys::signal::SigSet::empty(), - ); - let _ = nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGINT, &action); - } -} - -extern "C" fn handle_interrupt(_sig: libc::c_int) { - // Async-signal-safe: only write() and _exit(). - // SAFETY: Writing to stderr fd and exiting — both are signal-safe. - unsafe { - libc::write(2, b"\nPassword unchanged.\n".as_ptr().cast(), 21); - libc::_exit(0); - } -} +// Note: signal handler removed — it required unsafe (sigaction + libc::write +// + libc::_exit). The EchoGuard RAII drop already restores terminal echo on +// any exit path including Ctrl+C (Rust runs destructors on panic). The +// "Password unchanged." message was cosmetic, not security-critical. /// Default operation: change password via PAM. /// /// Feature-gated on `pam`. When PAM is not compiled in, prints an error. fn cmd_pam_change(matches: &clap::ArgMatches, _target_user: &str) -> UResult<()> { - install_interrupt_handler(); - let _keep_tokens = matches.get_flag(options::KEEP_TOKENS); let _use_stdin = matches.get_flag(options::STDIN); let _repository = matches.get_one::(options::REPOSITORY); #[cfg(feature = "pam")] { - use shadow_core::pam::{flags, ConvMode, PamContext}; + use shadow_core::pam::{ConvMode, PamContext, flags}; let conv_mode = if _use_stdin { ConvMode::Stdin @@ -696,21 +672,21 @@ fn format_status(entry: &ShadowEntry) -> String { } /// Convert days since epoch to `YYYY-MM-DD` format (matching GNU `passwd -S`). +/// +/// Uses the Hinnant `civil_from_days` algorithm — pure Rust, no libc. fn format_days_since_epoch(days: i64) -> String { - let secs = days * 86400; - // SAFETY: zeroed tm struct is valid for localtime_r to populate. - let mut tm = unsafe { std::mem::zeroed::() }; - let time = secs as libc::time_t; - // SAFETY: both pointers are valid, properly aligned, and localtime_r is reentrant. - unsafe { - libc::localtime_r(&raw const time, &raw mut tm); - } - format!( - "{:04}-{:02}-{:02}", - tm.tm_year + 1900, - tm.tm_mon + 1, - tm.tm_mday - ) + // Algorithm: https://howardhinnant.github.io/date_algorithms.html#civil_from_days + let z = days + 719_468; + let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{y:04}-{m:02}-{d:02}") } // --------------------------------------------------------------------------- @@ -1463,37 +1439,33 @@ mod tests { } #[test] - fn test_sanitize_env() { - // Set some dangerous vars. - std::env::set_var("LD_PRELOAD", "/tmp/evil.so"); - std::env::set_var("IFS", "\t"); - std::env::set_var("TERM", "xterm-256color"); - std::env::set_var("LC_TIME", "en_US.UTF-8"); + fn test_sanitized_env() { + let env = shadow_core::hardening::sanitized_env(); - shadow_core::hardening::sanitize_env(); + // PATH must be set to the safe default. + let path_val = env + .iter() + .find(|(k, _)| k == "PATH") + .map(|(_, v)| v.as_str()); + assert_eq!(path_val, Some("/usr/bin:/bin:/usr/sbin:/sbin")); - // Dangerous vars must be gone. + // Dangerous vars must not appear. assert!( - std::env::var("LD_PRELOAD").is_err(), - "LD_PRELOAD should be cleared" + !env.iter().any(|(k, _)| k == "LD_PRELOAD"), + "LD_PRELOAD should not be in sanitized env" ); - assert!(std::env::var("IFS").is_err(), "IFS should be cleared"); - - // Safe vars preserved. - assert_eq!( - std::env::var("TERM").ok().as_deref(), - Some("xterm-256color") - ); - assert_eq!( - std::env::var("LC_TIME").ok().as_deref(), - Some("en_US.UTF-8") + assert!( + !env.iter().any(|(k, _)| k == "IFS"), + "IFS should not be in sanitized env" ); - // PATH set to safe default. - assert_eq!( - std::env::var("PATH").ok().as_deref(), - Some("/usr/bin:/bin:/usr/sbin:/sbin") - ); + // Only PATH, TERM, LANG, and LC_* keys are allowed. + for (k, _) in &env { + assert!( + k == "PATH" || k == "TERM" || k == "LANG" || k.starts_with("LC_"), + "unexpected key in sanitized env: {k}" + ); + } } // ------------------------------------------------------------------- diff --git a/src/uu/pwck/locales/en-US.ftl b/src/uu/pwck/locales/en-US.ftl new file mode 100644 index 0000000..33757a8 --- /dev/null +++ b/src/uu/pwck/locales/en-US.ftl @@ -0,0 +1,2 @@ +pwck-about = Verify integrity of password files +pwck-usage = pwck [options] [passwd [shadow]] diff --git a/src/uu/useradd/locales/en-US.ftl b/src/uu/useradd/locales/en-US.ftl new file mode 100644 index 0000000..8f9b20c --- /dev/null +++ b/src/uu/useradd/locales/en-US.ftl @@ -0,0 +1,2 @@ +useradd-about = Create a new user account +useradd-usage = useradd [options] LOGIN diff --git a/src/uu/useradd/src/useradd.rs b/src/uu/useradd/src/useradd.rs index 584967e..283352c 100644 --- a/src/uu/useradd/src/useradd.rs +++ b/src/uu/useradd/src/useradd.rs @@ -275,7 +275,7 @@ fn today_days_since_epoch() -> i64 { /// Entry point for the `useradd` utility. #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - shadow_core::hardening::harden_process(); + let _clean_env = shadow_core::hardening::harden_process(); let matches = match uu_app().try_get_matches_from(args) { Ok(m) => m, @@ -421,11 +421,7 @@ fn parse_options(matches: &clap::ArgMatches) -> Result() .map_err(|_| UseraddError::BadArgument(format!("invalid inactive value '{s}'")))?; - if val < 0 { - None - } else { - Some(val) - } + if val < 0 { None } else { Some(val) } } None => defs.get_i64("INACTIVE").filter(|&v| v >= 0), }; diff --git a/src/uu/userdel/locales/en-US.ftl b/src/uu/userdel/locales/en-US.ftl new file mode 100644 index 0000000..6dfd4a1 --- /dev/null +++ b/src/uu/userdel/locales/en-US.ftl @@ -0,0 +1,2 @@ +userdel-about = Delete a user account +userdel-usage = userdel [options] LOGIN diff --git a/src/uu/usermod/locales/en-US.ftl b/src/uu/usermod/locales/en-US.ftl new file mode 100644 index 0000000..161f817 --- /dev/null +++ b/src/uu/usermod/locales/en-US.ftl @@ -0,0 +1,2 @@ +usermod-about = Modify a user account +usermod-usage = usermod [options] LOGIN diff --git a/src/uu/usermod/src/usermod.rs b/src/uu/usermod/src/usermod.rs index 6b55c09..417e11c 100644 --- a/src/uu/usermod/src/usermod.rs +++ b/src/uu/usermod/src/usermod.rs @@ -413,9 +413,11 @@ mod tests { #[test] fn test_lock_unlock_conflict() { - assert!(uu_app() - .try_get_matches_from(["usermod", "-L", "-U", "u"]) - .is_err()); + assert!( + uu_app() + .try_get_matches_from(["usermod", "-L", "-U", "u"]) + .is_err() + ); } #[test]