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
//! Active character set builder (FR-006 through FR-010).
//!
//! The set is rebuilt once per generation based on flag state, then sampled
//! uniformly during secure-mode generation. Pronounceable mode uses the
//! phoneme table directly; this module's filters apply only to the
//! caps/digit sprinkle layers of pronounceable mode.

/// ASCII symbol set used by `-y` (matches upstream pwgen's `pw_symbols`).
pub const SYMBOLS: &[u8] = b"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";

/// Ambiguous characters dropped by `-B` (matches upstream pwgen's `pw_ambiguous`).
pub const AMBIGUOUS: &[u8] = b"l1OI0";

/// Vowels (case-insensitive) dropped by `-v`.
pub const VOWELS: &[u8] = b"aeiouAEIOU";

/// Letters always present.
pub const LOWERCASE: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
pub const UPPERCASE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
pub const DIGITS: &[u8] = b"0123456789";

#[derive(Debug, Clone, Copy)]
pub struct CharSetFlags {
    pub capitalize: bool,
    pub numerals: bool,
    pub symbols: bool,
    pub ambiguous_filter: bool,
    pub no_vowels: bool,
}

impl Default for CharSetFlags {
    fn default() -> Self {
        Self {
            capitalize: true,
            numerals: true,
            symbols: false,
            ambiguous_filter: false,
            no_vowels: false,
        }
    }
}

/// Build the active character set as a `Vec<u8>` per FR-006.
///
/// Per AD-007: build once, then sample uniformly via `rng.gen_range(0..set.len())`.
/// `remove_chars` is the `-r <chars>` flag's value (empty if not used).
pub fn build(flags: CharSetFlags, remove_chars: &[u8]) -> Vec<u8> {
    let mut set: Vec<u8> = LOWERCASE.to_vec();
    if flags.capitalize {
        set.extend_from_slice(UPPERCASE);
    }
    if flags.numerals {
        set.extend_from_slice(DIGITS);
    }
    if flags.symbols {
        set.extend_from_slice(SYMBOLS);
    }
    // Apply filters AFTER union — order is `-B` then `-v` then `-r`.
    if flags.ambiguous_filter {
        set = filter_ambiguous(&set);
    }
    if flags.no_vowels {
        set = filter_vowels(&set);
    }
    if !remove_chars.is_empty() {
        set = remove_chars_from(&set, remove_chars);
    }
    set
}

/// Drop the ambiguous characters `l 1 O I 0` (FR-007).
pub fn filter_ambiguous(set: &[u8]) -> Vec<u8> {
    set.iter()
        .copied()
        .filter(|b| !AMBIGUOUS.contains(b))
        .collect()
}

/// Drop vowels both cases (FR-008).
pub fn filter_vowels(set: &[u8]) -> Vec<u8> {
    set.iter()
        .copied()
        .filter(|b| !VOWELS.contains(b))
        .collect()
}

/// Drop any byte present in `bad` (FR-009).
pub fn remove_chars_from(set: &[u8], bad: &[u8]) -> Vec<u8> {
    set.iter().copied().filter(|b| !bad.contains(b)).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_set_is_lowercase_upper_digits() {
        let set = build(CharSetFlags::default(), &[]);
        // FR-006: default = lowercase + uppercase + digits (no symbols).
        assert!(set.iter().all(|b| b.is_ascii_alphanumeric()));
        assert!(set.contains(&b'a'));
        assert!(set.contains(&b'Z'));
        assert!(set.contains(&b'5'));
        assert!(set.iter().all(|b| !SYMBOLS.contains(b)));
    }

    #[test]
    fn no_capitalize_drops_uppercase() {
        let flags = CharSetFlags {
            capitalize: false,
            ..Default::default()
        };
        let set = build(flags, &[]);
        assert!(set.iter().all(|b| !b.is_ascii_uppercase()));
    }

    #[test]
    fn no_numerals_drops_digits() {
        let flags = CharSetFlags {
            numerals: false,
            ..Default::default()
        };
        let set = build(flags, &[]);
        assert!(set.iter().all(|b| !b.is_ascii_digit()));
    }

    #[test]
    fn symbols_includes_symbol_set() {
        let flags = CharSetFlags {
            symbols: true,
            ..Default::default()
        };
        let set = build(flags, &[]);
        assert!(set.contains(&b'!'));
        assert!(set.contains(&b'@'));
        assert!(set.contains(&b'~'));
    }

    #[test]
    fn ambiguous_filter_removes_l1_oi0() {
        let flags = CharSetFlags {
            ambiguous_filter: true,
            ..Default::default()
        };
        let set = build(flags, &[]);
        for &b in AMBIGUOUS {
            assert!(!set.contains(&b), "ambiguous char {b:#x} should be removed");
        }
    }

    #[test]
    fn no_vowels_removes_vowels_both_cases() {
        let flags = CharSetFlags {
            no_vowels: true,
            ..Default::default()
        };
        let set = build(flags, &[]);
        for &b in VOWELS {
            assert!(!set.contains(&b), "vowel {b:#x} should be removed");
        }
    }

    #[test]
    fn remove_chars_drops_requested_bytes() {
        let set = build(CharSetFlags::default(), b"abc");
        assert!(!set.contains(&b'a'));
        assert!(!set.contains(&b'b'));
        assert!(!set.contains(&b'c'));
        assert!(set.contains(&b'd'));
    }

    #[test]
    fn set_is_ascii_only_invariant() {
        // FR-010: ASCII-only invariant across all flag combinations.
        for cap in [true, false] {
            for num in [true, false] {
                for sym in [true, false] {
                    for amb in [true, false] {
                        for nov in [true, false] {
                            let flags = CharSetFlags {
                                capitalize: cap,
                                numerals: num,
                                symbols: sym,
                                ambiguous_filter: amb,
                                no_vowels: nov,
                            };
                            let set = build(flags, &[]);
                            assert!(set.iter().all(|b| b.is_ascii()));
                        }
                    }
                }
            }
        }
    }
}