Skip to main content

iwcore/crypto/
password.rs

1//! Password generation functionality
2//!
3//! Implements three modes:
4//! - `generate_password` — uniformly random, weighted character pool. Matches
5//!   the original NS Wallet C# algorithm.
6//! - `generate_clever_password` — preserves the per-character category of the
7//!   input pattern (lower→lower, upper→upper, digit→digit, special→special).
8//! - `generate_memorable_password` — `<prefix><sep><Word><digits><sep>...`
9//!   using a 1024-word public wordlist. Customizable separator, prefix,
10//!   digits-per-word, and capitalisation position.
11//!
12//! All three generators draw from `rand::rngs::OsRng` — the OS CSPRNG —
13//! so randomness is cryptographically secure on every supported platform.
14//!
15//! SECURITY NOTE on the memorable mode: the wordlist is fully public
16//! (open source on crates.io) and the format is deterministic, so per-word
17//! entropy is exactly `log2(1024) = 10 bits` plus `log2(10) ≈ 3.32 bits`
18//! per appended digit. Format-aware crackers benefit from knowing this
19//! structure. Use the random mode for high-value secrets.
20
21use rand::rngs::StdRng;
22use rand::{Rng, SeedableRng};
23
24use super::wordlist::WORDS;
25
26/// Build a fresh CSPRNG seeded from the OS entropy source.
27///
28/// `StdRng` in rand 0.9 is a ChaCha12 stream cipher; `from_os_rng()` seeds
29/// it from `OsRng` (which on Apple platforms is `SecRandomCopyBytes`,
30/// `getrandom(2)` on modern Linux, `BCryptGenRandom` on Windows).
31/// We re-seed per call so password generation always reflects the current
32/// OS entropy state — never thread-local cached state.
33fn csprng() -> StdRng {
34    StdRng::from_os_rng()
35}
36
37const LOWER_LETTERS: &str = "qwertyuiopasdfghjklzxcvbnm";
38const UPPER_LETTERS: &str = "QWERTYUIOPASDFGHJKLZXCVBNM";
39const DIGITS: &str = "1234567890";
40const SPECIAL_SYMBOLS: &str = "!@#$%^&*()_+-=;:,.?~";
41
42/// Characters that look alike in many fonts and confuse the user when
43/// reading a password aloud or copying it by hand.
44/// Pairs/groups: l-1-I-i, o-O-0, B-8, S-5-s, Z-2.
45const AMBIGUOUS_CHARS: &str = "lIi1oO0B8Ss5Z2";
46
47/// Options for password generation
48#[derive(Debug, Clone)]
49pub struct PasswordOptions {
50    /// Include lowercase letters (a-z)
51    pub lowercase: bool,
52    /// Include uppercase letters (A-Z)
53    pub uppercase: bool,
54    /// Include digits (0-9)
55    pub digits: bool,
56    /// Include special symbols (!@#$%...)
57    pub special: bool,
58    /// Exclude visually-similar characters (l/I/i/1, o/O/0, B/8, S/5/s, Z/2).
59    pub avoid_ambiguous: bool,
60    /// Password length
61    pub length: usize,
62}
63
64impl Default for PasswordOptions {
65    fn default() -> Self {
66        Self {
67            lowercase: true,
68            uppercase: true,
69            digits: true,
70            special: false,
71            avoid_ambiguous: false,
72            length: 16,
73        }
74    }
75}
76
77/// Generate a random password with the specified options.
78///
79/// This matches the original C# `GeneratePassword` function.
80/// The character pool is weighted: letters are tripled, digits doubled,
81/// special symbols once - this gives more readable passwords with good entropy.
82///
83/// # Arguments
84/// * `options` - Password generation options
85///
86/// # Returns
87/// Generated password string
88///
89/// # Example
90/// ```
91/// use iwcore::crypto::password::{generate_password, PasswordOptions};
92///
93/// let options = PasswordOptions {
94///     lowercase: true,
95///     uppercase: true,
96///     digits: true,
97///     special: false,
98///     avoid_ambiguous: false,
99///     length: 12,
100/// };
101/// let password = generate_password(&options);
102/// assert_eq!(password.len(), 12);
103/// ```
104pub fn generate_password(options: &PasswordOptions) -> String {
105    let mut rng = csprng();
106    let mut char_pool = String::new();
107
108    // Build weighted character pool (matching C# implementation: letters×3, digits×2)
109    if options.lowercase {
110        char_pool.push_str(LOWER_LETTERS);
111        char_pool.push_str(LOWER_LETTERS);
112        char_pool.push_str(LOWER_LETTERS);
113    }
114    if options.uppercase {
115        char_pool.push_str(UPPER_LETTERS);
116        char_pool.push_str(UPPER_LETTERS);
117        char_pool.push_str(UPPER_LETTERS);
118    }
119    if options.digits {
120        char_pool.push_str(DIGITS);
121        char_pool.push_str(DIGITS);
122    }
123    if options.special {
124        char_pool.push_str(SPECIAL_SYMBOLS);
125    }
126
127    // If nothing selected, use lowercase as fallback
128    if char_pool.is_empty() {
129        char_pool.push_str(LOWER_LETTERS);
130    }
131
132    // Strip visually-similar characters when requested. Done after pool
133    // construction so the per-class weighting (lower×3, upper×3, digit×2)
134    // is preserved across the surviving characters.
135    if options.avoid_ambiguous {
136        char_pool = char_pool
137            .chars()
138            .filter(|c| !AMBIGUOUS_CHARS.contains(*c))
139            .collect();
140        if char_pool.is_empty() {
141            char_pool.push_str(LOWER_LETTERS);
142        }
143    }
144
145    let pool_chars: Vec<char> = char_pool.chars().collect();
146
147    // Guarantee at least one character from EACH selected class. Many
148    // third-party password rules ("must contain a digit", "must contain a
149    // symbol", etc.) demand this — without it a long random password can
150    // randomly fail validation on, say, a banking site.
151    let mut required: Vec<char> = Vec::new();
152    if options.lowercase {
153        if let Some(c) = pick_from_class(LOWER_LETTERS, options.avoid_ambiguous, &mut rng) {
154            required.push(c);
155        }
156    }
157    if options.uppercase {
158        if let Some(c) = pick_from_class(UPPER_LETTERS, options.avoid_ambiguous, &mut rng) {
159            required.push(c);
160        }
161    }
162    if options.digits {
163        if let Some(c) = pick_from_class(DIGITS, options.avoid_ambiguous, &mut rng) {
164            required.push(c);
165        }
166    }
167    if options.special {
168        if let Some(c) = pick_from_class(SPECIAL_SYMBOLS, options.avoid_ambiguous, &mut rng) {
169            required.push(c);
170        }
171    }
172
173    // If the user asked for a length shorter than the number of selected
174    // classes, shuffle the required chars and truncate. (Practically
175    // impossible — UI enforces length ≥ 8 — but we handle it cleanly.)
176    if options.length <= required.len() {
177        shuffle(&mut required, &mut rng);
178        return required.into_iter().take(options.length).collect();
179    }
180
181    // Fill the rest from the weighted pool.
182    let remaining = options.length - required.len();
183    let mut password_chars: Vec<char> = required;
184    for _ in 0..remaining {
185        let idx = rng.random_range(0..pool_chars.len());
186        password_chars.push(pool_chars[idx]);
187    }
188
189    // Shuffle so the guaranteed chars aren't always at the start.
190    shuffle(&mut password_chars, &mut rng);
191    password_chars.into_iter().collect()
192}
193
194/// Pick a single random character from a character-class string, optionally
195/// honouring the avoid-ambiguous filter. Returns None if every char in the
196/// class is filtered out (shouldn't happen for the current ambiguous set
197/// but guarded for future-proofing).
198fn pick_from_class(class: &str, avoid_ambiguous: bool, rng: &mut StdRng) -> Option<char> {
199    let candidates: Vec<char> = class
200        .chars()
201        .filter(|c| !avoid_ambiguous || !AMBIGUOUS_CHARS.contains(*c))
202        .collect();
203    if candidates.is_empty() {
204        None
205    } else {
206        Some(candidates[rng.random_range(0..candidates.len())])
207    }
208}
209
210/// Fisher-Yates shuffle in place using the given CSPRNG.
211fn shuffle<T>(items: &mut [T], rng: &mut StdRng) {
212    for i in (1..items.len()).rev() {
213        let j = rng.random_range(0..=i);
214        items.swap(i, j);
215    }
216}
217
218/// Generate a password based on a pattern.
219///
220/// This matches the original C# `GenerateCleverPassword` function.
221/// Each character in the pattern is replaced with a random character
222/// of the same type:
223/// - lowercase letter -> random lowercase letter
224/// - uppercase letter -> random uppercase letter
225/// - digit -> random digit
226/// - special symbol -> random special symbol
227/// - anything else -> random from all characters
228///
229/// # Arguments
230/// * `pattern` - Password pattern (e.g., "Aaaa0000" for uppercase + 3 lower + 4 digits)
231///
232/// # Returns
233/// Generated password string matching the pattern
234///
235/// # Example
236/// ```
237/// use iwcore::crypto::password::generate_clever_password;
238///
239/// let password = generate_clever_password("Aaaa0000");
240/// assert_eq!(password.len(), 8);
241/// // First char is uppercase, next 3 lowercase, last 4 digits
242/// ```
243pub fn generate_clever_password(pattern: &str) -> String {
244    let mut rng = csprng();
245    let all_symbols = format!("{}{}{}{}", LOWER_LETTERS, UPPER_LETTERS, DIGITS, SPECIAL_SYMBOLS);
246    let all_chars: Vec<char> = all_symbols.chars().collect();
247    let lower_chars: Vec<char> = LOWER_LETTERS.chars().collect();
248    let upper_chars: Vec<char> = UPPER_LETTERS.chars().collect();
249    let digit_chars: Vec<char> = DIGITS.chars().collect();
250    let special_chars: Vec<char> = SPECIAL_SYMBOLS.chars().collect();
251
252    let mut password = String::with_capacity(pattern.len());
253
254    for ch in pattern.chars() {
255        let generated_char = if LOWER_LETTERS.contains(ch) {
256            lower_chars[rng.random_range(0..lower_chars.len())]
257        } else if UPPER_LETTERS.contains(ch) {
258            upper_chars[rng.random_range(0..upper_chars.len())]
259        } else if DIGITS.contains(ch) {
260            digit_chars[rng.random_range(0..digit_chars.len())]
261        } else if SPECIAL_SYMBOLS.contains(ch) {
262            special_chars[rng.random_range(0..special_chars.len())]
263        } else {
264            all_chars[rng.random_range(0..all_chars.len())]
265        };
266        password.push(generated_char);
267    }
268
269    password
270}
271
272/// Capitalisation style for memorable-password words.
273///
274/// `First` → `Word` (default; uppercase first letter, lowercase rest).
275/// `Last` → `worD` (lowercase first letters, uppercase final letter).
276/// Both styles always produce mixed case, satisfying typical site
277/// password-strength requirements that demand both upper and lower.
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub enum MemorableCaps {
280    First,
281    Last,
282}
283
284/// Options for [`generate_memorable_password`].
285#[derive(Debug, Clone)]
286pub struct MemorableOptions {
287    /// How many words to include. 0 returns an empty string. UI should
288    /// constrain this to a safe range (e.g. 3..=6).
289    pub num_words: usize,
290    /// How many random digits (0–9) to append to each word. 0 = none.
291    pub digits_per_word: usize,
292    /// Separator placed between words AND between prefix (if any) and the
293    /// first word. Arbitrary string; "-" is a sensible default.
294    pub separator: String,
295    /// Free-text prefix prepended to the result. When non-empty it is
296    /// joined to the first word using `separator` (so it reads as a first
297    /// segment, not concatenated to the first word). Empty by default.
298    pub prefix: String,
299    /// Capitalisation style.
300    pub caps: MemorableCaps,
301}
302
303impl Default for MemorableOptions {
304    fn default() -> Self {
305        Self {
306            num_words: 4,
307            digits_per_word: 1,
308            separator: "-".to_string(),
309            prefix: String::new(),
310            caps: MemorableCaps::First,
311        }
312    }
313}
314
315/// Generate a memorable password of the form
316/// `<prefix><sep><Word1><digits1><sep><Word2><digits2>...`.
317///
318/// See [`MemorableOptions`] for the customizable parts.
319pub fn generate_memorable_password(opts: &MemorableOptions) -> String {
320    if opts.num_words == 0 {
321        return String::new();
322    }
323
324    let mut rng = csprng();
325    let mut segments: Vec<String> = Vec::with_capacity(opts.num_words);
326
327    if !opts.prefix.is_empty() {
328        segments.push(opts.prefix.clone());
329    }
330
331    for _ in 0..opts.num_words {
332        let word = WORDS[rng.random_range(0..WORDS.len())];
333        let mut s = apply_caps(word, opts.caps);
334        for _ in 0..opts.digits_per_word {
335            let digit = rng.random_range(0..10u32);
336            s.push(char::from_digit(digit, 10).unwrap());
337        }
338        segments.push(s);
339    }
340
341    segments.join(&opts.separator)
342}
343
344fn apply_caps(word: &str, caps: MemorableCaps) -> String {
345    let chars: Vec<char> = word.chars().collect();
346    if chars.is_empty() {
347        return String::new();
348    }
349    match caps {
350        MemorableCaps::First => {
351            let mut out = String::with_capacity(word.len());
352            out.push(chars[0].to_ascii_uppercase());
353            for &c in &chars[1..] {
354                out.push(c);
355            }
356            out
357        }
358        MemorableCaps::Last => {
359            let mut out = String::with_capacity(word.len());
360            for &c in &chars[..chars.len() - 1] {
361                out.push(c);
362            }
363            out.push(chars[chars.len() - 1].to_ascii_uppercase());
364            out
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use std::collections::HashSet;
373
374    #[test]
375    fn test_generate_password_default() {
376        let options = PasswordOptions::default();
377        let password = generate_password(&options);
378        assert_eq!(password.len(), 16);
379    }
380
381    #[test]
382    fn test_generate_password_length() {
383        let options = PasswordOptions {
384            length: 32,
385            ..Default::default()
386        };
387        let password = generate_password(&options);
388        assert_eq!(password.len(), 32);
389    }
390
391    #[test]
392    fn test_generate_password_lowercase_only() {
393        let options = PasswordOptions {
394            lowercase: true,
395            uppercase: false,
396            digits: false,
397            special: false,
398            avoid_ambiguous: false,
399            length: 20,
400        };
401        let password = generate_password(&options);
402        assert_eq!(password.len(), 20);
403        assert!(password.chars().all(|c| c.is_ascii_lowercase()));
404    }
405
406    #[test]
407    fn test_generate_password_uppercase_only() {
408        let options = PasswordOptions {
409            lowercase: false,
410            uppercase: true,
411            digits: false,
412            special: false,
413            avoid_ambiguous: false,
414            length: 20,
415        };
416        let password = generate_password(&options);
417        assert_eq!(password.len(), 20);
418        assert!(password.chars().all(|c| c.is_ascii_uppercase()));
419    }
420
421    #[test]
422    fn test_generate_password_digits_only() {
423        let options = PasswordOptions {
424            lowercase: false,
425            uppercase: false,
426            digits: true,
427            special: false,
428            avoid_ambiguous: false,
429            length: 20,
430        };
431        let password = generate_password(&options);
432        assert_eq!(password.len(), 20);
433        assert!(password.chars().all(|c| c.is_ascii_digit()));
434    }
435
436    #[test]
437    fn test_generate_password_special_only() {
438        let options = PasswordOptions {
439            lowercase: false,
440            uppercase: false,
441            digits: false,
442            special: true,
443            avoid_ambiguous: false,
444            length: 20,
445        };
446        let password = generate_password(&options);
447        assert_eq!(password.len(), 20);
448        assert!(password.chars().all(|c| SPECIAL_SYMBOLS.contains(c)));
449    }
450
451    #[test]
452    fn test_generate_password_all_types() {
453        let options = PasswordOptions {
454            lowercase: true,
455            uppercase: true,
456            digits: true,
457            special: true,
458            avoid_ambiguous: false,
459            length: 100,
460        };
461        let password = generate_password(&options);
462        assert_eq!(password.len(), 100);
463        // With 100 chars, we should have some of each type (probabilistically)
464    }
465
466    #[test]
467    fn test_generate_password_empty_options_fallback() {
468        let options = PasswordOptions {
469            lowercase: false,
470            uppercase: false,
471            digits: false,
472            special: false,
473            avoid_ambiguous: false,
474            length: 10,
475        };
476        let password = generate_password(&options);
477        assert_eq!(password.len(), 10);
478        // Should fallback to lowercase
479        assert!(password.chars().all(|c| c.is_ascii_lowercase()));
480    }
481
482    #[test]
483    fn test_generate_password_avoid_ambiguous() {
484        let options = PasswordOptions {
485            lowercase: true,
486            uppercase: true,
487            digits: true,
488            special: false,
489            avoid_ambiguous: true,
490            length: 200,
491        };
492        let password = generate_password(&options);
493        assert_eq!(password.len(), 200);
494        for c in password.chars() {
495            assert!(
496                !AMBIGUOUS_CHARS.contains(c),
497                "ambiguous char {c:?} should be excluded but appeared in {password}",
498            );
499        }
500    }
501
502    #[test]
503    fn test_generate_password_avoid_ambiguous_off_does_not_filter() {
504        // Sanity guard: with avoid_ambiguous=false, ambiguous chars CAN
505        // appear (probabilistically — with length 1000 it's effectively
506        // certain).
507        let options = PasswordOptions {
508            lowercase: true,
509            uppercase: true,
510            digits: true,
511            special: false,
512            avoid_ambiguous: false,
513            length: 1000,
514        };
515        let password = generate_password(&options);
516        let saw_any_ambiguous =
517            password.chars().any(|c| AMBIGUOUS_CHARS.contains(c));
518        assert!(
519            saw_any_ambiguous,
520            "expected at least one ambiguous char in 1000-char password",
521        );
522    }
523
524    #[test]
525    fn test_generate_password_guarantees_each_selected_class() {
526        // Run many short passwords with all 4 classes. Every single result
527        // must contain at least one of each class — never zero.
528        let options = PasswordOptions {
529            lowercase: true,
530            uppercase: true,
531            digits: true,
532            special: true,
533            avoid_ambiguous: false,
534            length: 8,
535        };
536        for _ in 0..200 {
537            let pwd = generate_password(&options);
538            assert_eq!(pwd.len(), 8);
539            assert!(pwd.chars().any(|c| c.is_ascii_lowercase()), "{pwd}");
540            assert!(pwd.chars().any(|c| c.is_ascii_uppercase()), "{pwd}");
541            assert!(pwd.chars().any(|c| c.is_ascii_digit()), "{pwd}");
542            assert!(
543                pwd.chars().any(|c| SPECIAL_SYMBOLS.contains(c)),
544                "expected a special symbol in {pwd}",
545            );
546        }
547    }
548
549    #[test]
550    fn test_generate_password_unselected_class_never_appears() {
551        // Inverse guard: classes the user did NOT select must not appear.
552        let options = PasswordOptions {
553            lowercase: true,
554            uppercase: false,
555            digits: true,
556            special: false,
557            avoid_ambiguous: false,
558            length: 100,
559        };
560        for _ in 0..50 {
561            let pwd = generate_password(&options);
562            assert!(
563                pwd.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()),
564                "unexpected char in {pwd}",
565            );
566        }
567    }
568
569    #[test]
570    fn test_generate_password_guarantees_with_avoid_ambiguous() {
571        // Combined: avoid ambiguous + at-least-one-of-each.
572        let options = PasswordOptions {
573            lowercase: true,
574            uppercase: true,
575            digits: true,
576            special: true,
577            avoid_ambiguous: true,
578            length: 8,
579        };
580        for _ in 0..200 {
581            let pwd = generate_password(&options);
582            assert!(pwd.chars().any(|c| c.is_ascii_lowercase()));
583            assert!(pwd.chars().any(|c| c.is_ascii_uppercase()));
584            assert!(pwd.chars().any(|c| c.is_ascii_digit()));
585            assert!(pwd.chars().any(|c| SPECIAL_SYMBOLS.contains(c)));
586            for c in pwd.chars() {
587                assert!(!AMBIGUOUS_CHARS.contains(c), "ambiguous {c} in {pwd}");
588            }
589        }
590    }
591
592    #[test]
593    fn test_generate_password_uniqueness() {
594        let options = PasswordOptions::default();
595        let p1 = generate_password(&options);
596        let p2 = generate_password(&options);
597        // Passwords should be different (extremely high probability)
598        assert_ne!(p1, p2);
599    }
600
601    #[test]
602    fn test_generate_clever_password_length() {
603        let password = generate_clever_password("Aaaa0000");
604        assert_eq!(password.len(), 8);
605    }
606
607    #[test]
608    fn test_generate_clever_password_pattern() {
609        let password = generate_clever_password("aaaa");
610        assert!(password.chars().all(|c| c.is_ascii_lowercase()));
611
612        let password = generate_clever_password("AAAA");
613        assert!(password.chars().all(|c| c.is_ascii_uppercase()));
614
615        let password = generate_clever_password("0000");
616        assert!(password.chars().all(|c| c.is_ascii_digit()));
617    }
618
619    #[test]
620    fn test_generate_clever_password_mixed() {
621        let password = generate_clever_password("Aa00");
622        assert_eq!(password.len(), 4);
623        let chars: Vec<char> = password.chars().collect();
624        assert!(chars[0].is_ascii_uppercase());
625        assert!(chars[1].is_ascii_lowercase());
626        assert!(chars[2].is_ascii_digit());
627        assert!(chars[3].is_ascii_digit());
628    }
629
630    #[test]
631    fn test_generate_clever_password_special() {
632        let password = generate_clever_password("!@#$");
633        assert_eq!(password.len(), 4);
634        assert!(password.chars().all(|c| SPECIAL_SYMBOLS.contains(c)));
635    }
636
637    #[test]
638    fn test_generate_clever_password_unknown_chars() {
639        // Unknown characters should map to random from all
640        let password = generate_clever_password("    "); // spaces
641        assert_eq!(password.len(), 4);
642    }
643
644    #[test]
645    fn test_generate_clever_password_empty() {
646        let password = generate_clever_password("");
647        assert!(password.is_empty());
648    }
649
650    #[test]
651    fn test_generate_clever_password_uniqueness() {
652        let p1 = generate_clever_password("Aaaa0000@@");
653        let p2 = generate_clever_password("Aaaa0000@@");
654        // Passwords should be different (extremely high probability)
655        assert_ne!(p1, p2);
656    }
657
658    // ─── memorable password tests ───────────────────────────────────────
659
660    fn opts(num_words: usize) -> MemorableOptions {
661        MemorableOptions {
662            num_words,
663            ..Default::default()
664        }
665    }
666
667    fn wordlist_set() -> HashSet<&'static str> {
668        WORDS.iter().copied().collect()
669    }
670
671    /// Strip the trailing digits from a word segment and return the word
672    /// (lowercased).
673    fn strip_digits_and_lower(seg: &str) -> String {
674        seg.chars()
675            .filter(|c| !c.is_ascii_digit())
676            .collect::<String>()
677            .to_ascii_lowercase()
678    }
679
680    #[test]
681    fn memorable_zero_words_returns_empty() {
682        let p = generate_memorable_password(&opts(0));
683        assert_eq!(p, "");
684    }
685
686    #[test]
687    fn memorable_one_word_no_separator() {
688        let p = generate_memorable_password(&opts(1));
689        assert!(!p.contains('-'), "single-word output should have no separator: {p}");
690        // Format: <Word><digit>
691        assert!(p.chars().last().unwrap().is_ascii_digit());
692        assert!(p.chars().next().unwrap().is_ascii_uppercase());
693    }
694
695    #[test]
696    fn memorable_default_four_words_three_dashes() {
697        let p = generate_memorable_password(&opts(4));
698        let dashes = p.chars().filter(|&c| c == '-').count();
699        assert_eq!(dashes, 3, "4 words → 3 dashes; got: {p}");
700        for seg in p.split('-') {
701            assert!(seg.chars().next().unwrap().is_ascii_uppercase());
702            assert!(seg.chars().last().unwrap().is_ascii_digit());
703        }
704    }
705
706    #[test]
707    fn memorable_custom_separator() {
708        let mut o = opts(3);
709        o.separator = "_".to_string();
710        let p = generate_memorable_password(&o);
711        assert_eq!(p.matches('_').count(), 2, "3 words → 2 underscores: {p}");
712        assert!(!p.contains('-'));
713    }
714
715    #[test]
716    fn memorable_multi_char_separator() {
717        let mut o = opts(3);
718        o.separator = "--".to_string();
719        let p = generate_memorable_password(&o);
720        // Two "--" separators → 4 dash chars total
721        assert_eq!(p.matches("--").count(), 2, "3 words joined by '--': {p}");
722    }
723
724    #[test]
725    fn memorable_zero_digits_per_word() {
726        let mut o = opts(3);
727        o.digits_per_word = 0;
728        let p = generate_memorable_password(&o);
729        for seg in p.split('-') {
730            assert!(
731                !seg.chars().any(|c| c.is_ascii_digit()),
732                "segment {seg:?} should have no digits"
733            );
734        }
735    }
736
737    #[test]
738    fn memorable_three_digits_per_word() {
739        let mut o = opts(3);
740        o.digits_per_word = 3;
741        let p = generate_memorable_password(&o);
742        for seg in p.split('-') {
743            let trailing_digits =
744                seg.chars().rev().take_while(|c| c.is_ascii_digit()).count();
745            assert_eq!(trailing_digits, 3, "segment {seg:?} should end with 3 digits");
746        }
747    }
748
749    #[test]
750    fn memorable_caps_last() {
751        let mut o = opts(3);
752        o.caps = MemorableCaps::Last;
753        o.digits_per_word = 1;
754        let p = generate_memorable_password(&o);
755        for seg in p.split('-') {
756            // Format: <lower><lower>...<UPPER><digit>
757            let chars: Vec<char> = seg.chars().collect();
758            assert!(chars[0].is_ascii_lowercase(), "first char should be lowercase: {seg:?}");
759            // Last is the digit; second-to-last is the uppercased letter
760            assert!(chars.last().unwrap().is_ascii_digit());
761            let upper = chars[chars.len() - 2];
762            assert!(upper.is_ascii_uppercase(), "letter before digit should be uppercase: {seg:?}");
763        }
764    }
765
766    #[test]
767    fn memorable_with_prefix_uses_separator() {
768        let mut o = opts(3);
769        o.prefix = "@home".to_string();
770        let p = generate_memorable_password(&o);
771        assert!(p.starts_with("@home-"), "prefix must be joined by separator: {p}");
772        // 1 prefix + 3 words → 3 dashes
773        assert_eq!(p.matches('-').count(), 3, "{p}");
774    }
775
776    #[test]
777    fn memorable_words_come_from_wordlist() {
778        let words = wordlist_set();
779        let p = generate_memorable_password(&opts(4));
780        for seg in p.split('-') {
781            let bare = strip_digits_and_lower(seg);
782            assert!(
783                words.contains(bare.as_str()),
784                "segment {seg:?} stripped to {bare:?} not in wordlist"
785            );
786        }
787    }
788
789    #[test]
790    fn memorable_two_calls_produce_different_outputs() {
791        let p1 = generate_memorable_password(&opts(4));
792        let p2 = generate_memorable_password(&opts(4));
793        assert_ne!(p1, p2, "two consecutive calls must differ");
794    }
795
796    #[test]
797    fn memorable_wordlist_length_is_1024() {
798        // Same as the wordlist module's own test, but kept here so changing
799        // password.rs alone still trips the regression guard if the wordlist
800        // is swapped out.
801        assert_eq!(WORDS.len(), 1024);
802    }
803}