mirror of
https://github.com/uutils/shadow.git
synced 2026-06-10 16:14:57 -07:00
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:
+141
@@ -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.**
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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>();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user