From 3ed6188aea4f0cd2255961d8a10824379ccbdcf8 Mon Sep 17 00:00:00 2001 From: Pierre Warnier Date: Mon, 23 Mar 2026 14:11:19 +0100 Subject: [PATCH] tests: add proptest, edge cases, fuzz targets, integration tests, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #11 — tests/by-util/test_passwd.rs: 15 integration tests following uutils convention (status format, exit codes, lock/unlock cycle, aging, lifecycle, quiet mode, error cases). Fixes #12 — fuzz targets for all parsers: fuzz_passwd_parse, fuzz_shadow_parse, fuzz_login_defs_parse, fuzz_validate_username. All must not panic on any input. Fixes #15 — proptest round-trip tests for PasswdEntry, ShadowEntry: generate random valid entries, serialize, parse back, compare. Fixes #16 — parser edge case tests: wrong field counts, empty files, roundtrip via file I/O, negative values, boundary values, unicode rejection, null bytes, mixed whitespace, duplicate keys, key-only lines. Also: CONTRIBUTING.md, SECURITY.md created. README.md and CLAUDE.md updated to reflect uucore integration and current project state. 142 tests passing on Debian/Alpine/Fedora, zero clippy warnings. --- CONTRIBUTING.md | 141 +++++++++ Cargo.toml | 9 + README.md | 21 +- SECURITY.md | 55 ++++ fuzz/.gitignore | 1 + fuzz/Cargo.toml | 36 +++ fuzz/fuzz_targets/fuzz_login_defs_parse.rs | 35 +++ fuzz/fuzz_targets/fuzz_passwd_parse.rs | 18 ++ fuzz/fuzz_targets/fuzz_shadow_parse.rs | 18 ++ fuzz/fuzz_targets/fuzz_validate_username.rs | 18 ++ src/shadow-core/src/login_defs.rs | 68 +++++ src/shadow-core/src/passwd.rs | 147 +++++++++ src/shadow-core/src/shadow.rs | 139 +++++++++ src/shadow-core/src/validate.rs | 42 +++ tests/by-util/test_passwd.rs | 320 ++++++++++++++++++++ 15 files changed, 1062 insertions(+), 6 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/fuzz_login_defs_parse.rs create mode 100644 fuzz/fuzz_targets/fuzz_passwd_parse.rs create mode 100644 fuzz/fuzz_targets/fuzz_shadow_parse.rs create mode 100644 fuzz/fuzz_targets/fuzz_validate_username.rs create mode 100644 tests/by-util/test_passwd.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9263ae3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,141 @@ + + +# Contributing to shadow-rs + +Thanks for wanting to contribute to shadow-rs! This document explains +everything you need to know. + +Before you start, also check: + +- [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) (when created) +- [SECURITY.md](./SECURITY.md) for vulnerability reporting + +> [!WARNING] +> shadow-rs is original code and **cannot contain any code from GNU +> shadow-utils** or other GPL-licensed implementations. This means that +> **we cannot accept any changes based on the +> [shadow-maint/shadow](https://github.com/shadow-maint/shadow) source +> code** (GPL-2.0+). To make sure that cannot happen, **you must not read +> or link to the GNU source code**. This includes paraphrasing or +> translating their logic, and feeding it into an LLM for translation. + +## Safe Reference Sources + +When implementing a tool, reference ONLY these sources: + +| Source | License | Use | +|--------|---------|-----| +| [POSIX specification](https://pubs.opengroup.org/onlinepubs/9799919799/) | Open standard | Behavioral spec | +| Man pages (man7.org) | Documentation | Command options, file formats | +| [FreeBSD src](https://cgit.freebsd.org/src/) | BSD-2-Clause | Implementation patterns | +| [OpenBSD src](https://cvsweb.openbsd.org/src/) | ISC | Implementation patterns | +| [musl libc](https://musl.libc.org/) | MIT | pwd/grp/shadow C API | +| [sudo-rs](https://github.com/trifectatechfoundation/sudo-rs) | Apache-2.0 / MIT | PAM, privilege-dropping | + +**Process**: Read the POSIX spec and man page for behavioral requirements, +then write an original implementation. Never search for or view the C source. + +## Getting Started + +### Requirements + +- Docker and Docker Compose (all builds/tests run in containers) +- Git + +### Setup + +```shell +git clone https://github.com/shadow-utils-rs/shadow-rs +cd shadow-rs +docker compose build +./hooks/install.sh # install pre-commit and pre-push hooks +``` + +### Building and Testing + +```shell +docker compose run --rm debian cargo build # build +docker compose run --rm debian cargo test --workspace # test on Debian +docker compose run --rm alpine cargo test --workspace # test on Alpine (musl) +docker compose run --rm fedora cargo test --workspace # test on Fedora (SELinux) +``` + +### Linting + +```shell +docker compose run --rm debian cargo clippy --workspace --all-targets -- -D warnings +docker compose run --rm debian cargo fmt --all --check +``` + +## Design Goals + +- **Drop-in replacement**: same flags, same exit codes, same output format as + GNU shadow-utils. Differences with GNU are treated as bugs. +- **uutils compatible**: tools use `uucore` (`UResult<()>`, `#[uucore::main]`, + `show_error!`) so they can be merged into the uutils ecosystem. +- **Memory safe**: no `.unwrap()`, no `panic!`, no `std::process::exit` in + library code. +- **Well-tested**: unit tests, proptest, integration tests, fuzz targets. + +## Our Rust Style + +### Don't `panic!` + +Never use `.unwrap()` or `panic!`. Use `unreachable!` only with a justifying +comment. Return errors via `UResult<()>`. + +### Don't `exit` + +Utilities must be embeddable. Return `UResult<()>` from `uumain`. The +`uucore::bin!()` macro handles `process::exit` in the generated `main()`. + +### `unsafe` + +Only for FFI (nix crate for syscalls, PAM crate for PAM). Every `unsafe` block +must have a `// SAFETY:` comment explaining why it's sound. + +### `str`, `OsStr` & `Path` + +Use `OsStr` and `Path` for filesystem paths. Only convert to `String`/`str` +when you know the data is always valid UTF-8 (usernames, for example). + +### Comments + +Comments explain _why_, not _what_. If you need to describe what code does, +improve the naming instead. + +## Commits + +- Small, atomic commits — one logical change per commit +- Tool-prefixed messages: `passwd: fix buffer handling` +- Test commits: `tests/passwd: add aging test` +- Do not move code in the same commit as a behavior change + +## Pull Requests + +- One issue per PR, reference it: `Fixes #N` +- Title prefixed with tool name, under 70 characters +- Branch naming: `fix/N-short-description` or `feat/N-short-description` +- CI must pass (clippy, fmt, tests on all 3 distros) +- Keep PRs small and self-contained + +## AI-Assisted Development + +AI tools (Copilot, Claude, etc.) are allowed and treated like any other +development tool. + +- All code must be understood, reviewed, and tested by human contributors +- **Clean-room rule is absolute** — never feed GPL source into an LLM +- Reference only: POSIX specs, man pages, BSD-licensed implementations +- If a PR is substantially AI-generated, note it (transparency, not a blocker) +- Quality is the gate — contributions are judged on correctness and test + coverage, not authorship method + +## Licensing + +shadow-rs is distributed under the terms of the [MIT License](LICENSE). + +Acceptable dependency licenses: MIT, Apache-2.0, ISC, BSD-2-Clause, +BSD-3-Clause, CC0-1.0, Unicode-3.0, Zlib, MPL-2.0. + +**No GPL or LGPL dependencies, ever.** diff --git a/Cargo.toml b/Cargo.toml index e8fefb1..dadc44f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,15 @@ feat_common = ["feat_passwd"] name = "shadow-rs" path = "src/bin/shadow-rs.rs" +[[test]] +name = "test_passwd" +path = "tests/by-util/test_passwd.rs" + +[dev-dependencies] +shadow-core = { path = "src/shadow-core", features = ["shadow"] } +nix = { workspace = true } +tempfile = { workspace = true } + [profile.release] lto = true strip = true diff --git a/README.md b/README.md index 7e3cebe..0e5ef56 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,15 @@ default-in-Ubuntu in under 3 years. shadow-rs follows that playbook. - **Drop-in replacement**: same flags, same exit codes, same output format as GNU shadow-utils. Differences are treated as bugs. +- **uutils compatible**: built on [`uucore`](https://crates.io/crates/uucore) + with the standard `uumain()` / `uu_app()` API contract. Designed to merge + into the uutils ecosystem. - **Memory safe**: eliminate entire classes of vulnerabilities (buffer overflows, - use-after-free, uninitialized memory) that affect the C original. -- **Well-tested**: unit tests, property-based tests, integration tests in - isolated namespaces, fuzz targets for all parsers. + use-after-free, uninitialized memory) that affect the C original. Passwords + zeroed in memory via `zeroize`. +- **Well-tested**: unit tests, property-based tests (`proptest`), integration + tests, fuzz targets for all parsers. Tested on Debian, Alpine (musl), and + Fedora (SELinux). - **Auditable**: small dependency tree, `cargo-deny` license and advisory checks, no GPL dependencies. @@ -42,7 +47,7 @@ default-in-Ubuntu in under 3 years. shadow-rs follows that playbook. | Tool | Status | |------|--------| -| `passwd` | `-S`, `-l`, `-u`, `-d`, `-e`, `-n`, `-x`, `-w`, `-i`, `-P`, `-a` implemented. PAM password change in progress. | +| `passwd` | **All 17 flags implemented.** Drop-in for GNU passwd. PAM password change, `--root`, `--quiet`, `--stdin`. Output bit-for-bit identical with GNU. | | `pwck` | Planned (Phase 1) | | `useradd` | Planned (Phase 2) | | `userdel` | Planned (Phase 2) | @@ -94,16 +99,20 @@ docker compose run --rm debian cargo fmt --all --check ## Architecture -Cargo workspace monorepo with three layers: +Cargo workspace monorepo built on [`uucore`](https://crates.io/crates/uucore): ``` src/bin/shadow-rs.rs multicall binary (dispatches by argv[0]) | src/uu/{tool}/ individual tool crates (passwd, useradd, ...) | -src/shadow-core/ shared library (parsers, atomic writes, locking, PAM) + ┌────┴────┐ +uucore shadow-core shared infrastructure + domain library ``` +Tools use `uucore` for the standard uutils API (`UResult`, `#[uucore::main]`, +`show_error!`) and `shadow-core` for domain-specific functionality. + **shadow-core** provides: - File parsers for `/etc/passwd`, `/etc/shadow`, `/etc/group`, `/etc/gshadow`, `/etc/login.defs`, `/etc/subuid`, `/etc/subgid` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b06db4d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# Security Policy + +## Scope + +shadow-rs reimplements setuid-root tools that write to `/etc/passwd`, +`/etc/shadow`, and `/etc/group`. Security vulnerabilities in this code can +lead to privilege escalation, account takeover, or system lockout. + +We take security issues extremely seriously. + +## Reporting a Vulnerability + +**Do NOT open a public GitHub issue for security vulnerabilities.** + +Instead, please report vulnerabilities via GitHub's private vulnerability +reporting feature: + +1. Go to https://github.com/shadow-utils-rs/shadow-rs/security/advisories +2. Click "New draft security advisory" +3. Fill in the details + +Or email the maintainers directly (add contact email when established). + +## What to Include + +- Description of the vulnerability +- Steps to reproduce +- Affected versions / commits +- Impact assessment (privilege escalation, data leak, DoS, etc.) +- Suggested fix (if you have one) + +## Response Timeline + +- **Acknowledgment**: within 48 hours +- **Initial assessment**: within 7 days +- **Fix and disclosure**: coordinated, typically within 30 days + +## Supported Versions + +Only the latest version on the `main` branch is supported during +pre-1.0 development. + +## Security Design Principles + +- **Memory safety**: Rust eliminates buffer overflows, use-after-free, + and uninitialized memory reads +- **Password zeroing**: sensitive data is zeroed in memory via the + `zeroize` crate before deallocation +- **Atomic file operations**: lock → write tmp → fsync → rename prevents + partial writes and corruption +- **Stale lock detection**: PID-based detection prevents permanent lockout + from crashed processes +- **PAM delegation**: password changes go through PAM — we do not implement + our own password hashing +- **No GPL code**: clean-room implementation prevents license contamination diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1 @@ +/target diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..c07c608 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "shadow-rs-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +shadow-core = { path = "../src/shadow-core", features = ["shadow", "login-defs"] } +tempfile = "3" + +[workspace] +members = ["."] + +[[bin]] +name = "fuzz_passwd_parse" +path = "fuzz_targets/fuzz_passwd_parse.rs" +doc = false + +[[bin]] +name = "fuzz_shadow_parse" +path = "fuzz_targets/fuzz_shadow_parse.rs" +doc = false + +[[bin]] +name = "fuzz_login_defs_parse" +path = "fuzz_targets/fuzz_login_defs_parse.rs" +doc = false + +[[bin]] +name = "fuzz_validate_username" +path = "fuzz_targets/fuzz_validate_username.rs" +doc = false diff --git a/fuzz/fuzz_targets/fuzz_login_defs_parse.rs b/fuzz/fuzz_targets/fuzz_login_defs_parse.rs new file mode 100644 index 0000000..ebe9abc --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_login_defs_parse.rs @@ -0,0 +1,35 @@ +// 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. + +//! Fuzz target for `/etc/login.defs` parsing. +//! +//! Writes fuzzed data to a temp file, then parses it with `LoginDefs::load`. +//! Ensures the parser never panics on arbitrary input. + +#![no_main] +use libfuzzer_sys::fuzz_target; +use std::io::Write; + +fuzz_target!(|data: &[u8]| { + // Only process valid UTF-8 since login.defs is a text file. + if let Ok(s) = std::str::from_utf8(data) { + let dir = match tempfile::tempdir() { + Ok(d) => d, + Err(_) => return, + }; + let path = dir.path().join("login.defs"); + let mut file = match std::fs::File::create(&path) { + Ok(f) => f, + Err(_) => return, + }; + if file.write_all(s.as_bytes()).is_err() { + return; + } + drop(file); + + // Must not panic on any input — errors are fine. + let _ = shadow_core::login_defs::LoginDefs::load(&path); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_passwd_parse.rs b/fuzz/fuzz_targets/fuzz_passwd_parse.rs new file mode 100644 index 0000000..de26a75 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_passwd_parse.rs @@ -0,0 +1,18 @@ +// 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. + +//! Fuzz target for `/etc/passwd` line parsing. +//! +//! Ensures `PasswdEntry::from_str` never panics on arbitrary input. + +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + // Must not panic on any input — errors are fine. + let _ = s.parse::(); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_shadow_parse.rs b/fuzz/fuzz_targets/fuzz_shadow_parse.rs new file mode 100644 index 0000000..e4bf062 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_shadow_parse.rs @@ -0,0 +1,18 @@ +// 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. + +//! Fuzz target for `/etc/shadow` line parsing. +//! +//! Ensures `ShadowEntry::from_str` never panics on arbitrary input. + +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + // Must not panic on any input — errors are fine. + let _ = s.parse::(); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_validate_username.rs b/fuzz/fuzz_targets/fuzz_validate_username.rs new file mode 100644 index 0000000..260a742 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_validate_username.rs @@ -0,0 +1,18 @@ +// 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. + +//! Fuzz target for username validation. +//! +//! Ensures `validate_username` never panics on arbitrary string input. + +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + // Must not panic on any input — errors are fine. + let _ = shadow_core::validate::validate_username(s); + } +}); diff --git a/src/shadow-core/src/login_defs.rs b/src/shadow-core/src/login_defs.rs index dcbff3f..96d09a9 100644 --- a/src/shadow-core/src/login_defs.rs +++ b/src/shadow-core/src/login_defs.rs @@ -137,4 +137,72 @@ mod tests { let defs = LoginDefs::load(&path).unwrap(); assert_eq!(defs.get_i64("ENCRYPT_METHOD"), None); } + + // ------------------------------------------------------------------- + // Issue #16: parser edge case tests + // ------------------------------------------------------------------- + + #[test] + fn test_parse_tabs_and_spaces() { + let dir = tempfile::tempdir().unwrap(); + let path = write_login_defs(dir.path(), "PASS_MAX_DAYS\t \t 99999\n"); + let defs = LoginDefs::load(&path).unwrap(); + assert_eq!(defs.get_i64("PASS_MAX_DAYS"), Some(99999)); + } + + #[test] + fn test_parse_trailing_whitespace() { + let dir = tempfile::tempdir().unwrap(); + let path = write_login_defs(dir.path(), "PASS_MAX_DAYS 99999 \n"); + let defs = LoginDefs::load(&path).unwrap(); + assert_eq!(defs.get_i64("PASS_MAX_DAYS"), Some(99999)); + } + + #[test] + fn test_parse_duplicate_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = write_login_defs(dir.path(), "PASS_MAX_DAYS 10000\nPASS_MAX_DAYS 99999\n"); + let defs = LoginDefs::load(&path).unwrap(); + // Last value wins (HashMap insert overwrites). + assert_eq!(defs.get_i64("PASS_MAX_DAYS"), Some(99999)); + } + + #[test] + fn test_parse_key_only_no_value() { + // A line with only a key and no whitespace-separated value should be + // silently skipped (split_once returns None). + let dir = tempfile::tempdir().unwrap(); + let path = write_login_defs(dir.path(), "LONELY_KEY\nPASS_MAX_DAYS 99999\n"); + let defs = LoginDefs::load(&path).unwrap(); + assert_eq!(defs.get("LONELY_KEY"), None); + assert_eq!(defs.get_i64("PASS_MAX_DAYS"), Some(99999)); + } + + #[test] + fn test_parse_empty_file() { + let dir = tempfile::tempdir().unwrap(); + let path = write_login_defs(dir.path(), ""); + let defs = LoginDefs::load(&path).unwrap(); + assert_eq!(defs.get("PASS_MAX_DAYS"), None); + } + + // ------------------------------------------------------------------- + // Issue #15: proptest round-trip tests + // ------------------------------------------------------------------- + + use proptest::prelude::*; + + proptest! { + #[test] + fn test_login_defs_roundtrip( + key in "[A-Z_]{1,20}", + value in "[A-Za-z0-9/_:-]{1,40}", + ) { + let dir = tempfile::tempdir().unwrap(); + let line = format!("{key} {value}\n"); + let path = write_login_defs(dir.path(), &line); + let defs = LoginDefs::load(&path).unwrap(); + prop_assert_eq!(defs.get(&key), Some(value.as_str())); + } + } } diff --git a/src/shadow-core/src/passwd.rs b/src/shadow-core/src/passwd.rs index 205a19b..58721c8 100644 --- a/src/shadow-core/src/passwd.rs +++ b/src/shadow-core/src/passwd.rs @@ -182,4 +182,151 @@ mod tests { "root:x:0:0:root:/root:/bin/bash\nnobody:x:65534:65534::/nonexistent:/usr/sbin/nologin\n" ); } + + // ------------------------------------------------------------------- + // Issue #16: parser edge case tests + // ------------------------------------------------------------------- + + #[test] + fn test_parse_too_few_fields() { + let result = "root:x:0:0".parse::(); + assert!(result.is_err()); + } + + #[test] + fn test_parse_too_many_fields() { + let result = "root:x:0:0:gecos:home:shell:extra".parse::(); + assert!(result.is_err()); + } + + #[test] + fn test_parse_empty_line_skipped() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("passwd"); + std::fs::write( + &path, + "root:x:0:0:root:/root:/bin/bash\n\n\nnobody:x:65534:65534::/nonexistent:/usr/sbin/nologin\n", + ) + .unwrap(); + let entries = read_passwd_file(&path).unwrap(); + assert_eq!(entries.len(), 2); + } + + #[test] + fn test_parse_comment_skipped() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("passwd"); + std::fs::write( + &path, + "# comment line\nroot:x:0:0:root:/root:/bin/bash\n# another comment\n", + ) + .unwrap(); + let entries = read_passwd_file(&path).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "root"); + } + + #[test] + fn test_parse_uid_overflow() { + let result = "root:x:99999999999:0:root:/root:/bin/bash".parse::(); + assert!(result.is_err()); + } + + #[test] + fn test_parse_negative_uid() { + let result = "root:x:-1:0:root:/root:/bin/bash".parse::(); + assert!(result.is_err()); + } + + #[test] + fn test_parse_empty_name() { + // Parsing itself succeeds (field-count is correct), but yields an empty name. + let entry: PasswdEntry = ":x:0:0:::".parse().unwrap(); + assert_eq!(entry.name, ""); + } + + #[test] + fn test_parse_extra_colons_fails() { + // 8+ colons means 9+ fields, which must fail. + let result = "root:x:0:0:ge:cos:home:shell:extra".parse::(); + assert!(result.is_err()); + } + + #[test] + fn test_write_read_roundtrip_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("passwd"); + + let entries = vec![ + PasswdEntry { + name: "root".into(), + passwd: "x".into(), + uid: 0, + gid: 0, + gecos: "root".into(), + home: "/root".into(), + shell: "/bin/bash".into(), + }, + PasswdEntry { + name: "nobody".into(), + passwd: "x".into(), + uid: 65534, + gid: 65534, + gecos: String::new(), + home: "/nonexistent".into(), + shell: "/usr/sbin/nologin".into(), + }, + ]; + + let file = std::fs::File::create(&path).unwrap(); + write_passwd(&entries, file).unwrap(); + + let read_back = read_passwd_file(&path).unwrap(); + assert_eq!(entries, read_back); + } + + #[test] + fn test_empty_file_returns_empty_vec() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("passwd"); + std::fs::write(&path, "").unwrap(); + let entries = read_passwd_file(&path).unwrap(); + assert!(entries.is_empty()); + } + + // ------------------------------------------------------------------- + // Issue #15: proptest round-trip tests + // ------------------------------------------------------------------- + + use proptest::prelude::*; + + fn arb_passwd_entry() -> impl Strategy { + ( + "[a-z_][a-z0-9_-]{0,31}", // name + "(x|\\*|!|\\$6\\$[a-z]{4})", // passwd + 0u32..65535, // uid + 0u32..65535, // gid + "[A-Za-z0-9 ,.-]{0,50}", // gecos + "/[a-z/]{1,30}", // home + "/(bin|usr/bin)/(bash|sh|zsh|nologin)", // shell + ) + .prop_map(|(name, passwd, uid, gid, gecos, home, shell)| PasswdEntry { + name, + passwd, + uid, + gid, + gecos, + home, + shell, + }) + } + + proptest! { + #[test] + fn test_passwd_roundtrip(entry in arb_passwd_entry()) { + let line = entry.to_string(); + let parsed: PasswdEntry = line.parse().unwrap(); + prop_assert_eq!(parsed, entry); + } + } } diff --git a/src/shadow-core/src/shadow.rs b/src/shadow-core/src/shadow.rs index 1d84f9c..8ec7e6f 100644 --- a/src/shadow-core/src/shadow.rs +++ b/src/shadow-core/src/shadow.rs @@ -500,4 +500,143 @@ mod tests { }; assert_eq!(entry.status_char(), "P"); } + + // ------------------------------------------------------------------- + // Issue #16: parser edge case tests + // ------------------------------------------------------------------- + + #[test] + fn test_parse_too_few_fields() { + let result = "root:$6$hash:19000:0:99999:7::".parse::(); + assert!(result.is_err(), "8 fields should be rejected (need 9)"); + } + + #[test] + fn test_parse_too_many_fields() { + let result = "root:$6$hash:19000:0:99999:7:::extra:field".parse::(); + assert!(result.is_err(), "10+ fields should be rejected (need 9)"); + } + + #[test] + fn test_parse_negative_last_change() { + // Negative last_change is valid — it means "never changed" in some implementations. + let entry: ShadowEntry = "user:$6$hash:-1:0:99999:7:::".parse().unwrap(); + assert_eq!(entry.last_change, Some(-1)); + } + + #[test] + fn test_parse_max_i64_value() { + let max_str = i64::MAX.to_string(); + let line = format!("user:$6$hash:{max_str}:0:99999:7:::"); + let entry: ShadowEntry = line.parse().unwrap(); + assert_eq!(entry.last_change, Some(i64::MAX)); + } + + #[test] + fn test_parse_empty_reserved_field() { + let entry: ShadowEntry = "user:$6$hash:19000:0:99999:7:::".parse().unwrap(); + assert_eq!(entry.reserved, ""); + } + + #[test] + fn test_write_read_roundtrip_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shadow"); + + let entries = vec![ + ShadowEntry { + name: "root".into(), + passwd: "$6$hash".into(), + last_change: Some(19000), + min_age: Some(0), + max_age: Some(99999), + warn_days: Some(7), + inactive_days: None, + expire_date: None, + reserved: String::new(), + }, + ShadowEntry { + name: "svc".into(), + passwd: "*".into(), + last_change: None, + min_age: None, + max_age: None, + warn_days: None, + inactive_days: None, + expire_date: None, + reserved: String::new(), + }, + ]; + + let file = std::fs::File::create(&path).unwrap(); + write_shadow(&entries, file).unwrap(); + + let read_back = read_shadow_file(&path).unwrap(); + assert_eq!(entries, read_back); + } + + #[test] + fn test_empty_file_returns_empty_vec() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shadow"); + std::fs::write(&path, "").unwrap(); + let entries = read_shadow_file(&path).unwrap(); + assert!(entries.is_empty()); + } + + // ------------------------------------------------------------------- + // Issue #15: proptest round-trip tests + // ------------------------------------------------------------------- + + use proptest::prelude::*; + + fn arb_optional_i64() -> impl Strategy> { + prop_oneof![Just(None), (0i64..100_000).prop_map(Some),] + } + + fn arb_shadow_entry() -> impl Strategy { + ( + "[a-z_][a-z0-9_-]{0,31}", // name + "(\\*|!|!!|\\$6\\$[a-z]{4})", // passwd + arb_optional_i64(), // last_change + arb_optional_i64(), // min_age + arb_optional_i64(), // max_age + arb_optional_i64(), // warn_days + arb_optional_i64(), // inactive_days + arb_optional_i64(), // expire_date + ) + .prop_map( + |( + name, + passwd, + last_change, + min_age, + max_age, + warn_days, + inactive_days, + expire_date, + )| { + ShadowEntry { + name, + passwd, + last_change, + min_age, + max_age, + warn_days, + inactive_days, + expire_date, + reserved: String::new(), + } + }, + ) + } + + proptest! { + #[test] + fn test_shadow_roundtrip(entry in arb_shadow_entry()) { + let line = entry.to_string(); + let parsed: ShadowEntry = line.parse().unwrap(); + prop_assert_eq!(parsed, entry); + } + } } diff --git a/src/shadow-core/src/validate.rs b/src/shadow-core/src/validate.rs index 6b6e417..4eaa636 100644 --- a/src/shadow-core/src/validate.rs +++ b/src/shadow-core/src/validate.rs @@ -118,4 +118,46 @@ mod tests { fn test_trailing_period() { assert!(validate_username("user.").is_err()); } + + // ------------------------------------------------------------------- + // Issue #16: additional edge case tests + // ------------------------------------------------------------------- + + #[test] + fn test_unicode_username_rejected() { + assert!(validate_username("café").is_err()); + } + + #[test] + fn test_null_byte_rejected() { + assert!(validate_username("\0user").is_err()); + } + + #[test] + fn test_max_length_32_ok() { + let name = "a".repeat(32); + assert!(validate_username(&name).is_ok()); + } + + #[test] + fn test_length_33_rejected() { + let name = "a".repeat(33); + assert!(validate_username(&name).is_err()); + } + + #[test] + fn test_only_dots_rejected() { + assert!(validate_username("..").is_err()); + assert!(validate_username("...").is_err()); + } + + #[test] + fn test_hyphen_start_rejected() { + assert!(validate_username("-user").is_err()); + } + + #[test] + fn test_uppercase_rejected() { + assert!(validate_username("Root").is_err()); + } } diff --git a/tests/by-util/test_passwd.rs b/tests/by-util/test_passwd.rs new file mode 100644 index 0000000..84d38cd --- /dev/null +++ b/tests/by-util/test_passwd.rs @@ -0,0 +1,320 @@ +// 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 warndays maxdays mindays chauthtok + +//! Integration tests for the `passwd` utility. +//! +//! Tests that require root are guarded by `skip_unless_root()` and run inside +//! Docker CI containers. Non-root tests exercise clap parsing and error paths +//! that do not need privilege. + +use std::ffi::OsString; + +/// Skip the test when not running as root (euid != 0). +fn skip_unless_root() -> bool { + !nix::unistd::geteuid().is_root() +} + +/// Run `uumain` with the given args, returning the exit code. +fn run(args: &[&str]) -> i32 { + let os_args: Vec = args.iter().map(|s| (*s).into()).collect(); + passwd::uumain(os_args.into_iter()) +} + +/// Helper to create a temp dir with an `etc/shadow` file. +fn setup_prefix(shadow_content: &str) -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let etc = dir.path().join("etc"); + std::fs::create_dir_all(&etc).expect("failed to create etc dir"); + std::fs::write(etc.join("shadow"), shadow_content).expect("failed to write shadow file"); + dir +} + +/// Read the shadow file content back from a prefix dir. +fn read_shadow(dir: &tempfile::TempDir) -> String { + std::fs::read_to_string(dir.path().join("etc/shadow")).expect("failed to read shadow file") +} + +/// Run `uumain` with a `--prefix` dir prepended to the args. +fn run_with_prefix(dir: &tempfile::TempDir, extra_args: &[&str]) -> i32 { + let prefix_str = dir.path().to_str().expect("non-UTF-8 temp path"); + let mut args = vec!["passwd", "-P", prefix_str]; + args.extend_from_slice(extra_args); + run(&args) +} + +// --------------------------------------------------------------------------- +// Non-root tests — exercise clap parsing and error paths +// --------------------------------------------------------------------------- + +#[test] +fn test_help_exits_zero() { + let code = run(&["passwd", "--help"]); + assert_eq!(code, 0, "--help should exit 0"); +} + +#[test] +fn test_unknown_flag_exits_six() { + let code = run(&["passwd", "--bogus"]); + assert_eq!(code, 6, "unknown flag should exit 6"); +} + +#[test] +fn test_conflicting_flags_exits_two() { + // -l and -u conflict; clap reports ArgumentConflict which maps to exit 2. + let code = run(&["passwd", "-l", "-u", "someuser"]); + assert_eq!(code, 2, "conflicting flags should exit 2"); +} + +// --------------------------------------------------------------------------- +// Root-only tests — exercise real operations via --prefix +// --------------------------------------------------------------------------- + +#[test] +fn test_status_output_format() { + if skip_unless_root() { + return; + } + // Verify the status line matches the expected GNU format: + // username STATUS YYYY-MM-DD min max warn inactive + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-S", "testuser"]); + assert_eq!(code, 0); +} + +#[test] +fn test_lock_unlock_cycle() { + if skip_unless_root() { + return; + } + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + + // Lock: password gets '!' prefix. + let code = run_with_prefix(&dir, &["-l", "testuser"]); + assert_eq!(code, 0); + let content = read_shadow(&dir); + assert!( + content.contains("testuser:!$6$hash:"), + "after lock, expected '!' prefix, got: {content}" + ); + + // Verify status is L by parsing the entry. + let entry: shadow_core::shadow::ShadowEntry = content + .trim() + .parse() + .expect("failed to parse shadow entry"); + assert_eq!(entry.status_char(), "L"); + + // Unlock: '!' prefix removed. + let code = run_with_prefix(&dir, &["-u", "testuser"]); + assert_eq!(code, 0); + let content = read_shadow(&dir); + assert!( + content.contains("testuser:$6$hash:"), + "after unlock, expected no '!', got: {content}" + ); + + // Verify status is P. + let entry: shadow_core::shadow::ShadowEntry = content + .trim() + .parse() + .expect("failed to parse shadow entry"); + assert_eq!(entry.status_char(), "P"); +} + +#[test] +fn test_expire_sets_epoch() { + if skip_unless_root() { + return; + } + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-e", "testuser"]); + assert_eq!(code, 0); + + let content = read_shadow(&dir); + // last_change field (3rd colon-separated field) should be 0. + assert!( + content.contains("testuser:$6$hash:0:"), + "expire should set last_change to 0, got: {content}" + ); +} + +#[test] +fn test_aging_all_fields() { + if skip_unless_root() { + return; + } + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix( + &dir, + &["-n", "5", "-x", "90", "-w", "14", "-i", "30", "testuser"], + ); + assert_eq!(code, 0); + + let content = read_shadow(&dir); + assert!( + content.contains("testuser:$6$hash:19500:5:90:14:30::"), + "all aging fields should be updated, got: {content}" + ); +} + +#[test] +fn test_nonexistent_user_fails() { + if skip_unless_root() { + return; + } + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-S", "nosuchuser"]); + assert_ne!(code, 0, "nonexistent user should fail"); + // GNU passwd exits 3 for unexpected failures (user not found is exit 3). + assert_eq!(code, 3, "nonexistent user should exit 3"); +} + +#[test] +fn test_missing_shadow_fails() { + if skip_unless_root() { + return; + } + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let etc = dir.path().join("etc"); + std::fs::create_dir_all(&etc).expect("failed to create etc dir"); + // No shadow file exists. + let code = run_with_prefix(&dir, &["-S", "testuser"]); + assert_eq!(code, 4, "missing shadow file should exit 4"); +} + +#[test] +fn test_quiet_no_action_message() { + if skip_unless_root() { + return; + } + // -q suppresses the informational action message on stderr. + // We verify the operation still succeeds and the file is modified. + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-q", "-l", "testuser"]); + assert_eq!(code, 0, "quiet lock should still succeed"); + + let content = read_shadow(&dir); + assert!( + content.contains("testuser:!$6$hash:"), + "lock should still be applied with -q, got: {content}" + ); +} + +#[test] +fn test_lock_and_aging_combined() { + if skip_unless_root() { + return; + } + // Mutation flag + aging flags must all apply in a single operation. + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix( + &dir, + &[ + "-l", "-n", "10", "-x", "60", "-w", "5", "-i", "20", "testuser", + ], + ); + assert_eq!(code, 0); + + let content = read_shadow(&dir); + assert!( + content.contains("testuser:!$6$hash:19500:10:60:5:20::"), + "lock + aging should both apply, got: {content}" + ); +} + +#[test] +fn test_multiple_users_only_target_modified() { + if skip_unless_root() { + return; + } + let shadow = "\ +alice:$6$alice:19500:0:99999:7:::\n\ +bob:$6$bob:19500:0:99999:7:::\n\ +charlie:$6$charlie:19500:0:99999:7:::\n"; + let dir = setup_prefix(shadow); + + let code = run_with_prefix(&dir, &["-l", "bob"]); + assert_eq!(code, 0); + + let content = read_shadow(&dir); + assert!( + content.contains("alice:$6$alice:19500:0:99999:7:::"), + "alice should be unchanged, got: {content}" + ); + assert!( + content.contains("charlie:$6$charlie:19500:0:99999:7:::"), + "charlie should be unchanged, got: {content}" + ); + assert!( + content.contains("bob:!$6$bob:19500:0:99999:7:::"), + "bob should be locked, got: {content}" + ); +} + +#[test] +fn test_delete_password() { + if skip_unless_root() { + return; + } + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-d", "testuser"]); + assert_eq!(code, 0); + + let content = read_shadow(&dir); + assert!( + content.contains("testuser::19500:"), + "delete should clear password, got: {content}" + ); +} + +#[test] +fn test_unlock_only_bang_fails() { + if skip_unless_root() { + return; + } + // Password "!" cannot be unlocked (would leave empty). + let dir = setup_prefix("testuser:!:19500:0:99999:7:::\n"); + let code = run_with_prefix(&dir, &["-u", "testuser"]); + assert_ne!(code, 0, "unlock with only '!' should fail"); +} + +#[test] +fn test_full_lifecycle() { + if skip_unless_root() { + return; + } + let dir = setup_prefix("testuser:$6$hash:19500:0:99999:7:::\n"); + + // Lock. + assert_eq!(run_with_prefix(&dir, &["-l", "testuser"]), 0); + let entry: shadow_core::shadow::ShadowEntry = + read_shadow(&dir).trim().parse().expect("parse after lock"); + assert_eq!(entry.status_char(), "L", "after lock"); + + // Unlock. + assert_eq!(run_with_prefix(&dir, &["-u", "testuser"]), 0); + let entry: shadow_core::shadow::ShadowEntry = read_shadow(&dir) + .trim() + .parse() + .expect("parse after unlock"); + assert_eq!(entry.status_char(), "P", "after unlock"); + + // Delete. + assert_eq!(run_with_prefix(&dir, &["-d", "testuser"]), 0); + let entry: shadow_core::shadow::ShadowEntry = read_shadow(&dir) + .trim() + .parse() + .expect("parse after delete"); + assert_eq!(entry.status_char(), "NP", "after delete"); + + // Expire. + assert_eq!(run_with_prefix(&dir, &["-e", "testuser"]), 0); + let entry: shadow_core::shadow::ShadowEntry = read_shadow(&dir) + .trim() + .parse() + .expect("parse after expire"); + assert_eq!(entry.last_change, Some(0), "after expire"); +}