Skip to main content

bitwarden_generators/
password.rs

1use std::collections::BTreeSet;
2
3use bitwarden_error::bitwarden_error;
4use rand::{Rng, RngExt, distr::Distribution, seq::SliceRandom};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9#[allow(missing_docs)]
10#[bitwarden_error(flat)]
11#[derive(Debug, Error)]
12pub enum PasswordError {
13    #[error("No character set enabled")]
14    NoCharacterSetEnabled,
15    #[error("Invalid password length")]
16    InvalidLength,
17}
18
19/// Password generator request options.
20#[derive(Serialize, Deserialize, Debug, JsonSchema)]
21#[serde(rename_all = "camelCase", deny_unknown_fields)]
22#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
23#[bitwarden_ffi::wasm_record]
24pub struct PasswordGeneratorRequest {
25    /// Include lowercase characters (a-z).
26    pub lowercase: bool,
27    /// Include uppercase characters (A-Z).
28    pub uppercase: bool,
29    /// Include numbers (0-9).
30    pub numbers: bool,
31    /// Include special characters: ! @ # $ % ^ & *
32    pub special: bool,
33
34    /// The length of the generated password.
35    /// Note that the password length must be greater than the sum of all the minimums.
36    pub length: u8,
37
38    /// When set to true, the generated password will not contain ambiguous characters.
39    /// The ambiguous characters are: I, O, l, 0, 1
40    pub avoid_ambiguous: bool, // TODO: Should we rename this to include_all_characters?
41
42    /// The minimum number of lowercase characters in the generated password.
43    /// When set, the value must be between 1 and 9. This value is ignored if lowercase is false.
44    pub min_lowercase: Option<u8>,
45    /// The minimum number of uppercase characters in the generated password.
46    /// When set, the value must be between 1 and 9. This value is ignored if uppercase is false.
47    pub min_uppercase: Option<u8>,
48    /// The minimum number of numbers in the generated password.
49    /// When set, the value must be between 1 and 9. This value is ignored if numbers is false.
50    pub min_number: Option<u8>,
51    /// The minimum number of special characters in the generated password.
52    /// When set, the value must be between 1 and 9. This value is ignored if special is false.
53    pub min_special: Option<u8>,
54
55    /// Custom characters that must each be available to the generator and from which at least
56    /// one character is guaranteed to appear in the output. Each character of the string is
57    /// treated as a member of the custom required set. Non-ASCII-printable characters are
58    /// silently dropped during validation.
59    ///
60    /// This is primarily used by the HTML `passwordrules` parser to honor custom required
61    /// character classes (e.g. `required: [!#$]`).
62    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
63    #[cfg_attr(feature = "wasm", tsify(optional))]
64    pub custom_required_chars: Option<String>,
65    /// Custom characters that are added to the overall pool of allowed characters, but are not
66    /// required to appear. Each character of the string is treated as a member of the custom
67    /// allowed set. Non-ASCII-printable characters are silently dropped during validation.
68    ///
69    /// This is primarily used by the HTML `passwordrules` parser to honor custom allowed
70    /// character classes (e.g. `allowed: [-_.]`).
71    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
72    #[cfg_attr(feature = "wasm", tsify(optional))]
73    pub custom_allowed_chars: Option<String>,
74
75    /// The maximum number of consecutive identical characters allowed in the generated password,
76    /// as expressed by the HTML `passwordrules` `max-consecutive` property. `None` disables
77    /// the check; `Some(0)` is invalid and rejected at request validation. Enforced via
78    /// re-shuffle with a single-pass repair fallback for degenerate pool sizes.
79    #[cfg_attr(feature = "uniffi", uniffi(default = None))]
80    #[cfg_attr(feature = "wasm", tsify(optional))]
81    pub max_consecutive: Option<u8>,
82}
83
84const DEFAULT_PASSWORD_LENGTH: u8 = 16;
85
86/// Minimum password length accepted by client-facing callers.
87/// The SDK's internal validator allows shorter values, but all Bitwarden clients
88/// silently clamp up to this value for entropy reasons.
89pub const MINIMUM_PASSWORD_LENGTH: u8 = 5;
90/// Maximum password length accepted by Bitwarden clients.
91pub const MAXIMUM_PASSWORD_LENGTH: u8 = 128;
92/// Minimum value for `min_number` / `min_special` (per-charset minimum count).
93pub const MINIMUM_MIN_CHAR_COUNT: u8 = 0;
94/// Maximum value for `min_number` / `min_special` (per-charset minimum count).
95pub const MAXIMUM_MIN_CHAR_COUNT: u8 = 9;
96
97impl Default for PasswordGeneratorRequest {
98    fn default() -> Self {
99        Self {
100            lowercase: true,
101            uppercase: true,
102            numbers: true,
103            special: false,
104            length: DEFAULT_PASSWORD_LENGTH,
105            avoid_ambiguous: false,
106            min_lowercase: None,
107            min_uppercase: None,
108            min_number: None,
109            min_special: None,
110            custom_required_chars: None,
111            custom_allowed_chars: None,
112            max_consecutive: None,
113        }
114    }
115}
116
117/// Filters the characters of `s` down to ASCII-printable, non-whitespace, deduplicated
118/// characters, preserving relative order on first occurrence.
119fn sanitize_custom_chars(s: &str) -> Vec<char> {
120    let mut seen = BTreeSet::new();
121    s.chars()
122        .filter(|c| c.is_ascii_graphic())
123        .filter(|c| seen.insert(*c))
124        .collect()
125}
126
127const UPPER_CHARS_AMBIGUOUS: &[char] = &['I', 'O'];
128const LOWER_CHARS_AMBIGUOUS: &[char] = &['l'];
129const NUMBER_CHARS_AMBIGUOUS: &[char] = &['0', '1'];
130const SPECIAL_CHARS: &[char] = &['!', '@', '#', '$', '%', '^', '&', '*'];
131
132/// A set of characters used to generate a password. This set is backed by a BTreeSet
133/// to have consistent ordering between runs. This is not important during normal execution,
134/// but it's necessary for the tests to be repeatable.
135/// To create an instance, use [`CharSet::default()`](CharSet::default)
136#[derive(Clone, Default)]
137struct CharSet(BTreeSet<char>);
138impl CharSet {
139    /// Includes the given characters in the set. Any duplicate items will be ignored
140    pub fn include(self, other: impl IntoIterator<Item = char>) -> Self {
141        self.include_if(true, other)
142    }
143
144    /// Includes the given characters in the set if the predicate is true. Any duplicate items will
145    /// be ignored
146    pub fn include_if(mut self, predicate: bool, other: impl IntoIterator<Item = char>) -> Self {
147        if predicate {
148            self.0.extend(other);
149        }
150        self
151    }
152
153    /// Excludes the given characters from the set. Any missing items will be ignored
154    pub fn exclude_if<'a>(
155        self,
156        predicate: bool,
157        other: impl IntoIterator<Item = &'a char>,
158    ) -> Self {
159        if predicate {
160            let other: BTreeSet<_> = other.into_iter().copied().collect();
161            Self(self.0.difference(&other).copied().collect())
162        } else {
163            self
164        }
165    }
166}
167impl<'a> IntoIterator for &'a CharSet {
168    type Item = char;
169    type IntoIter = std::iter::Copied<std::collections::btree_set::Iter<'a, char>>;
170    fn into_iter(self) -> Self::IntoIter {
171        self.0.iter().copied()
172    }
173}
174impl Distribution<char> for CharSet {
175    fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> char {
176        let idx = rng.random_range(0..self.0.len());
177        *self.0.iter().nth(idx).expect("Valid index")
178    }
179}
180
181/// Represents a set of valid options to generate a password with.
182/// To get an instance of it, use
183/// [`PasswordGeneratorRequest::validate_options`](PasswordGeneratorRequest::validate_options)
184struct PasswordGeneratorOptions {
185    lower: (CharSet, usize),
186    upper: (CharSet, usize),
187    number: (CharSet, usize),
188    special: (CharSet, usize),
189    /// Custom required characters from `passwordrules` `required: [...]` entries. At least one
190    /// char from this set is guaranteed to appear in the output when the set is non-empty.
191    custom: (CharSet, usize),
192    all: (CharSet, usize),
193
194    length: usize,
195
196    /// Maximum number of consecutive identical characters allowed in the output. `None`
197    /// disables the check. Validated to be `>= 1` in
198    /// [`PasswordGeneratorRequest::validate_options`].
199    max_consecutive: Option<usize>,
200}
201
202impl PasswordGeneratorRequest {
203    /// Validates the request and returns an immutable struct with valid options to use with the
204    /// password generator.
205    fn validate_options(self) -> Result<PasswordGeneratorOptions, PasswordError> {
206        // TODO: Add password generator policy checks
207
208        // Sanitize custom char lists defensively: the parser already filters non-printable
209        // characters, but the request type is `pub` so callers can construct it directly.
210        let custom_required = self
211            .custom_required_chars
212            .as_deref()
213            .map(sanitize_custom_chars)
214            .unwrap_or_default();
215        let custom_allowed: Vec<char> = self
216            .custom_allowed_chars
217            .as_deref()
218            .map(sanitize_custom_chars)
219            .unwrap_or_default();
220
221        // We always have to have at least one character set enabled (standard or custom).
222        if !self.lowercase
223            && !self.uppercase
224            && !self.numbers
225            && !self.special
226            && custom_required.is_empty()
227            && custom_allowed.is_empty()
228        {
229            return Err(PasswordError::NoCharacterSetEnabled);
230        }
231
232        if self.length < 4 {
233            return Err(PasswordError::InvalidLength);
234        }
235
236        // Make sure the minimum values are zero when the character
237        // set is disabled, and at least one when it's enabled
238        fn get_minimum(min: Option<u8>, enabled: bool) -> usize {
239            if enabled {
240                usize::max(min.unwrap_or(1) as usize, 1)
241            } else {
242                0
243            }
244        }
245
246        let length = self.length as usize;
247        let min_lowercase = get_minimum(self.min_lowercase, self.lowercase);
248        let min_uppercase = get_minimum(self.min_uppercase, self.uppercase);
249        let min_number = get_minimum(self.min_number, self.numbers);
250        let min_special = get_minimum(self.min_special, self.special);
251        let min_custom = if custom_required.is_empty() { 0 } else { 1 };
252
253        // Check that the minimum lengths aren't larger than the password length
254        let minimum_length = min_lowercase + min_uppercase + min_number + min_special + min_custom;
255        if minimum_length > length {
256            return Err(PasswordError::InvalidLength);
257        }
258
259        let lower = (
260            CharSet::default()
261                .include_if(self.lowercase, 'a'..='z')
262                .exclude_if(self.avoid_ambiguous, LOWER_CHARS_AMBIGUOUS),
263            min_lowercase,
264        );
265
266        let upper = (
267            CharSet::default()
268                .include_if(self.uppercase, 'A'..='Z')
269                .exclude_if(self.avoid_ambiguous, UPPER_CHARS_AMBIGUOUS),
270            min_uppercase,
271        );
272
273        let number = (
274            CharSet::default()
275                .include_if(self.numbers, '0'..='9')
276                .exclude_if(self.avoid_ambiguous, NUMBER_CHARS_AMBIGUOUS),
277            min_number,
278        );
279
280        let special = (
281            CharSet::default().include_if(self.special, SPECIAL_CHARS.iter().copied()),
282            min_special,
283        );
284
285        let custom = (
286            CharSet::default().include(custom_required.iter().copied()),
287            min_custom,
288        );
289
290        let all = (
291            CharSet::default()
292                .include(&lower.0)
293                .include(&upper.0)
294                .include(&number.0)
295                .include(&special.0)
296                .include(&custom.0)
297                .include(custom_allowed.iter().copied()),
298            length - minimum_length,
299        );
300
301        // A `max_consecutive` of 0 would forbid every output (no character can appear once
302        // without exceeding a run of 0). Reject it up front so the generator can assume any
303        // populated `Some(_)` is `>= 1`.
304        let max_consecutive = match self.max_consecutive {
305            None => None,
306            Some(0) => return Err(PasswordError::InvalidLength),
307            Some(n) => Some(n as usize),
308        };
309
310        Ok(PasswordGeneratorOptions {
311            lower,
312            upper,
313            number,
314            special,
315            custom,
316            all,
317            length,
318            max_consecutive,
319        })
320    }
321}
322
323/// Implementation of the random password generator.
324pub(crate) fn password(input: PasswordGeneratorRequest) -> Result<String, PasswordError> {
325    let options = input.validate_options()?;
326    Ok(password_with_rng(bitwarden_random::rng(), options))
327}
328
329/// Test-only helper that validates a request and runs the generator with a caller-supplied RNG.
330/// Lets the `passwordrules` test module exercise the end-to-end generator path deterministically.
331#[cfg(test)]
332pub(crate) fn password_with_rng_for_test(
333    rng: impl Rng,
334    input: PasswordGeneratorRequest,
335) -> Result<String, PasswordError> {
336    let options = input.validate_options()?;
337    Ok(password_with_rng(rng, options))
338}
339
340fn password_with_rng(mut rng: impl Rng, options: PasswordGeneratorOptions) -> String {
341    let mut buf: Vec<char> = Vec::with_capacity(options.length);
342
343    let opts = [
344        &options.all,
345        &options.upper,
346        &options.lower,
347        &options.number,
348        &options.special,
349        &options.custom,
350    ];
351    for (set, qty) in opts {
352        buf.extend(set.sample_iter(&mut rng).take(*qty));
353    }
354
355    buf.shuffle(&mut rng);
356
357    if let Some(limit) = options.max_consecutive {
358        // For realistic inputs (length up to 128, charset size > limit) a re-shuffle clears
359        // the violation in a few rounds. The repair-pass fallback handles pathological cases
360        // (small charset, large length) where re-shuffles wouldn't terminate quickly.
361        const MAX_RESHUFFLES: u8 = 16;
362        let mut tries = 0;
363        while violates_max_consecutive(&buf, limit) && tries < MAX_RESHUFFLES {
364            buf.shuffle(&mut rng);
365            tries += 1;
366        }
367        if violates_max_consecutive(&buf, limit) {
368            repair_consecutive(&mut buf, limit);
369        }
370    }
371
372    buf.iter().collect()
373}
374
375/// Returns `true` if `buf` contains a run of identical characters longer than `limit`.
376fn violates_max_consecutive(buf: &[char], limit: usize) -> bool {
377    if limit == 0 || buf.len() <= limit {
378        return false;
379    }
380    let mut run = 1usize;
381    for w in buf.windows(2) {
382        if w[0] == w[1] {
383            run += 1;
384            if run > limit {
385                return true;
386            }
387        } else {
388            run = 1;
389        }
390    }
391    false
392}
393
394/// Single-pass repair: scans `buf` and, whenever a run grows past `limit`, swaps the
395/// offending character with the next non-matching character ahead in the buffer. Used
396/// only when re-shuffling fails to clear the constraint (typically when the available
397/// pool is degenerately small relative to the requested length).
398fn repair_consecutive(buf: &mut [char], limit: usize) {
399    if limit == 0 || buf.len() <= limit {
400        return;
401    }
402    let mut run = 1usize;
403    let mut i = 1;
404    while i < buf.len() {
405        if buf[i] == buf[i - 1] {
406            run += 1;
407            if run > limit {
408                // Find any later position with a character that breaks both the current
409                // run and the trailing run (so we don't extend a different violation).
410                let target = (i + 1..buf.len())
411                    .find(|&k| buf[k] != buf[i] && (k + 1 == buf.len() || buf[k + 1] != buf[i]));
412                match target {
413                    Some(j) => {
414                        buf.swap(i, j);
415                        run = 1;
416                    }
417                    None => {
418                        // No feasible swap target; leave the constraint partially satisfied
419                        // rather than looping. For realistic inputs (any charset of size >=2
420                        // with charset_size >> length / limit) this branch is unreachable.
421                        return;
422                    }
423                }
424            }
425        } else {
426            run = 1;
427        }
428        i += 1;
429    }
430}
431
432#[cfg(test)]
433mod test {
434    use std::collections::BTreeSet;
435
436    use rand::SeedableRng;
437
438    use super::*;
439
440    // We convert the slices to BTreeSets to be able to use `is_subset`
441    fn ref_to_set<'a>(chars: impl IntoIterator<Item = &'a char>) -> BTreeSet<char> {
442        chars.into_iter().copied().collect()
443    }
444    fn to_set(chars: impl IntoIterator<Item = char>) -> BTreeSet<char> {
445        chars.into_iter().collect()
446    }
447
448    #[test]
449    fn test_password_gen_all_charsets_enabled() {
450        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
451
452        let options = PasswordGeneratorRequest {
453            lowercase: true,
454            uppercase: true,
455            numbers: true,
456            special: true,
457            avoid_ambiguous: false,
458            ..Default::default()
459        }
460        .validate_options()
461        .unwrap();
462
463        assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
464        assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
465        assert_eq!(to_set(&options.number.0), to_set('0'..='9'));
466        assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
467
468        let pass = password_with_rng(&mut rng, options);
469        assert_eq!(pass, "0oA772tQjaUO$a@L");
470    }
471
472    #[test]
473    fn test_password_gen_only_letters_enabled() {
474        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
475
476        let options = PasswordGeneratorRequest {
477            lowercase: true,
478            uppercase: true,
479            numbers: false,
480            special: false,
481            avoid_ambiguous: false,
482            ..Default::default()
483        }
484        .validate_options()
485        .unwrap();
486
487        assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
488        assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
489        assert_eq!(to_set(&options.number.0), to_set([]));
490        assert_eq!(to_set(&options.special.0), to_set([]));
491
492        let pass = password_with_rng(&mut rng, options);
493        assert_eq!(pass, "FrNSJGvhnAbXggMU");
494    }
495
496    #[test]
497    fn test_password_gen_only_numbers_and_lower_enabled_no_ambiguous() {
498        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
499
500        let options = PasswordGeneratorRequest {
501            lowercase: true,
502            uppercase: false,
503            numbers: true,
504            special: false,
505            avoid_ambiguous: true,
506            ..Default::default()
507        }
508        .validate_options()
509        .unwrap();
510
511        assert!(to_set(&options.lower.0).is_subset(&to_set('a'..='z')));
512        assert!(to_set(&options.lower.0).is_disjoint(&ref_to_set(LOWER_CHARS_AMBIGUOUS)));
513
514        assert!(to_set(&options.number.0).is_subset(&to_set('0'..='9')));
515        assert!(to_set(&options.number.0).is_disjoint(&ref_to_set(NUMBER_CHARS_AMBIGUOUS)));
516
517        assert_eq!(to_set(&options.upper.0), to_set([]));
518        assert_eq!(to_set(&options.special.0), to_set([]));
519
520        let pass = password_with_rng(&mut rng, options);
521        assert_eq!(pass, "5uat85wos2jg4n9f");
522    }
523
524    #[test]
525    fn test_password_gen_only_upper_and_special_enabled_no_ambiguous() {
526        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
527
528        let options = PasswordGeneratorRequest {
529            lowercase: false,
530            uppercase: true,
531            numbers: false,
532            special: true,
533            avoid_ambiguous: true,
534            ..Default::default()
535        }
536        .validate_options()
537        .unwrap();
538
539        assert!(to_set(&options.upper.0).is_subset(&to_set('A'..='Z')));
540        assert!(to_set(&options.upper.0).is_disjoint(&ref_to_set(UPPER_CHARS_AMBIGUOUS)));
541
542        assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
543
544        assert_eq!(to_set(&options.lower.0), to_set([]));
545        assert_eq!(to_set(&options.number.0), to_set([]));
546
547        let pass = password_with_rng(&mut rng, options);
548        assert_eq!(pass, "%VBT*%YPT!LH$PAF");
549    }
550
551    #[test]
552    fn test_password_gen_minimum_limits() {
553        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
554
555        let options = PasswordGeneratorRequest {
556            lowercase: true,
557            uppercase: true,
558            numbers: true,
559            special: true,
560            avoid_ambiguous: false,
561            length: 24,
562            min_lowercase: Some(5),
563            min_uppercase: Some(5),
564            min_number: Some(5),
565            min_special: Some(5),
566            ..Default::default()
567        }
568        .validate_options()
569        .unwrap();
570
571        assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
572        assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
573        assert_eq!(to_set(&options.number.0), to_set('0'..='9'));
574        assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
575
576        assert_eq!(options.lower.1, 5);
577        assert_eq!(options.upper.1, 5);
578        assert_eq!(options.number.1, 5);
579        assert_eq!(options.special.1, 5);
580
581        let pass = password_with_rng(&mut rng, options);
582        assert_eq!(pass, "t&c0L73*D*G%aak7goq!N2T4");
583    }
584
585    fn longest_run(s: &str) -> usize {
586        let mut longest = 0usize;
587        let mut run = 0usize;
588        let mut prev: Option<char> = None;
589        for c in s.chars() {
590            if prev == Some(c) {
591                run += 1;
592            } else {
593                run = 1;
594                prev = Some(c);
595            }
596            longest = longest.max(run);
597        }
598        longest
599    }
600
601    #[test]
602    fn test_password_gen_honors_max_consecutive() {
603        // 64 attempts at length 64 across ascii-printable: even ignoring our enforcement,
604        // ambient runs of 4 should be vanishingly rare. With the constraint enforced
605        // (max_consecutive: 2) every iteration must satisfy run-length <= 2.
606        for seed_byte in 0u8..64 {
607            let mut rng = rand_chacha::ChaCha8Rng::from_seed([seed_byte; 32]);
608            let options = PasswordGeneratorRequest {
609                lowercase: true,
610                uppercase: true,
611                numbers: true,
612                special: true,
613                avoid_ambiguous: false,
614                length: 64,
615                max_consecutive: Some(2),
616                ..Default::default()
617            }
618            .validate_options()
619            .unwrap();
620            let pass = password_with_rng(&mut rng, options);
621            let run = longest_run(&pass);
622            assert!(
623                run <= 2,
624                "seed={seed_byte}: produced {pass:?} with run of length {run} (>2)"
625            );
626        }
627    }
628
629    #[test]
630    fn test_password_gen_max_consecutive_one_breaks_pairs() {
631        // The tightest meaningful constraint: no two adjacent characters may be equal.
632        let mut rng = rand_chacha::ChaCha8Rng::from_seed([7u8; 32]);
633        let options = PasswordGeneratorRequest {
634            lowercase: true,
635            uppercase: true,
636            numbers: true,
637            special: true,
638            avoid_ambiguous: false,
639            length: 32,
640            max_consecutive: Some(1),
641            ..Default::default()
642        }
643        .validate_options()
644        .unwrap();
645        let pass = password_with_rng(&mut rng, options);
646        assert!(
647            longest_run(&pass) <= 1,
648            "produced {pass:?} with adjacent duplicate"
649        );
650    }
651
652    #[test]
653    fn test_password_gen_max_consecutive_zero_is_rejected() {
654        let result = PasswordGeneratorRequest {
655            lowercase: true,
656            length: 14,
657            max_consecutive: Some(0),
658            ..Default::default()
659        }
660        .validate_options();
661        assert!(
662            matches!(result, Err(PasswordError::InvalidLength)),
663            "expected InvalidLength for max_consecutive=Some(0)"
664        );
665    }
666
667    #[test]
668    fn test_password_gen_max_consecutive_none_is_unconstrained() {
669        // Sanity: explicit None and Default's None should behave identically (no constraint).
670        let mut rng_a = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
671        let mut rng_b = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
672        let opts_a = PasswordGeneratorRequest {
673            lowercase: true,
674            uppercase: true,
675            numbers: true,
676            special: true,
677            avoid_ambiguous: false,
678            ..Default::default()
679        }
680        .validate_options()
681        .unwrap();
682        let opts_b = PasswordGeneratorRequest {
683            lowercase: true,
684            uppercase: true,
685            numbers: true,
686            special: true,
687            avoid_ambiguous: false,
688            max_consecutive: None,
689            ..Default::default()
690        }
691        .validate_options()
692        .unwrap();
693        assert_eq!(
694            password_with_rng(&mut rng_a, opts_a),
695            password_with_rng(&mut rng_b, opts_b)
696        );
697    }
698
699    #[test]
700    fn test_custom_required_chars_appear_in_output() {
701        // The generator allocates one slot from the custom-required set up front, so at least
702        // one of the supplied chars is guaranteed to appear in the output.
703        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
704        let options = PasswordGeneratorRequest {
705            lowercase: true,
706            uppercase: true,
707            numbers: true,
708            special: true,
709            avoid_ambiguous: false,
710            length: 32,
711            custom_required_chars: Some("!@#".to_string()),
712            ..Default::default()
713        }
714        .validate_options()
715        .unwrap();
716
717        // Required-custom contributes 1 to the minimum-count.
718        assert_eq!(options.custom.1, 1);
719        assert_eq!(to_set(&options.custom.0), BTreeSet::from(['!', '@', '#']));
720
721        let pass = password_with_rng(&mut rng, options);
722        let any = pass.chars().any(|c| matches!(c, '!' | '@' | '#'));
723        assert!(any, "expected at least one of !@# in {pass:?}");
724    }
725
726    #[test]
727    fn test_custom_allowed_chars_extend_pool_without_requirement() {
728        // `custom_allowed_chars` should join the overall pool but not force any specific char
729        // to appear. With no standard classes enabled, the output should consist exclusively
730        // of the allowed-custom chars.
731        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
732        let options = PasswordGeneratorRequest {
733            lowercase: false,
734            uppercase: false,
735            numbers: false,
736            special: false,
737            avoid_ambiguous: false,
738            length: 16,
739            custom_allowed_chars: Some("XYZ".to_string()),
740            ..Default::default()
741        }
742        .validate_options()
743        .unwrap();
744
745        // The `custom` slot covers required chars only — empty here.
746        assert!(to_set(&options.custom.0).is_empty());
747        assert_eq!(options.custom.1, 0);
748        // The `all` pool is the union of every enabled set — just XYZ in this case.
749        assert_eq!(to_set(&options.all.0), BTreeSet::from(['X', 'Y', 'Z']));
750
751        let pass = password_with_rng(&mut rng, options);
752        assert!(
753            pass.chars().all(|c| matches!(c, 'X' | 'Y' | 'Z')),
754            "expected output to consist only of XYZ, got {pass:?}"
755        );
756        assert_eq!(pass.chars().count(), 16);
757    }
758
759    #[test]
760    fn test_custom_required_is_sole_charset_when_no_standards_enabled() {
761        // Only `custom_required_chars` set, no standard classes, no allowed extension:
762        // the request must validate, and the output must be drawn exclusively from those chars.
763        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
764        let options = PasswordGeneratorRequest {
765            lowercase: false,
766            uppercase: false,
767            numbers: false,
768            special: false,
769            avoid_ambiguous: false,
770            length: 8,
771            custom_required_chars: Some("abc".to_string()),
772            ..Default::default()
773        }
774        .validate_options()
775        .unwrap();
776
777        assert_eq!(options.custom.1, 1);
778        let pass = password_with_rng(&mut rng, options);
779        assert!(
780            pass.chars().all(|c| matches!(c, 'a' | 'b' | 'c')),
781            "expected output to consist only of abc, got {pass:?}"
782        );
783        assert_eq!(pass.chars().count(), 8);
784    }
785
786    #[test]
787    fn test_custom_chars_are_sanitized() {
788        // `sanitize_custom_chars` keeps ASCII-graphic chars, drops whitespace, and dedupes.
789        let options = PasswordGeneratorRequest {
790            lowercase: false,
791            uppercase: false,
792            numbers: false,
793            special: false,
794            avoid_ambiguous: false,
795            length: 8,
796            custom_required_chars: Some(" a\tbb!@\n".to_string()),
797            ..Default::default()
798        }
799        .validate_options()
800        .unwrap();
801
802        // Whitespace ( , \t, \n) dropped; the doubled 'b' deduplicated.
803        assert_eq!(
804            to_set(&options.custom.0),
805            BTreeSet::from(['a', 'b', '!', '@'])
806        );
807    }
808
809    #[test]
810    fn test_custom_required_and_allowed_compose() {
811        // Required chars must appear (1 slot reserved); allowed chars contribute to the pool
812        // but aren't forced; the total pool is the union of all custom sets.
813        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
814        let options = PasswordGeneratorRequest {
815            lowercase: false,
816            uppercase: false,
817            numbers: false,
818            special: false,
819            avoid_ambiguous: false,
820            length: 16,
821            custom_required_chars: Some("!".to_string()),
822            custom_allowed_chars: Some("abc".to_string()),
823            ..Default::default()
824        }
825        .validate_options()
826        .unwrap();
827
828        assert_eq!(to_set(&options.custom.0), BTreeSet::from(['!']));
829        assert_eq!(options.custom.1, 1);
830        assert_eq!(to_set(&options.all.0), BTreeSet::from(['!', 'a', 'b', 'c']));
831
832        let pass = password_with_rng(&mut rng, options);
833        assert!(
834            pass.contains('!'),
835            "expected required ! to appear in {pass:?}"
836        );
837        assert!(
838            pass.chars().all(|c| matches!(c, '!' | 'a' | 'b' | 'c')),
839            "expected output to consist only of !abc, got {pass:?}"
840        );
841    }
842}