rusty-pwgen 0.1.0

Generate pronounceable or random passwords from the OS CSPRNG — a Rust port of Theodore Ts'o's `pwgen` with strict-compat mode, deterministic `-H` reproducible mode (SHA256 + ChaCha20), and a typed library API.
Documentation
//! US1 (Pronounceable mode, P1) + US2 (Secure mode, P1) integration tests.

mod common;

use common::{assert_only_ascii, rusty_pwgen_cmd};

#[test]
fn default_invocation_emits_one_8char_password() {
    // US1 AS2 / SC-001: piped stdout → one 8-char pronounceable password.
    let output = rusty_pwgen_cmd().assert().success().get_output().clone();
    let stdout = String::from_utf8_lossy(&output.stdout);
    let lines: Vec<&str> = stdout.lines().collect();
    assert_eq!(
        lines.len(),
        1,
        "piped stdout → exactly one password; got {lines:?}"
    );
    assert_eq!(lines[0].len(), 8, "default length is 8");
    assert_only_ascii(&output.stdout);
}

#[test]
fn explicit_length_12_count_5() {
    let output = rusty_pwgen_cmd()
        .arg("12")
        .arg("5")
        .assert()
        .success()
        .get_output()
        .clone();
    let lines: Vec<&str> = std::str::from_utf8(&output.stdout)
        .unwrap()
        .lines()
        .collect();
    assert_eq!(lines.len(), 5);
    for line in &lines {
        assert_eq!(line.len(), 12, "expected 12-char passwords; got {line:?}");
    }
}

#[test]
fn consecutive_runs_differ() {
    // SC-002: two consecutive runs at default settings produce different passwords.
    let a = rusty_pwgen_cmd()
        .arg("12")
        .assert()
        .success()
        .get_output()
        .clone();
    let b = rusty_pwgen_cmd()
        .arg("12")
        .assert()
        .success()
        .get_output()
        .clone();
    assert_ne!(
        a.stdout, b.stdout,
        "consecutive default-mode runs must differ"
    );
}

#[test]
fn default_charset_is_alphanumeric_no_symbols() {
    let output = rusty_pwgen_cmd()
        .arg("16")
        .arg("10")
        .assert()
        .success()
        .get_output()
        .clone();
    for byte in output.stdout.iter() {
        if byte.is_ascii_whitespace() {
            continue;
        }
        assert!(
            byte.is_ascii_alphanumeric(),
            "default charset (no -y) must be alphanumeric only; got {:?}",
            *byte as char
        );
    }
}

#[test]
fn secure_mode_alphanumeric_with_uniform_sample() {
    // US2 AS1 / SC-004: -s -> uniform-random from [a-zA-Z0-9].
    let output = rusty_pwgen_cmd()
        .arg("-s")
        .arg("16")
        .arg("100")
        .assert()
        .success()
        .get_output()
        .clone();
    let s = std::str::from_utf8(&output.stdout).unwrap();
    for line in s.lines() {
        assert_eq!(line.len(), 16);
        assert!(
            line.bytes().all(|b| b.is_ascii_alphanumeric()),
            "secure default → alphanumeric only; got {line:?}"
        );
    }
}

#[test]
fn secure_with_symbols_can_include_symbol_chars() {
    let output = rusty_pwgen_cmd()
        .arg("-s")
        .arg("-y")
        .arg("32")
        .arg("100")
        .assert()
        .success()
        .get_output()
        .clone();
    let s = std::str::from_utf8(&output.stdout).unwrap();
    // Statistical: with 100 32-char passwords from a 94-char set with ~31/94 symbol
    // probability per byte, ≥1 symbol byte is overwhelmingly likely.
    let has_symbol = s
        .bytes()
        .any(|b| !b.is_ascii_alphanumeric() && b != b'\n' && b != b'\r' && b.is_ascii());
    assert!(
        has_symbol,
        "with -y, expected at least one symbol char in 3200 bytes"
    );
}

#[test]
fn ambiguous_filter_drops_l1_oi0() {
    // SC-005: -B excludes l 1 0 O I.
    let output = rusty_pwgen_cmd()
        .arg("-s")
        .arg("-B")
        .arg("16")
        .arg("100")
        .assert()
        .success()
        .get_output()
        .clone();
    for byte in output.stdout.iter() {
        if byte.is_ascii_whitespace() {
            continue;
        }
        assert!(
            !b"l1O0I".contains(byte),
            "ambiguous char '{}' in output despite -B",
            *byte as char
        );
    }
}

