rusty-pwgen 0.2.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
//! Pronounceable algorithm (FR-002, FR-003, FR-004).
//!
//! Behavioral parity with upstream pwgen's `pw_phonemes.c`: same phoneme
//! table, NOT_FIRST constraint on `gh ng`, alternating consonant/vowel groups,
//! "no vowel followed by vowel+diphthong" rule, ~20% caps / ~30% digit
//! sprinkle rates (Clarification Q2 / Risk-1 / AD-003).

use crate::rng::RngSource;

const FLAG_CONSONANT: u8 = 0b0001;
const FLAG_VOWEL: u8 = 0b0010;
const FLAG_DIPTHONG: u8 = 0b0100;
const FLAG_NOT_FIRST: u8 = 0b1000;

/// One element in the phoneme table.
struct Phoneme {
    bytes: &'static [u8],
    flags: u8,
}

/// Phoneme table matching upstream pwgen 2.08 `pw_phonemes.c` exactly.
const PHONEMES: &[Phoneme] = &[
    Phoneme {
        bytes: b"a",
        flags: FLAG_VOWEL,
    },
    Phoneme {
        bytes: b"ae",
        flags: FLAG_VOWEL | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"ah",
        flags: FLAG_VOWEL | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"ai",
        flags: FLAG_VOWEL | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"b",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"c",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"ch",
        flags: FLAG_CONSONANT | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"d",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"e",
        flags: FLAG_VOWEL,
    },
    Phoneme {
        bytes: b"ee",
        flags: FLAG_VOWEL | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"ei",
        flags: FLAG_VOWEL | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"f",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"g",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"gh",
        flags: FLAG_CONSONANT | FLAG_DIPTHONG | FLAG_NOT_FIRST,
    },
    Phoneme {
        bytes: b"h",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"i",
        flags: FLAG_VOWEL,
    },
    Phoneme {
        bytes: b"ie",
        flags: FLAG_VOWEL | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"j",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"k",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"l",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"m",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"n",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"ng",
        flags: FLAG_CONSONANT | FLAG_DIPTHONG | FLAG_NOT_FIRST,
    },
    Phoneme {
        bytes: b"o",
        flags: FLAG_VOWEL,
    },
    Phoneme {
        bytes: b"oh",
        flags: FLAG_VOWEL | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"oo",
        flags: FLAG_VOWEL | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"p",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"ph",
        flags: FLAG_CONSONANT | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"qu",
        flags: FLAG_CONSONANT | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"r",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"s",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"sh",
        flags: FLAG_CONSONANT | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"t",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"th",
        flags: FLAG_CONSONANT | FLAG_DIPTHONG,
    },
    Phoneme {
        bytes: b"u",
        flags: FLAG_VOWEL,
    },
    Phoneme {
        bytes: b"v",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"w",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"x",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"y",
        flags: FLAG_CONSONANT,
    },
    Phoneme {
        bytes: b"z",
        flags: FLAG_CONSONANT,
    },
];

/// Generate one pronounceable password of exactly `length` bytes.
///
/// `capitalize` and `numerals` control the sprinkle layers per FR-003/FR-004:
/// - `length <= 2` → silently no caps regardless of flag (FR-033)
/// - `length <= 1` → silently no digits regardless of flag (FR-034)
/// - `length == 0` → empty string
pub fn generate<R: RngSource + ?Sized>(
    rng: &mut R,
    length: usize,
    capitalize: bool,
    numerals: bool,
) -> String {
    if length == 0 {
        return String::new();
    }

    let effective_caps = capitalize && length > 2;
    let effective_digits = numerals && length > 1;

    let mut out = Vec::with_capacity(length);
    let mut should_be: u8 = if rng.gen_range(2) == 0 {
        FLAG_VOWEL
    } else {
        FLAG_CONSONANT
    };
    let mut prev_flags: u8 = 0;
    let mut first = true;

    while out.len() < length {
        // Pick a random phoneme of the desired type.
        let idx = rng.gen_range(PHONEMES.len() as u32) as usize;
        let p = &PHONEMES[idx];

        // Must match the desired type (vowel or consonant).
        if (p.flags & should_be) == 0 {
            continue;
        }
        // NOT_FIRST: can't open a password with `gh` or `ng` (matches upstream).
        if first && (p.flags & FLAG_NOT_FIRST) != 0 {
            continue;
        }
        // "Don't allow VOWEL followed by VOWEL+DIPTHONG" (matches upstream).
        if (prev_flags & FLAG_VOWEL) != 0
            && (p.flags & FLAG_VOWEL) != 0
            && (p.flags & FLAG_DIPTHONG) != 0
        {
            continue;
        }
        // Don't overflow the requested length.
        if out.len() + p.bytes.len() > length {
            continue;
        }

        // Decide whether to capitalize this phoneme (FR-003).
        // Upstream: `pw_number(10) < 2` ≈ 20% probability, applied to the
        // first letter of a consonant phoneme OR the very first character.
        let mut bytes_to_push: Vec<u8> = p.bytes.to_vec();
        if effective_caps && (first || (p.flags & FLAG_CONSONANT) != 0) && rng.gen_range(10) < 2 {
            // Capitalize the first letter of this phoneme.
            bytes_to_push[0] = bytes_to_push[0].to_ascii_uppercase();
        }
        out.extend_from_slice(&bytes_to_push);

        // Alternate type for next iteration.
        if (should_be & FLAG_CONSONANT) != 0 {
            should_be = FLAG_VOWEL;
        } else {
            should_be = if rng.gen_range(10) < 3 && !is_dipthong(p.flags) {
                FLAG_VOWEL
            } else {
                FLAG_CONSONANT
            };
        }
        prev_flags = p.flags;
        first = false;

        // After placing a phoneme, maybe insert a digit (FR-004).
        // Upstream: `pw_number(10) < 3` ≈ 30% probability, with phoneme state reset.
        if effective_digits && out.len() < length && rng.gen_range(10) < 3 {
            let digit = b'0' + (rng.gen_range(10) as u8);
            out.push(digit);
            // Reset state — next phoneme can be anything.
            first = true;
            prev_flags = 0;
            should_be = if rng.gen_range(2) == 0 {
                FLAG_VOWEL
            } else {
                FLAG_CONSONANT
            };
        }
    }

    // Bytes guaranteed ASCII by phoneme table construction; no allocation needed.
    String::from_utf8(out).expect("phoneme output is ASCII")
}

