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