use clap::Parser;
use rand::RngExt;
#[derive(Parser)]
#[command(name = "forgekey", version, about)]
pub struct Cli {
#[arg(short, long, default_value_t = 16)]
pub length: usize,
#[arg(short, long, default_value_t = 1)]
pub number: usize,
#[arg(long, default_value_t = false)]
pub no_symbols: bool,
#[arg(long, default_value_t = false)]
pub no_numbers: bool,
#[arg(long, default_value_t = false)]
pub no_uppercase: bool,
#[arg(short, long)]
pub copy: bool,
#[arg(short, long)]
pub strength: bool,
#[arg(short, long)]
pub passphrase: bool,
#[arg(short, long, default_value_t = 4)]
pub words: usize,
#[arg(long, default_value = "-")]
pub separator: String,
}
pub const LOWERCASE: &str = "abcdefghijklmnopqrstuvwxyz";
pub const UPPERCASE: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
pub const NUMBERS: &str = "0123456789";
pub const SYMBOLS: &str = "!@#$%^&*()_+-=[]{}|;:,.<>?";
pub fn generate_password(cli: &Cli) -> Result<(String, usize), String> {
if cli.length == 0 {
return Err("Password length must be greater than zero.".to_string());
}
let mut charset = String::new();
charset.push_str(LOWERCASE);
if !cli.no_uppercase {
charset.push_str(UPPERCASE);
}
if !cli.no_numbers {
charset.push_str(NUMBERS);
}
if !cli.no_symbols {
charset.push_str(SYMBOLS);
}
let charset_bytes = charset.as_bytes();
let mut rng = rand::rng();
let password = (0..cli.length)
.map(|_| {
let idx = rng.random_range(0..charset_bytes.len());
charset_bytes[idx] as char
})
.collect();
Ok((password, charset_bytes.len()))
}
pub fn calculate_entropy(length: usize, charset_size: usize) -> (f64, &'static str) {
let entropy = length as f64 * (charset_size as f64).log2();
let result = match entropy as u32 {
0..28 => "weak",
28..36 => "fair",
36..60 => "strong",
60.. => "very strong",
};
(entropy, result)
}
pub fn generate_passphrase(cli: &Cli) -> Result<String, String> {
let words = get_words();
if cli.words == 0 {
return Err("Word count must be greater than zero.".to_string());
}
let mut rng = rand::rng();
let selected: Vec<&str> = (0..cli.words)
.map(|_| {
let idx = rng.random_range(0..words.len());
words[idx]
})
.collect();
Ok(selected.join(&cli.separator))
}
const WORDLIST: &str = include_str!("wordlist.txt");
pub fn get_words() -> Vec<&'static str> {
WORDLIST.lines().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn cli_default() -> Cli {
Cli {
length: 16,
number: 1,
no_symbols: false,
no_numbers: false,
no_uppercase: false,
copy: false,
strength: false,
passphrase: false,
words: 4,
separator: String::from("-"),
}
}
#[test]
fn test_default_password_length() {
let cli = cli_default();
let (password, _) = generate_password(&cli).unwrap();
assert_eq!(password.len(), 16);
}
#[test]
fn test_custom_length() {
let mut cli = cli_default();
cli.length = 32;
let (password, _) = generate_password(&cli).unwrap();
assert_eq!(password.len(), 32);
}
#[test]
fn test_zero_length_returns_error() {
let mut cli = cli_default();
cli.length = 0;
let result = generate_password(&cli);
assert!(result.is_err());
}
#[test]
fn test_no_symbols_excludes_symbols() {
let mut cli = cli_default();
cli.no_symbols = true;
cli.length = 200;
let (password, _) = generate_password(&cli).unwrap();
assert!(!password.chars().any(|c| SYMBOLS.contains(c)));
}
#[test]
fn test_no_numbers_excludes_numbers() {
let mut cli = cli_default();
cli.no_numbers = true;
cli.length = 200;
let (password, _) = generate_password(&cli).unwrap();
assert!(!password.chars().any(|c| c.is_ascii_digit()));
}
#[test]
fn test_no_uppercase_excludes_uppercase() {
let mut cli = cli_default();
cli.no_uppercase = true;
cli.length = 200;
let (password, _) = generate_password(&cli).unwrap();
assert!(!password.chars().any(|c| c.is_ascii_uppercase()));
}
#[test]
fn test_all_exclusions_produces_only_lowercase() {
let mut cli = cli_default();
cli.no_symbols = true;
cli.no_numbers = true;
cli.no_uppercase = true;
let (password, _) = generate_password(&cli).unwrap();
assert!(password.chars().all(|c| c.is_ascii_lowercase()));
}
#[test]
fn test_multiple_passwords_are_unique() {
let cli = cli_default();
let p1 = generate_password(&cli).unwrap();
let p2 = generate_password(&cli).unwrap();
assert_ne!(p1, p2);
}
#[test]
fn test_copy_flag() {
use clap::Parser;
let cli = Cli::parse_from(["forgekey", "-c"]);
assert!(cli.copy);
}
#[test]
fn test_weak_password() {
let (_, level) = calculate_entropy(4, 26);
assert_eq!(level, "weak");
}
#[test]
fn test_fair_password() {
let (_, level) = calculate_entropy(6, 36);
assert_eq!(level, "fair");
}
#[test]
fn test_strong_password() {
let (_, level) = calculate_entropy(8, 24);
assert_eq!(level, "strong");
}
#[test]
fn test_very_strong_password() {
let (_, level) = calculate_entropy(16, 88);
assert_eq!(level, "very strong");
}
#[test]
fn test_passphrase_default() {
let mut cli = cli_default();
cli.passphrase = true;
let passphrase = generate_passphrase(&cli).unwrap();
let words: Vec<&str> = passphrase.split('-').collect();
assert_eq!(words.len(), 4);
}
#[test]
fn test_passphrase_custom_words() {
let mut cli = cli_default();
cli.passphrase = true;
cli.words = 6;
let passphrase = generate_passphrase(&cli).unwrap();
let words: Vec<&str> = passphrase.split('-').collect();
assert_eq!(words.len(), 6);
}
#[test]
fn test_passphrase_custom_separator() {
let mut cli = cli_default();
cli.passphrase = true;
cli.separator = String::from("_");
let passphrase = generate_passphrase(&cli).unwrap();
assert!(passphrase.contains('_'));
assert!(!passphrase.contains('-'));
}
#[test]
fn test_passphrase_zero_words_returns_error() {
let mut cli = cli_default();
cli.passphrase = true;
cli.words = 0;
let result = generate_passphrase(&cli);
assert!(result.is_err());
}
#[test]
fn test_passphrase_words_are_from_wordlist() {
let mut cli = cli_default();
cli.passphrase = true;
let passphrase = generate_passphrase(&cli).unwrap();
let wordlist = get_words();
for word in passphrase.split('-') {
assert!(wordlist.contains(&word));
}
}
}