#[test]
fn no_vowels_implies_secure_and_excludes_vowels() {
    // SC-006: -v drops vowels, silently implies -s.
    let output = rusty_pwgen_cmd()
        .arg("-v")
        .arg("16")
        .arg("50")
        .assert()
        .success()
        .get_output()
        .clone();
    for byte in output.stdout.iter() {
        if byte.is_ascii_whitespace() {
            continue;
        }
        assert!(
            !b"aeiouAEIOU".contains(byte),
            "vowel '{}' in -v output",
            *byte as char
        );
    }
}

#[test]
fn no_capitalize_and_no_numerals_lowercase_only() {
    // US3 AS1: -A -0 in pronounceable mode → lowercase only.
    let output = rusty_pwgen_cmd()
        .arg("-A")
        .arg("-0")
        .arg("12")
        .arg("20")
        .assert()
        .success()
        .get_output()
        .clone();
    for byte in output.stdout.iter() {
        if byte.is_ascii_whitespace() {
            continue;
        }
        assert!(
            byte.is_ascii_lowercase(),
            "expected lowercase only with -A -0; got '{}'",
            *byte as char
        );
    }
}

#[test]
fn default_mode_rejects_conflicting_caps_flags() {
    // SC-008: -c -A in Default mode → exit 2 + parse error.
    let output = rusty_pwgen_cmd()
        .arg("-c")
        .arg("-A")
        .arg("8")
        .arg("1")
        .assert()
        .failure()
        .get_output()
        .clone();
    assert_eq!(output.status.code(), Some(2));
}

#[test]
fn default_mode_rejects_conflicting_one_vs_columnar_flags() {
    let output = rusty_pwgen_cmd()
        .arg("-1")
        .arg("-C")
        .arg("8")
        .arg("1")
        .assert()
        .failure()
        .get_output()
        .clone();
    assert_eq!(output.status.code(), Some(2));
}

#[test]
fn length_below_5_falls_back_to_secure_with_warning() {
    // FR-032: length < 5 → secure mode + Default-mode warning.
    let output = rusty_pwgen_cmd()
        .arg("3")
        .arg("5")
        .assert()
        .success()
        .get_output()
        .clone();
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("using secure mode"),
        "expected Default-mode warning for length < 5; got: {stderr:?}"
    );
    // The 5 passwords on stdout should all be 3-char.
    for line in std::str::from_utf8(&output.stdout).unwrap().lines() {
        assert_eq!(line.len(), 3);
    }
}

#[test]
fn force_one_column_via_dash_one() {
    // FR-020: -1 forces one password per line.
    let output = rusty_pwgen_cmd()
        .arg("-1")
        .arg("8")
        .arg("5")
        .assert()
        .success()
        .get_output()
        .clone();
    let lines: Vec<&str> = std::str::from_utf8(&output.stdout)
        .unwrap()
        .lines()
        .collect();
    assert_eq!(lines.len(), 5);
    for line in &lines {
        // Single-column → no internal spaces between passwords.
        assert!(!line.contains(' '));
    }
}

#[test]
fn force_columnar_via_dash_capital_c() {
    // FR-021: -C forces columnar output even when piped.
    let output = rusty_pwgen_cmd()
        .arg("-C")
        .arg("8")
        .arg("16")
        .env("RUSTY_PWGEN_TEST_COLUMNS", "80")
        .assert()
        .success()
        .get_output()
        .clone();
    let s = std::str::from_utf8(&output.stdout).unwrap();
    // 16 passwords × 8 char + space = 9 cols × 8 = ~72 chars per row at 80 cols.
    // → 2 rows of 8 columns each.
    assert!(
        s.lines().any(|line| line.contains(' ')),
        "expected columnar (spaces between passwords); got {s:?}"
    );
}

#[test]
fn version_flag_succeeds() {
    rusty_pwgen_cmd().arg("--version").assert().success();
}

#[test]
fn help_flag_succeeds() {
    rusty_pwgen_cmd().arg("--help").assert().success();
}