Skip to main content

bitwarden_generators/
passwordrules.rs

1//! Translation layer that adapts [`password_rules_parser`] output to the SDK's
2//! [`PasswordGeneratorRequest`] shape.
3//!
4//! Reference: Apple's [password-manager-resources spec][apple-spec] (also a WHATWG proposal).
5//! The parsing itself is delegated to the third-party `password-rules-parser` crate
6//! (maintained by 1Password). This module handles SDK-specific concerns:
7//!
8//!   - clamping length into [`MINIMUM_PASSWORD_LENGTH`, `MAXIMUM_PASSWORD_LENGTH`];
9//!   - applying the spec's defaults for `allowed` when `required` is present;
10//!   - flattening the parser's `Vec<Vec<CharacterClass>>` required model into the SDK's flat
11//!     AND-of-classes model with `min_*` counts;
12//!   - shaping errors for WASM/UniFFI via `bitwarden_error(flat)`.
13//!
14//! [apple-spec]: https://github.com/apple/password-manager-resources
15
16use 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/// Errors that may occur while parsing an HTML `passwordrules` attribute.
27#[allow(missing_docs)]
28#[bitwarden_error(flat)]
29#[derive(Debug, Error, PartialEq, Eq)]
30pub enum PasswordRulesError {
31    /// The input was syntactically invalid (unknown property, malformed rule, bad
32    /// custom class, etc.). The wrapped string is a human-readable description of the
33    /// failure from the underlying parser.
34    #[error("Failed to parse password rules: {0}")]
35    Parse(String),
36    /// `minlength` exceeds `maxlength`, or `max_consecutive` does not fit in a `u8`.
37    #[error("Invalid length constraint")]
38    InvalidLength,
39}
40
41/// Default password length used when no `minlength`/`maxlength` constrains the choice.
42const DEFAULT_LENGTH: u32 = 16;
43
44/// Maximum length (in characters) of any user-supplied substring echoed back in errors.
45/// Keeps error payloads bounded as they cross the WASM/UniFFI boundary.
46const MAX_ECHOED_VALUE_LEN: usize = 64;
47
48/// Trims `s` and, if it exceeds [`MAX_ECHOED_VALUE_LEN`] characters, truncates to that many
49/// characters followed by an ellipsis. Operates on `char` boundaries, so it is UTF-8 safe.
50fn 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/// The standard character classes from a single `required` or `allowed` rule, flattened
61/// into the SDK's "this class is enabled" boolean model. The parser exposes `required`
62/// as `Vec<Vec<CharacterClass>>` (AND of ORs), but the SDK's [`PasswordGeneratorRequest`]
63/// only models a flat AND of classes — so any nested OR groups are flattened by taking
64/// the union of their classes.
65#[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            // `ascii-printable` and `unicode` keywords are treated as enabling all four
86            // standard classes. The SDK doesn't generate beyond ASCII-printable, so the
87            // two are equivalent for our purposes.
88            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                // Restrict to ASCII-graphic characters so the generated pool stays inside
96                // the SDK's expected character range.
97                self.custom
98                    .extend(chars.iter().copied().filter(|c| c.is_ascii_graphic()));
99            }
100        }
101    }
102}
103
104/// Parses an HTML `passwordrules` attribute string into a [`PasswordGeneratorRequest`].
105///
106/// Empty or whitespace-only input is accepted and resolves to the spec default
107/// (`allowed: ascii-printable`).
108pub fn parse_password_rules(rules: &str) -> Result<PasswordGeneratorRequest, PasswordRulesError> {
109    // Short-circuit empty/whitespace input rather than relying on the external parser's
110    // behavior for it; matches the spec default and keeps the empty-input path simple.
111    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
155/// Build the final [`PasswordGeneratorRequest`] from the accumulated rule state.
156fn 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    // Spec defaults:
165    //  - If `required` is given but `allowed` is not, `allowed` defaults to the required set.
166    //  - If neither is given, `allowed` defaults to `ascii-printable` (all four standard classes).
167    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
224/// Resolves the final password length from the (un-clamped) `min_length`/`max_length`
225/// parsed from the input, applying the SDK's `[MINIMUM_PASSWORD_LENGTH, MAXIMUM_PASSWORD_LENGTH]`
226/// clamp and validating that `min_length <= max_length`.
227fn 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
244/// Map the external parser's error into the SDK's `PasswordRulesError::Parse(String)`,
245/// truncating the message so the payload stays bounded across FFI.
246fn 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        // Default classes when no `required`/`allowed` is given.
262        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        // The external `password-rules-parser` crate accepts `[` as a literal char inside
387        // a custom class, so `[abc[]` parses to the set `{'[', 'a', 'b', 'c'}` rather than
388        // erroring. Documented behavior change vs. the previous in-tree parser.
389        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        // 'é' and space are not ascii_graphic so the translation layer drops them.
398        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}