tests: add proptest, edge cases, fuzz targets, integration tests, docs

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.
This commit is contained in:
Pierre Warnier
2026-03-23 14:11:19 +01:00
parent ca9b31249e
commit 3ed6188aea
15 changed files with 1062 additions and 6 deletions
+141
View File
@@ -0,0 +1,141 @@
<!-- spell-checker:ignore reimplementing setuid subuid subgid gshadow -->
# 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.**
+9
View File
@@ -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
+15 -6
View File
@@ -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`
+55
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
/target
+36
View File
@@ -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
@@ -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);
}
});
+18
View File
@@ -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::<shadow_core::passwd::PasswdEntry>();
}
});
+18
View File
@@ -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::<shadow_core::shadow::ShadowEntry>();
}
});
@@ -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);
}
});
+68
View File
@@ -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()));
}
}
}
+147
View File
@@ -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::<PasswdEntry>();
assert!(result.is_err());
}
#[test]
fn test_parse_too_many_fields() {
let result = "root:x:0:0:gecos:home:shell:extra".parse::<PasswdEntry>();
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::<PasswdEntry>();
assert!(result.is_err());
}
#[test]
fn test_parse_negative_uid() {
let result = "root:x:-1:0:root:/root:/bin/bash".parse::<PasswdEntry>();
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::<PasswdEntry>();
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<Value = PasswdEntry> {
(
"[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);
}
}
}
+139
View File
@@ -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::<ShadowEntry>();
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::<ShadowEntry>();
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<Value = Option<i64>> {
prop_oneof![Just(None), (0i64..100_000).prop_map(Some),]
}
fn arb_shadow_entry() -> impl Strategy<Value = ShadowEntry> {
(
"[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);
}
}
}
+42
View File
@@ -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());
}
}
+320
View File
@@ -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<OsString> = 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");
}