1use std::collections::BTreeSet;
17
18use bitwarden_error::bitwarden_error;
19use password_rules_parser::{
20 CharacterClass, PasswordRules, parse_password_rules as parse_external,
21};
22use thiserror::Error;
23
24use crate::password::{MAXIMUM_PASSWORD_LENGTH, MINIMUM_PASSWORD_LENGTH, PasswordGeneratorRequest};
25
26#[allow(missing_docs)]
28#[bitwarden_error(flat)]
29#[derive(Debug, Error, PartialEq, Eq)]
30pub enum PasswordRulesError {
31 #[error("Failed to parse password rules: {0}")]
35 Parse(String),
36 #[error("Invalid length constraint")]
38 InvalidLength,
39}
40
41const DEFAULT_LENGTH: u32 = 16;
43
44const MAX_ECHOED_VALUE_LEN: usize = 64;
47
48fn truncate_for_error(s: &str) -> String {
51 let trimmed = s.trim();
52 if trimmed.chars().count() <= MAX_ECHOED_VALUE_LEN {
53 trimmed.to_string()
54 } else {
55 let truncated: String = trimmed.chars().take(MAX_ECHOED_VALUE_LEN).collect();
56 format!("{truncated}…")
57 }
58}
59
60#[derive(Default, Debug, Clone)]
66struct AccumulatedClasses {
67 upper: bool,
68 lower: bool,
69 digit: bool,
70 special: bool,
71 custom: BTreeSet<char>,
72}
73
74impl AccumulatedClasses {
75 fn is_empty(&self) -> bool {
76 !self.upper && !self.lower && !self.digit && !self.special && self.custom.is_empty()
77 }
78
79 fn apply(&mut self, class: &CharacterClass) {
80 match class {
81 CharacterClass::Upper => self.upper = true,
82 CharacterClass::Lower => self.lower = true,
83 CharacterClass::Digit => self.digit = true,
84 CharacterClass::Special => self.special = true,
85 CharacterClass::AsciiPrintable | CharacterClass::Unicode => {
89 self.upper = true;
90 self.lower = true;
91 self.digit = true;
92 self.special = true;
93 }
94 CharacterClass::Custom(chars) => {
95 self.custom
98 .extend(chars.iter().copied().filter(|c| c.is_ascii_graphic()));
99 }
100 }
101 }
102}
103
104pub fn parse_password_rules(rules: &str) -> Result<PasswordGeneratorRequest, PasswordRulesError> {
109 if rules.trim().is_empty() {
112 return assemble_request(
113 None,
114 None,
115 None,
116 AccumulatedClasses::default(),
117 AccumulatedClasses::default(),
118 false,
119 );
120 }
121
122 let parsed = parse_external(rules, false).map_err(parse_error_to_sdk)?;
123
124 let PasswordRules {
125 min_length,
126 max_length,
127 max_consecutive,
128 allowed,
129 required,
130 } = parsed;
131
132 let mut required_classes = AccumulatedClasses::default();
133 for group in &required {
134 for cls in group {
135 required_classes.apply(cls);
136 }
137 }
138
139 let mut allowed_classes = AccumulatedClasses::default();
140 let allowed_seen = !allowed.is_empty();
141 for cls in &allowed {
142 allowed_classes.apply(cls);
143 }
144
145 assemble_request(
146 min_length,
147 max_length,
148 max_consecutive,
149 required_classes,
150 allowed_classes,
151 allowed_seen,
152 )
153}
154
155fn assemble_request(
157 min_length: Option<u32>,
158 max_length: Option<u32>,
159 max_consecutive: Option<u32>,
160 required: AccumulatedClasses,
161 mut allowed: AccumulatedClasses,
162 allowed_seen: bool,
163) -> Result<PasswordGeneratorRequest, PasswordRulesError> {
164 if !allowed_seen {
168 if !required.is_empty() {
169 allowed = required.clone();
170 } else {
171 allowed.upper = true;
172 allowed.lower = true;
173 allowed.digit = true;
174 allowed.special = true;
175 }
176 }
177
178 let length = resolve_length(min_length, max_length)?;
179
180 let lowercase = allowed.lower || required.lower;
181 let uppercase = allowed.upper || required.upper;
182 let numbers = allowed.digit || required.digit;
183 let special = allowed.special || required.special;
184
185 let custom_required_chars: Option<String> = if required.custom.is_empty() {
186 None
187 } else {
188 Some(required.custom.iter().collect())
189 };
190 let custom_allowed_union: BTreeSet<char> = allowed
191 .custom
192 .iter()
193 .chain(required.custom.iter())
194 .copied()
195 .collect();
196 let custom_allowed_chars: Option<String> = if custom_allowed_union.is_empty() {
197 None
198 } else {
199 Some(custom_allowed_union.into_iter().collect())
200 };
201
202 let max_consecutive = match max_consecutive {
203 Some(v) => Some(u8::try_from(v).map_err(|_| PasswordRulesError::InvalidLength)?),
204 None => None,
205 };
206
207 Ok(PasswordGeneratorRequest {
208 lowercase,
209 uppercase,
210 numbers,
211 special,
212 length,
213 avoid_ambiguous: false,
214 min_lowercase: required.lower.then_some(1),
215 min_uppercase: required.upper.then_some(1),
216 min_number: required.digit.then_some(1),
217 min_special: required.special.then_some(1),
218 custom_required_chars,
219 custom_allowed_chars,
220 max_consecutive,
221 })
222}
223
224fn resolve_length(
228 min_length: Option<u32>,
229 max_length: Option<u32>,
230) -> Result<u8, PasswordRulesError> {
231 if let (Some(min), Some(max)) = (min_length, max_length)
232 && min > max
233 {
234 return Err(PasswordRulesError::InvalidLength);
235 }
236 let min_u32 = MINIMUM_PASSWORD_LENGTH as u32;
237 let max_u32 = MAXIMUM_PASSWORD_LENGTH as u32;
238 let lower_bound = min_length.unwrap_or(0).clamp(min_u32, max_u32);
239 let upper_bound = max_length.unwrap_or(max_u32).clamp(min_u32, max_u32);
240 let clamped = DEFAULT_LENGTH.clamp(lower_bound, upper_bound);
241 u8::try_from(clamped).map_err(|_| PasswordRulesError::InvalidLength)
242}
243
244fn parse_error_to_sdk<E: std::fmt::Display>(e: E) -> PasswordRulesError {
247 PasswordRulesError::Parse(truncate_for_error(&e.to_string()))
248}
249
250#[cfg(test)]
251mod tests {
252 use rand::SeedableRng;
253
254 use super::*;
255 use crate::password::password_with_rng_for_test;
256
257 #[test]
258 fn parses_minlength() {
259 let req = parse_password_rules("minlength: 12").unwrap();
260 assert_eq!(req.length, 16);
261 assert!(req.lowercase && req.uppercase && req.numbers && req.special);
263 }
264
265 #[test]
266 fn parses_maxlength_clamps_default_down() {
267 let req = parse_password_rules("maxlength: 10").unwrap();
268 assert_eq!(req.length, 10);
269 }
270
271 #[test]
272 fn parses_minlength_clamps_default_up() {
273 let req = parse_password_rules("minlength: 20").unwrap();
274 assert_eq!(req.length, 20);
275 }
276
277 #[test]
278 fn parses_minlength_clamps_to_max_password_length() {
279 let req = parse_password_rules("minlength: 200").unwrap();
280 assert_eq!(req.length, MAXIMUM_PASSWORD_LENGTH);
281 }
282
283 #[test]
284 fn parses_minlength_with_maxlength_below_default() {
285 let req = parse_password_rules("minlength: 5; maxlength: 8").unwrap();
286 assert_eq!(req.length, 8);
287 }
288
289 #[test]
290 fn parses_minlength_with_maxlength_at_default() {
291 let req = parse_password_rules("minlength: 8").unwrap();
292 assert_eq!(req.length, 16);
293 }
294
295 #[test]
296 fn rejects_minlength_greater_than_maxlength() {
297 let err = parse_password_rules("minlength: 20; maxlength: 10").unwrap_err();
298 assert_eq!(err, PasswordRulesError::InvalidLength);
299 }
300
301 #[test]
302 fn parses_max_consecutive() {
303 let req = parse_password_rules("max-consecutive: 3").unwrap();
304 assert_eq!(req.max_consecutive, Some(3));
305 }
306
307 #[test]
308 fn parses_required_alone_defaults_allowed_to_required() {
309 let req = parse_password_rules("required: upper").unwrap();
310 assert!(req.uppercase);
311 assert!(!req.lowercase);
312 assert!(!req.numbers);
313 assert!(!req.special);
314 assert_eq!(req.min_uppercase, Some(1));
315 assert_eq!(req.min_lowercase, None);
316 }
317
318 #[test]
319 fn parses_allowed_alone() {
320 let req = parse_password_rules("allowed: lower, digit").unwrap();
321 assert!(req.lowercase);
322 assert!(req.numbers);
323 assert!(!req.uppercase);
324 assert!(!req.special);
325 assert_eq!(req.min_lowercase, None);
326 assert_eq!(req.min_number, None);
327 }
328
329 #[test]
330 fn parses_required_and_allowed_together() {
331 let req = parse_password_rules("required: upper; allowed: lower, digit").unwrap();
332 assert!(req.uppercase && req.lowercase && req.numbers);
333 assert!(!req.special);
334 assert_eq!(req.min_uppercase, Some(1));
335 assert_eq!(req.min_lowercase, None);
336 }
337
338 #[test]
339 fn empty_input_defaults_to_ascii_printable() {
340 let req = parse_password_rules("").unwrap();
341 assert!(req.lowercase && req.uppercase && req.numbers && req.special);
342 }
343
344 #[test]
345 fn whitespace_only_input_defaults_to_ascii_printable() {
346 let req = parse_password_rules(" \t ").unwrap();
347 assert!(req.lowercase && req.uppercase && req.numbers && req.special);
348 }
349
350 #[test]
351 fn ascii_printable_enables_all_four_standard_classes() {
352 let req = parse_password_rules("allowed: ascii-printable").unwrap();
353 assert!(req.lowercase && req.uppercase && req.numbers && req.special);
354 }
355
356 #[test]
357 fn unicode_keyword_enables_all_four_standard_classes() {
358 let req = parse_password_rules("allowed: unicode").unwrap();
359 assert!(req.lowercase && req.uppercase && req.numbers && req.special);
360 }
361
362 #[test]
363 fn custom_class_dash_is_literal_when_first() {
364 let req = parse_password_rules("required: [-abc]").unwrap();
365 let chars = req.custom_required_chars.unwrap();
366 let set: BTreeSet<char> = chars.chars().collect();
367 assert_eq!(set, BTreeSet::from(['-', 'a', 'b', 'c']));
368 }
369
370 #[test]
371 fn custom_class_bracket_is_literal_when_last() {
372 let req = parse_password_rules("required: [abc]]").unwrap();
373 let chars = req.custom_required_chars.unwrap();
374 let set: BTreeSet<char> = chars.chars().collect();
375 assert_eq!(set, BTreeSet::from(['a', 'b', 'c', ']']));
376 }
377
378 #[test]
379 fn custom_class_dash_in_middle_is_error() {
380 let err = parse_password_rules("required: [a-b]").unwrap_err();
381 assert!(matches!(err, PasswordRulesError::Parse(_)));
382 }
383
384 #[test]
385 fn custom_class_open_bracket_is_treated_as_literal() {
386 let req = parse_password_rules("required: [abc[]").unwrap();
390 let chars = req.custom_required_chars.unwrap();
391 let set: BTreeSet<char> = chars.chars().collect();
392 assert_eq!(set, BTreeSet::from(['[', 'a', 'b', 'c']));
393 }
394
395 #[test]
396 fn custom_class_drops_non_ascii_printable() {
397 let req = parse_password_rules("required: [aéb c]").unwrap();
399 let chars = req.custom_required_chars.unwrap();
400 let set: BTreeSet<char> = chars.chars().collect();
401 assert_eq!(set, BTreeSet::from(['a', 'b', 'c']));
402 }
403
404 #[test]
405 fn whitespace_tolerance() {
406 let a = parse_password_rules(" minlength : 8 ; required: upper ").unwrap();
407 let b = parse_password_rules("minlength:8;required:upper").unwrap();
408 assert_eq!(a.length, b.length);
409 assert_eq!(a.uppercase, b.uppercase);
410 assert_eq!(a.min_uppercase, b.min_uppercase);
411 }
412
413 #[test]
414 fn trailing_semicolon_accepted() {
415 let req = parse_password_rules("required: upper;").unwrap();
416 assert!(req.uppercase);
417 }
418
419 #[test]
420 fn unknown_property_errors() {
421 let err = parse_password_rules("zzz: 1").unwrap_err();
422 assert!(matches!(err, PasswordRulesError::Parse(_)));
423 }
424
425 #[test]
426 fn malformed_rule_missing_colon() {
427 let err = parse_password_rules("minlength 8").unwrap_err();
428 assert!(matches!(err, PasswordRulesError::Parse(_)));
429 }
430
431 #[test]
432 fn invalid_numeric_value_errors() {
433 let err = parse_password_rules("minlength: abc").unwrap_err();
434 assert!(matches!(err, PasswordRulesError::Parse(_)));
435 }
436
437 #[test]
438 fn property_name_matching_is_case_insensitive() {
439 let mixed = parse_password_rules("MinLength: 8").unwrap();
440 let lower = parse_password_rules("minlength: 8").unwrap();
441 assert_eq!(mixed.length, lower.length);
442 }
443
444 #[test]
445 fn required_property_name_matching_is_case_insensitive() {
446 let mixed = parse_password_rules("REQUIRED: UPPER").unwrap();
447 let lower = parse_password_rules("required: upper").unwrap();
448 assert_eq!(mixed.uppercase, lower.uppercase);
449 assert_eq!(mixed.min_uppercase, lower.min_uppercase);
450 }
451
452 #[test]
453 fn class_keyword_matching_is_case_insensitive() {
454 let mixed = parse_password_rules("allowed: Ascii-Printable").unwrap();
455 let lower = parse_password_rules("allowed: ascii-printable").unwrap();
456 assert_eq!(mixed.lowercase, lower.lowercase);
457 assert_eq!(mixed.uppercase, lower.uppercase);
458 assert_eq!(mixed.numbers, lower.numbers);
459 assert_eq!(mixed.special, lower.special);
460 }
461
462 #[test]
463 fn custom_class_contents_are_not_lowercased() {
464 let req = parse_password_rules("required: [ABC]").unwrap();
465 let chars = req.custom_required_chars.unwrap();
466 let set: BTreeSet<char> = chars.chars().collect();
467 assert_eq!(set, BTreeSet::from(['A', 'B', 'C']));
468 }
469
470 #[test]
471 fn maxlength_below_minimum_clamps_up_to_floor() {
472 let req = parse_password_rules("maxlength: 4").unwrap();
473 assert_eq!(req.length, MINIMUM_PASSWORD_LENGTH);
474 }
475
476 #[test]
477 fn maxlength_well_below_minimum_still_clamps_up_to_floor() {
478 let req = parse_password_rules("maxlength: 3").unwrap();
479 assert_eq!(req.length, MINIMUM_PASSWORD_LENGTH);
480 }
481
482 #[test]
483 fn error_payloads_are_truncated() {
484 let long = "a".repeat(MAX_ECHOED_VALUE_LEN + 50);
485 let input = format!("{long}: 1");
486 let err = parse_password_rules(&input).unwrap_err();
487 match err {
488 PasswordRulesError::Parse(s) => {
489 assert!(s.chars().count() <= MAX_ECHOED_VALUE_LEN + 1);
490 assert!(s.ends_with('…'));
491 }
492 other => panic!("expected Parse, got {other:?}"),
493 }
494 }
495
496 #[test]
497 fn generator_honors_custom_required_chars() {
498 let req = parse_password_rules("required: [!@#]; minlength: 16").unwrap();
499 assert_eq!(req.length, 16);
500 let custom: BTreeSet<char> = req
501 .custom_required_chars
502 .as_deref()
503 .unwrap()
504 .chars()
505 .collect();
506 assert_eq!(custom, BTreeSet::from(['!', '@', '#']));
507
508 let rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
509 let out = password_with_rng_for_test(rng, req).expect("password generation succeeds");
510 let any_custom = out.chars().any(|c| c == '!' || c == '@' || c == '#');
511 assert!(any_custom, "expected at least one of !@# in {out}");
512 }
513}