mk_pass/
lib.rs

1#![doc = include_str!("../README.md")]
2#![doc(
3    html_logo_url = "https://raw.githubusercontent.com/2bndy5/mk-pass/main/docs/docs/images/logo-square.png"
4)]
5#![doc(
6    html_favicon_url = "https://github.com/2bndy5/mk-pass/raw/main/docs/docs/images/favicon.ico"
7)]
8use rand::prelude::*;
9mod helpers;
10use helpers::{CharKind, CountTypesUsed};
11pub use helpers::{DECIMAL, LOWERCASE, SPECIAL_CHARACTERS, UPPERCASE};
12mod config;
13pub use config::PasswordRequirements;
14
15#[cfg(feature = "clap")]
16pub use clap;
17
18/// Generate a password given the constraints specified by `config`.
19///
20/// This function will invoke [`PasswordRequirements::validate()`] to
21/// ensure basic password requirements are met.
22pub fn generate_password(config: PasswordRequirements) -> String {
23    let config = config.validate();
24    let len = config.length as usize;
25    let mut rng = rand::rng();
26    let mut password = String::with_capacity(len);
27
28    let mut used_types = CountTypesUsed::default();
29    let mut available_types = vec![CharKind::Uppercase, CharKind::Lowercase];
30    if config.specials > 0 {
31        available_types.push(CharKind::Special);
32    }
33    if config.decimal > 0 {
34        available_types.push(CharKind::Decimal);
35    }
36    let max_letters = len as u16 - config.decimal - config.specials;
37    let max_lowercase = max_letters / 2;
38    let max_uppercase = max_letters - max_lowercase;
39    #[cfg(test)]
40    {
41        println!("max_lowers: {max_lowercase}, max_uppers: {max_uppercase}");
42    }
43
44    let mut pass_chars = vec!['\n'; len];
45
46    let start = if config.first_is_letter {
47        let letter_types = [CharKind::Lowercase, CharKind::Uppercase];
48        let sample_kind = letter_types[rng.random_range(0..letter_types.len())];
49        let sample_set = sample_kind.into_sample();
50        pass_chars[0] = sample_set[rng.random_range(0..LOWERCASE.len())];
51        match sample_kind {
52            CharKind::Lowercase => {
53                used_types.lowercase += 1;
54                if used_types.lowercase == max_lowercase {
55                    available_types = CharKind::pop_kind(available_types, &sample_kind);
56                }
57            }
58            _ => {
59                used_types.uppercase += 1;
60                if used_types.uppercase == max_uppercase {
61                    available_types = CharKind::pop_kind(available_types, &sample_kind);
62                }
63            }
64        }
65        1
66    } else {
67        0
68    };
69    let mut positions = (start..len).collect::<Vec<usize>>();
70
71    for _ in start..len {
72        debug_assert!(!available_types.is_empty());
73        // pick a sample set from which to pick a character
74        let kind = available_types[rng.random_range(0..available_types.len())];
75        match kind {
76            CharKind::Lowercase => {
77                used_types.lowercase += 1;
78                #[cfg(test)]
79                {
80                    println!("used lowers: {}", used_types.lowercase);
81                }
82                if used_types.lowercase == max_lowercase {
83                    available_types = CharKind::pop_kind(available_types, &kind);
84                }
85            }
86            CharKind::Uppercase => {
87                used_types.uppercase += 1;
88                #[cfg(test)]
89                {
90                    println!("used uppers: {}", used_types.uppercase);
91                }
92                if used_types.uppercase == max_uppercase {
93                    available_types = CharKind::pop_kind(available_types, &kind);
94                }
95            }
96            CharKind::Decimal => {
97                used_types.number += 1;
98                if used_types.number == config.decimal {
99                    available_types = CharKind::pop_kind(available_types, &kind);
100                }
101            }
102            CharKind::Special => {
103                used_types.special += 1;
104                if used_types.special == config.specials {
105                    available_types = CharKind::pop_kind(available_types, &kind);
106                }
107            }
108        }
109
110        // now generate character from selected sample set
111        let sample = kind.into_sample();
112        let mut rand_index = rng.random_range(0..sample.len());
113        if !config.allow_repeats {
114            while pass_chars.contains(&sample[rand_index]) {
115                rand_index = rng.random_range(0..sample.len());
116            }
117        }
118
119        // now pick an index in the password that hasn't been used
120        let rnd_pos = rng.random_range(0..positions.len());
121        let pos = positions.remove(rnd_pos);
122        pass_chars[pos] = sample[rand_index];
123    }
124
125    for ch in pass_chars {
126        password.push(ch);
127    }
128    password
129}
130
131#[cfg(test)]
132mod test {
133    use super::{PasswordRequirements, generate_password};
134    use crate::helpers::{DECIMAL, LOWERCASE, SPECIAL_CHARACTERS, UPPERCASE};
135
136    fn count(output: &str) -> (usize, usize, usize, usize, usize) {
137        let (mut uppers, mut lowers, mut decimal, mut specials) = (0, 0, 0, 0);
138        let mut repeats = vec![];
139        for (i, ch) in output.char_indices() {
140            if LOWERCASE.contains(&ch) {
141                lowers += 1;
142            } else if UPPERCASE.contains(&ch) {
143                uppers += 1;
144            } else if DECIMAL.contains(&ch) {
145                decimal += 1;
146            } else if SPECIAL_CHARACTERS.contains(&ch) {
147                specials += 1;
148            }
149            if output[0..i].contains(ch) && !repeats.contains(&ch) {
150                repeats.push(ch);
151            }
152        }
153        let repeats = repeats.len();
154        println!(
155            "decimal: {decimal}, uppercase: {uppers}, lowercase: {lowers}, special: {specials}, repeats: {repeats}"
156        );
157        (uppers, lowers, decimal, specials, repeats)
158    }
159
160    fn gen_pass(config: PasswordRequirements) {
161        let password = generate_password(config);
162        println!("Generated password: {password}");
163        assert_eq!(password.len(), config.length as usize);
164        let (uppers, lowers, decimal, specials, repeats) = count(&password);
165        assert_eq!(decimal, config.decimal as usize);
166        assert_eq!(specials, config.specials as usize);
167        let letters = (config.length - config.specials - config.decimal) as usize;
168        assert_eq!(letters, uppers + lowers);
169        assert_eq!(repeats > 0, config.allow_repeats);
170        if config.first_is_letter {
171            let first = password.chars().next().unwrap();
172            assert!(LOWERCASE.contains(&first) || UPPERCASE.contains(&first));
173        }
174    }
175
176    #[test]
177    fn special_4() {
178        let config = PasswordRequirements {
179            specials: 4,
180            ..Default::default()
181        };
182        gen_pass(config);
183    }
184
185    #[test]
186    fn no_special() {
187        let config = PasswordRequirements {
188            specials: 0,
189            ..Default::default()
190        };
191        gen_pass(config);
192    }
193
194    #[test]
195    fn no_first_is_letter() {
196        // NOTE: there's no way to adequately test the randomness of the first char.
197        let config = PasswordRequirements {
198            first_is_letter: false,
199            ..Default::default()
200        };
201        gen_pass(config);
202    }
203
204    #[test]
205    fn no_decimal() {
206        let config = PasswordRequirements {
207            decimal: 0,
208            ..Default::default()
209        };
210        gen_pass(config);
211    }
212
213    #[test]
214    fn allow_repeats() {
215        let config = PasswordRequirements {
216            allow_repeats: true,
217            decimal: 18,
218            length: 20,
219            specials: 0,
220            ..Default::default()
221        };
222        gen_pass(config);
223    }
224
225    /// This is a hacky way to ensure complete coverage about the first character kind.
226    ///
227    /// It basically keeps generating a password until the first random letter is either
228    /// [`UPPERCASE`] or [`LOWERCASE`] as specified by the `lower` parameter.
229    ///
230    /// Theoretically, this could cause an infinite loop or just run a long time because
231    /// we are relying on randomness. However, the condition being tested means the
232    /// probability of the randomness is 50:50.
233    fn till_first_is(lower: bool) {
234        let config = PasswordRequirements {
235            specials: 8,
236            decimal: 0,
237            length: 10,
238            ..Default::default()
239        };
240        let sample_set: &[char] = if lower { &LOWERCASE } else { &UPPERCASE };
241
242        let mut password = generate_password(config);
243        while !sample_set.contains(&password.chars().next().unwrap()) {
244            password = generate_password(config);
245        }
246    }
247
248    #[test]
249    fn gen_first_lower() {
250        till_first_is(true);
251    }
252
253    #[test]
254    fn gen_first_upper() {
255        till_first_is(false);
256    }
257}