fn is_dipthong(flags: u8) -> bool {
    (flags & FLAG_DIPTHONG) != 0
}

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

    #[test]
    fn generate_length_zero_is_empty() {
        let mut rng = OsRngSource::new();
        assert_eq!(generate(&mut rng, 0, true, true), "");
    }

    #[test]
    fn generate_default_length_8_is_8_chars() {
        let mut rng = OsRngSource::new();
        for _ in 0..50 {
            let s = generate(&mut rng, 8, true, true);
            assert_eq!(s.len(), 8, "expected 8 chars, got {s:?}");
            assert!(s.is_ascii());
        }
    }

    #[test]
    fn generate_length_12_is_12_chars() {
        let mut rng = OsRngSource::new();
        for _ in 0..50 {
            let s = generate(&mut rng, 12, true, true);
            assert_eq!(s.len(), 12);
        }
    }

    #[test]
    fn generate_consecutive_outputs_differ() {
        let mut rng = OsRngSource::new();
        let mut seen = std::collections::HashSet::new();
        for _ in 0..20 {
            seen.insert(generate(&mut rng, 12, true, true));
        }
        assert!(
            seen.len() > 1,
            "10+ consecutive calls should produce distinct outputs"
        );
    }

    #[test]
    fn generate_with_no_caps_no_digits_is_pure_lowercase() {
        let mut rng = OsRngSource::new();
        let s = generate(&mut rng, 16, false, false);
        assert!(
            s.bytes().all(|b| b.is_ascii_lowercase()),
            "no caps, no digits → pure lowercase, got {s:?}"
        );
    }

    #[test]
    fn generate_length_2_suppresses_caps_silently() {
        // FR-033: length <= 2 silently disables capitalization.
        let mut rng = OsRngSource::new();
        for _ in 0..50 {
            let s = generate(&mut rng, 2, true, true);
            assert_eq!(s.len(), 2);
            assert!(
                s.bytes().all(|b| !b.is_ascii_uppercase()),
                "length 2 must not capitalize, got {s:?}"
            );
        }
    }

    #[test]
    fn generate_length_1_suppresses_digits_silently() {
        // FR-034: length <= 1 silently disables digit insertion.
        let mut rng = OsRngSource::new();
        for _ in 0..50 {
            let s = generate(&mut rng, 1, false, true);
            assert_eq!(s.len(), 1);
            // First character must be a phoneme, not a digit.
            assert!(
                s.bytes().next().unwrap().is_ascii_alphabetic(),
                "length 1 must not be a digit, got {s:?}"
            );
        }
    }

    #[test]
    fn deterministic_with_seeded_rng() {
        // Same seed → same output.
        use crate::rng::SeededSource;
        let seed = [99u8; 32];
        let mut a = SeededSource::from_seed(seed);
        let mut b = SeededSource::from_seed(seed);
        for _ in 0..10 {
            assert_eq!(
                generate(&mut a, 12, true, true),
                generate(&mut b, 12, true, true)
            );
        }
    }
}