password_rules_parser/
lib.rs

1//! Rust parser for the HTML [`passwordrules` attribute][whatwg_proposal], a proposal for an
2//! HTML attribute that allows services to specify their password requirements in a machine-readable format.
3//!
4//! This spec is primarily being backed by Apple, and their tools and docs can be found
5//! [here][apple_docs].
6//!
7//! # Password Rules
8//!
9//! A password rule consists of the following:
10//!
11//! * `max-consecutive` - The maximum number of consecutive identical characters allowed in the
12//!   password
13//! * `minlength` - The minimum length of the password
14//! * `maxlength` - The maximum length of the password
15//! * `allowed` - A set of character classes whose characters the password is allowed to be
16//!   generated with
17//!     * Note that `allowed: digit, upper;` is equivalent to `allowed: digit; allowed: upper;`
18//! * `required` - A set of character classes where at least one character from each `required` set
19//!   must appear in the password
20//!     * Note that `required: digit, upper;` is **not** equivalent to
21//!       `required: digit; required: upper;`. The first (`required: digit, upper;`) means that the
22//!       password must contain a `digit` or an `upper`(case) character, while the second
23//!       (`required: digit; required: upper;`) means the password must contain a `digit` **AND**
24//!       an `upper`(case) character.
25//!
26//! Rules are separated by a semicolon (`;`), while character classes are separated by a comma (`,`).
27//!
28//! An example of a password rule:
29//!
30//! `max-consecutive: 2; minlength: 10; maxlength: 15; allowed: upper; required: digit, special;`
31//!
32//! # Character Classes
33//!
34//! There are several different types of character classes:
35//!
36//! * `Upper` - All ASCII uppercase characters (`ABCDEFGHIJKLMNOPQRSTUVWXZY`)
37//! * `Lower` - All ASCII lowercase characters (`abcdefghijklmnopqrstuvwxzy`)
38//! * `Digit` - All ASCII digits (`0123456789`)
39//! * `Special` - ASCII special characters (`-~!@#$%^&*_+=``|(){}[:;"'<>,.?]`)
40//! * `AsciiPrintable` - All ASCII printable characters
41//! * `Unicode` - All unicode characters
42//!     * **Note:** In this implementation this class is equivalent to `AsciiPrintable`
43//! * `Custom` - Contains a set of custom ASCII printable characters in the format `[-abc]]`
44//!   where -, a, b, c, and ] are the characters.
45//!     * **Note:** `-` and `]` are special characters in a character class where `-` must be the
46//!       first character in the set and `]` must be the last character.
47//!
48//! # Example
49//!
50//! This example can be run via `cargo run --example parse`.
51//!
52//! ```
53//! use password_rules_parser::{parse_password_rules, CharacterClass};
54//!
55//! let password_rules = "minlength: 8; maxlength: 32; required: lower, upper; required: digit; allowed: [-_./\\@$*&!#];";
56//! let parsed_rules =
57//!     parse_password_rules(password_rules, true).expect("failed to parse password rules");
58//!
59//! assert_eq!(parsed_rules.min_length.unwrap(), 8);
60//! assert_eq!(parsed_rules.max_length.unwrap(), 32);
61//! // This password rule does not place a restriction on consecutive characters
62//! assert!(parsed_rules.max_consecutive.is_none());
63//! assert_eq!(
64//!     parsed_rules.allowed,
65//!     vec![CharacterClass::Custom(vec![
66//!         '!', '#', '$', '&', '*', '-', '.', '/', '@', '\\', '_',
67//!     ])]
68//! );
69//! assert_eq!(
70//!     parsed_rules.required,
71//!     vec![
72//!         vec![CharacterClass::Upper, CharacterClass::Lower],
73//!         vec![CharacterClass::Digit]
74//!     ]
75//! );
76//!
77//! // The above information can be used to make informed decisions about what password
78//! // to generate for use with a specific service
79//! ```
80//!
81//! You can try parsing arbitrary rules with this tool via `cargo run --example cli`.
82//!
83//! [apple_docs]: https://developer.apple.com/password-rules/
84//! [whatwg_proposal]: https://github.com/whatwg/html/issues/3518
85
86#![forbid(unsafe_code)]
87
88pub mod error;
89
90use crate::error::{PasswordRulesError, PasswordRulesErrorContext};
91use nom::error::FromExternalError;
92use nom::{
93    self,
94    branch::alt,
95    bytes::complete::{is_not, tag_no_case as nom_tag},
96    character::complete::{char, digit1, multispace0},
97    combinator::{complete, cut, map, map_res, opt, peek, recognize, value},
98    error::ParseError,
99    sequence::{delimited, tuple},
100    IResult,
101};
102use once_cell::sync::Lazy;
103use std::collections::{BTreeMap, BTreeSet};
104use std::{cmp::max, cmp::min, ops::RangeInclusive};
105
106// FIXME: There's a significant amount of similarity among these different
107// variants; find a way to deduplicate.
108const ASCII_RANGE: RangeInclusive<char> = ' '..='~';
109const UPPER_RANGE: RangeInclusive<char> = 'A'..='Z';
110const LOWER_RANGE: RangeInclusive<char> = 'a'..='z';
111const DIGIT_RANGE: RangeInclusive<char> = '0'..='9';
112const SPECIAL_CHARS: &str = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
113
114/// Character classes that the password can be allowed or required to use
115#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
116pub enum CharacterClass {
117    /// A-Z
118    Upper,
119    /// a-z
120    Lower,
121    /// 0-9
122    Digit,
123    /// -~!@#$%^&*_+=`|(){}[:;"'<>,.? ] and space
124    Special,
125    /// All ASCII printable characters
126    AsciiPrintable,
127    /// All unicode characters
128    Unicode,
129    /// A custom list between \[\] of ascii characters that can be used in the password
130    /// For example: \[abc\] consists of the characters a, b, and c
131    Custom(Vec<char>),
132}
133
134impl CharacterClass {
135    /// The characters a character class consists of
136    pub fn chars(&self) -> Vec<char> {
137        use CharacterClass::*;
138
139        match self {
140            Upper => UPPER_RANGE.collect(),
141            Lower => LOWER_RANGE.collect(),
142            Digit => DIGIT_RANGE.collect(),
143            Special => SPECIAL_CHARS.chars().collect(),
144            // TODO(brandon): What range should we use for unicode? It's not a great idea to generate
145            // a password containing unicode characters if the website doesn't normalize
146            // as mentioned by Goldberg in https://github.com/whatwg/html/issues/3518#issuecomment-644581962
147            AsciiPrintable | Unicode => ASCII_RANGE.collect(),
148            Custom(custom) => custom.clone(),
149        }
150    }
151}
152
153/// The various parsed password rules
154#[derive(Debug, Clone, Default, PartialEq, Eq)]
155pub struct PasswordRules {
156    /// The maximum length of consecutive characters in your password
157    pub max_consecutive: Option<u32>,
158    /// The minimum length of the password
159    pub min_length: Option<u32>,
160    /// The maximum length of the password
161    pub max_length: Option<u32>,
162    /// A subset of allowed characters based on a set of `CharacterClass`
163    pub allowed: Vec<CharacterClass>,
164    /// Restrictions that all passwords must follow based on a set of `CharacterClass`
165    pub required: Vec<Vec<CharacterClass>>,
166}
167
168/// A PasswordRule is a single row that can appear in a passwordrules document.
169/// a list of these is flattened and canonicalized into a final PasswordRules.
170#[derive(Debug, Clone)]
171enum PasswordRule {
172    Allow(Vec<CharacterClass>),
173    Require(Vec<CharacterClass>),
174    MinLength(Option<u32>),
175    MaxLength(Option<u32>),
176    MaxConsecutive(Option<u32>),
177}
178
179impl PasswordRules {
180    /// Returns true if the rules of `self` satisfy all the rules of `other`.
181    pub fn is_subset(&self, other: &PasswordRules) -> bool {
182        if let Some(max_consecutive) = other.max_consecutive {
183            if self.max_consecutive.map(|x| x <= max_consecutive) != Some(true) {
184                return false;
185            }
186        }
187
188        if let Some(min_length) = other.min_length {
189            if self.min_length.map(|x| x >= min_length) != Some(true) {
190                return false;
191            }
192        }
193
194        if let Some(max_length) = other.max_length {
195            if self.max_length.map(|x| x <= max_length) != Some(true) {
196                return false;
197            }
198        }
199
200        if !satisfies_allowed(self, other) {
201            return false;
202        }
203
204        satisfies_required(self.required.clone(), other.required.clone())
205    }
206}
207
208fn satisfies_allowed(a: &PasswordRules, b: &PasswordRules) -> bool {
209    let b_allowed = b
210        .required
211        .iter()
212        .flatten()
213        .chain(b.allowed.iter())
214        .flat_map(|class| class.chars().into_iter())
215        .collect::<BTreeSet<char>>();
216
217    a.required
218        .iter()
219        .flatten()
220        .chain(a.allowed.iter())
221        .map(CharacterSet::from)
222        .all(|set| set.is_subset(&b_allowed))
223}
224
225fn satisfies_required(mut a: Vec<Vec<CharacterClass>>, mut b: Vec<Vec<CharacterClass>>) -> bool {
226    /// Sort by number of classes in each required instance, and by number of characters in each class, low to high
227    fn presort_by_length(sets: &mut [Vec<CharacterClass>]) {
228        for set in sets.iter_mut() {
229            set.sort_by_key(|class| CharacterSet::from(class).len());
230        }
231
232        sets.sort_by_key(|set| {
233            (
234                set.len(),
235                set.last().map(|class| CharacterSet::from(class).len()),
236            )
237        });
238    }
239
240    presort_by_length(&mut a);
241    presort_by_length(&mut b);
242
243    // Is each `x` in `ra` in `a`, a subset of any `y` in `rb` in `b`?
244    // If so, delete `rb` from `b`.
245    a.iter().for_each(|ra| {
246        if let Some(i) = b.iter().enumerate().find_map(|(i, rb)| {
247            ra.iter()
248                .all(|x_class| {
249                    rb.iter().any(|y_class| {
250                        let x_set = CharacterSet::from(x_class);
251                        let y_set = CharacterSet::from(y_class);
252                        x_set.is_subset(&y_set)
253                    })
254                })
255                .then_some(i)
256        }) {
257            b.remove(i);
258        }
259    });
260
261    b.is_empty()
262}
263
264enum CharacterSet<'a> {
265    Static(&'a BTreeSet<char>),
266    Dynamic(BTreeSet<char>),
267}
268
269impl std::ops::Deref for CharacterSet<'_> {
270    type Target = BTreeSet<char>;
271
272    fn deref(&self) -> &Self::Target {
273        match self {
274            CharacterSet::Static(a) => a,
275            CharacterSet::Dynamic(a) => a,
276        }
277    }
278}
279
280impl<'a> From<&'a CharacterClass> for CharacterSet<'a> {
281    fn from(class: &'a CharacterClass) -> Self {
282        static STATIC_CHARACTER_SETS: Lazy<BTreeMap<CharacterClass, BTreeSet<char>>> =
283            Lazy::new(|| {
284                [
285                    CharacterClass::Upper,
286                    CharacterClass::Lower,
287                    CharacterClass::Digit,
288                    CharacterClass::Special,
289                    CharacterClass::AsciiPrintable,
290                    CharacterClass::Unicode,
291                ]
292                .iter()
293                .map(|c| (c.clone(), c.chars().into_iter().collect::<BTreeSet<_>>()))
294                .collect()
295            });
296
297        match class {
298            CharacterClass::Custom(_) => CharacterSet::Dynamic(class.chars().into_iter().collect()),
299            _ => CharacterSet::Static(STATIC_CHARACTER_SETS.get(class).unwrap()),
300        }
301    }
302}
303
304// HELPER PARSER COMBINATORS
305// These are generic combinators used in a few places in the password rules implementations.
306
307/// Wrap a parser such that it accepts any amount (including 0) whitespace
308/// before and after itself.
309fn space_surround<'a, P, O, E>(parser: P) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
310where
311    P: FnMut(&'a str) -> IResult<&'a str, O, E>,
312    E: ParseError<&'a str>,
313{
314    delimited(multispace0, parser, multispace0)
315}
316
317/// Parse a sequence with a folding function. This creates a parser that runs the inner `parser` in
318/// a loop, using the fold function to create a value. Between each inner parser, it parses and
319/// discards a separator, `sep`. If `allow_trailing_separator` is given, a trailing separator may
320/// be parsed; otherwise, separators *must* be followed by another value.
321///
322/// The sequence *must* end with the `terminator`, which will be scanned but not consumed (as a
323/// lookahead). If it does not, the parser will return an error. The terminator will not be checked
324/// until the loop finishes.
325///
326/// This combinator exists to provide useful errors. Normally, looping parsers like this return
327/// success unconditionally, because an error from the subparser just means that the loop is
328/// finished and parsing can continue. By adding a lookahead for the terminator, we can return that
329/// error if the loop finished without the presence of the expected terminator.
330fn fold_separated_terminated<P, S, R, F, T, I, O, O2, O3, E>(
331    mut parser: P,
332    mut sep: S,
333    terminator: R,
334    allow_trailing_separator: bool,
335    init: T,
336    mut fold: F,
337) -> impl FnMut(I) -> IResult<I, T, E>
338where
339    P: FnMut(I) -> IResult<I, O, E>,
340    S: FnMut(I) -> IResult<I, O2, E>,
341    R: FnMut(I) -> IResult<I, O3, E>,
342    I: Clone,
343    T: Clone,
344    F: FnMut(T, O) -> T,
345    E: ParseError<I>,
346{
347    let mut terminator_lookahead = peek(terminator);
348
349    move |mut input| {
350        let mut accum = init.clone();
351
352        // Parse the first item
353        let (fold_err, tail) = match parser(input.clone()) {
354            Err(nom::Err::Error(err)) => (err, input),
355            Err(err) => return Err(err),
356            Ok((tail, output)) => {
357                accum = fold(accum, output);
358                input = tail;
359
360                // Parse everything after the first item
361                loop {
362                    // Parse and discard a separator.
363                    match sep(input.clone()) {
364                        Err(nom::Err::Error(err)) => break (err, input),
365                        Err(err) => return Err(err),
366                        Ok((tail, _)) => input = tail,
367                    }
368
369                    // Parse a subsequent item. If allow_trailing_separator,
370                    // this must succeed.
371                    match parser(input.clone()) {
372                        Err(err) if !allow_trailing_separator => return Err(err),
373                        Err(nom::Err::Error(err)) => break (err, input),
374                        Err(err) => return Err(err),
375                        Ok((tail, output)) => {
376                            accum = fold(accum, output);
377                            input = tail;
378                        }
379                    }
380                }
381            }
382        };
383
384        // Check that the terminator is present
385        match terminator_lookahead(tail.clone()) {
386            Ok(..) => Ok((tail, accum)),
387            Err(err) => Err(err.map(move |err| fold_err.or(err))),
388        }
389    }
390}
391
392/// Optionally parse something. The thing, if absent, must end with terminator, which will be
393/// scanned but not consumed (as a lookahead). If it does not, the parser will return an error.
394///
395/// This combinator exists to provide useful errors. Normally, parsers like this return success
396/// unconditionally, because an error from the subparser just means that the optional is None, so
397/// parsing can continue. By adding a lookahead for the terminator, we can return that error if the
398/// optional was absent without the presence of the expected terminator.
399fn opt_terminated<P, R, I, O, O2, E>(
400    mut parser: P,
401    terminator: R,
402) -> impl FnMut(I) -> IResult<I, Option<O>, E>
403where
404    P: FnMut(I) -> IResult<I, O, E>,
405    R: FnMut(I) -> IResult<I, O2, E>,
406    E: ParseError<I>,
407    I: Clone,
408{
409    let mut terminator_lookahead = peek(terminator);
410
411    move |input| match parser(input.clone()) {
412        Ok((tail, value)) => Ok((tail, Some(value))),
413        Err(nom::Err::Error(opt_err)) => match terminator_lookahead(input.clone()) {
414            Ok(..) => Ok((input, None)),
415            Err(err) => Err(err.map(move |err| opt_err.or(err))),
416        },
417        Err(err) => Err(err),
418    }
419}
420
421/// Parse only an EOF
422fn eof<'a, E>(input: &'a str) -> IResult<&'a str, (), E>
423where
424    E: ParseError<&'a str>,
425{
426    if input.is_empty() {
427        Ok((input, ()))
428    } else {
429        Err(nom::Err::Error(E::from_error_kind(
430            input,
431            nom::error::ErrorKind::Eof,
432        )))
433    }
434}
435
436/// Wrapper for nom::tag_no_case that supports collecting the specific tag into
437/// the error, in the event of a mismatch
438fn tag_no_case<'a, E>(tag: &'static str) -> impl FnMut(&'a str) -> IResult<&'a str, &'a str, E>
439where
440    E: error::WithTagError<&'a str>,
441{
442    let nom_tag = nom_tag(tag);
443    move |input| match nom_tag(input) {
444        Ok(result) => Ok(result),
445        Err(err) => Err(err.map(|()| E::from_tag(input, tag))),
446    }
447}
448
449// PASSWORD RULES PARSERS
450
451/// Parse a custom character class, which is a series of ascii-printable
452/// characters, enclosed by []. if - is present, it must be at the beginning,
453/// and if ] is present, it must be at the end.
454fn parse_custom_character_class<'a>(
455    input: &'a str,
456) -> IResult<&'a str, Vec<char>, PasswordRulesErrorContext<'a>> {
457    // Parse an optional -
458    let opt_dash = opt(char('-'));
459
460    // Parse any number of characters that aren't -]. is_not requires a match
461    // of at least 1 character, so we make it optional as well.
462    let inner = opt(is_not("-]"));
463
464    // Custom lookahead parser that parses an optional ] only if it's followed by another
465    // ]
466    let opt_bracket = |input: &'a str| {
467        if input.starts_with("]]") {
468            Ok((&input[1..], Some(']')))
469        } else {
470            Ok((input, None))
471        }
472    };
473
474    // Parse the body (the stuff between the [])
475    let body = recognize(tuple((opt_dash, inner, opt_bracket)));
476
477    // Convert the parsed body into a Vec<char>. Per the spec, ignore characters
478    // that aren't ' ' or ascii-printables
479    let body = map(body, |s| {
480        s.chars()
481            .filter(|&c| c.is_ascii_graphic() || c == ' ')
482            .collect()
483    });
484
485    delimited(char('['), body, cut(char(']')))(input)
486}
487
488// Parse a string that indicates a character class
489fn parse_character_class(input: &str) -> IResult<&str, CharacterClass, PasswordRulesErrorContext> {
490    alt((
491        value(CharacterClass::Upper, tag_no_case("upper")),
492        value(CharacterClass::Lower, tag_no_case("lower")),
493        value(CharacterClass::Digit, tag_no_case("digit")),
494        value(CharacterClass::Special, tag_no_case("special")),
495        value(
496            CharacterClass::AsciiPrintable,
497            tag_no_case("ascii-printable"),
498        ),
499        value(CharacterClass::Unicode, tag_no_case("unicode")),
500        map(parse_custom_character_class, CharacterClass::Custom),
501    ))(input)
502}
503
504/// Parse a list of character classes (which are comma-whitespace delimited)
505fn parse_character_classes(
506    input: &str,
507) -> IResult<&str, Vec<CharacterClass>, PasswordRulesErrorContext> {
508    let comma = space_surround(char(','));
509
510    // A list of character classes must be terminated either by a semicolon or
511    // EOF. We need to use value((), char) to unify the return type with EOF.
512    let terminator = space_surround(alt((value((), char(';')), eof)));
513    fold_separated_terminated(
514        parse_character_class,
515        comma,
516        terminator,
517        false,
518        Vec::new(),
519        |mut classes, class| {
520            classes.push(class);
521            classes
522        },
523    )(input)
524}
525
526/// Parse a number, which is 1 or more consecutive digits
527fn parse_number<'a, E>(input: &'a str) -> IResult<&'a str, u32, E>
528where
529    E: ParseError<&'a str> + FromExternalError<&'a str, std::num::ParseIntError>,
530{
531    map_res(digit1, str::parse)(input)
532}
533
534/// A rule looks like "require: upper, lower". It's a string tag, colon, data. This function
535/// creates a parser that matches a specific rule, which has a tag name (like "required") and a
536/// subparser for the rule content. Because trailing semicolons may be omitted, the semicolon is
537/// handled separately when parsing a sequence, not as part of a rule.
538fn parse_generic_rule<'a, P, O>(
539    name: &'static str,
540    parser: P,
541) -> impl FnMut(&'a str) -> IResult<&'a str, O, PasswordRulesErrorContext<'a>>
542where
543    P: Fn(&'a str) -> IResult<&'a str, O, PasswordRulesErrorContext<'a>>,
544{
545    let colon = space_surround(char(':'));
546    let pattern = tuple((tag_no_case(name), cut(colon), cut(parser)));
547    map(pattern, |(_, _, out)| out)
548}
549
550/// Parse an optional number. If the number is absent, lookahead that the next thing in the input
551/// is a semicolon or EoF. This construct is used to ensure "expected number" errors can be
552/// correctly delivered to the caller.
553fn parse_optional_rule_number<'a, E>(input: &'a str) -> IResult<&'a str, Option<u32>, E>
554where
555    E: ParseError<&'a str> + FromExternalError<&'a str, std::num::ParseIntError>,
556{
557    opt_terminated(
558        parse_number,
559        space_surround(alt((value((), char(';')), eof))),
560    )(input)
561}
562
563/// Parse a single PasswordRule, which is one of the semicolon delimited lines in a passwordrules
564/// document. Because the semicolon is optional, it's handled separately when parsing a sequence
565/// of rules, rather than here as part of a single rule.
566fn parse_rule(input: &str) -> IResult<&str, PasswordRule, PasswordRulesErrorContext> {
567    alt((
568        map(
569            parse_generic_rule("required", parse_character_classes),
570            PasswordRule::Require,
571        ),
572        map(
573            parse_generic_rule("allowed", parse_character_classes),
574            PasswordRule::Allow,
575        ),
576        map(
577            parse_generic_rule("max-consecutive", parse_optional_rule_number),
578            PasswordRule::MaxConsecutive,
579        ),
580        map(
581            parse_generic_rule("minlength", parse_optional_rule_number),
582            PasswordRule::MinLength,
583        ),
584        map(
585            parse_generic_rule("maxlength", parse_optional_rule_number),
586            PasswordRule::MaxLength,
587        ),
588    ))(input)
589}
590
591/// If the source option is None, set it to Some(new). Otherwise, call cmp with
592/// the old and new T, and set the option to the return value of cmp. Used to
593/// implement "min of" and "max of" logic with options.
594fn apply<T>(source: &mut Option<T>, new: T, mut cmp: impl FnMut(T, T) -> T) {
595    *source = match source.take() {
596        None => Some(new),
597        Some(old) => Some(cmp(old, new)),
598    }
599}
600
601/// Naïvely parse a complete rules document. This parser only folds all the rules as seen into
602/// a `PasswordRules` struct. It accepts an empty string as an empty `PasswordRules`, and it does
603/// not perform the two post processing steps, which are to add AsciiPrintable to "allowed" if both
604/// allowed and required are empty, and to canonicalize the allowed set.
605fn parse_rule_list(input: &str) -> IResult<&str, PasswordRules, PasswordRulesErrorContext> {
606    fold_separated_terminated(
607        parse_rule,
608        space_surround(char(';')),
609        space_surround(eof),
610        true,
611        PasswordRules::default(),
612        |mut rules, rule| {
613            match rule {
614                PasswordRule::Allow(classes) => rules.allowed.extend(classes),
615                PasswordRule::Require(classes) => {
616                    let classes = canonicalize(classes);
617                    if !classes.is_empty() {
618                        rules.required.push(canonicalize(classes));
619                    }
620                }
621                PasswordRule::MinLength(Some(length)) => apply(&mut rules.min_length, length, max),
622                PasswordRule::MaxLength(Some(length)) => apply(&mut rules.max_length, length, min),
623                PasswordRule::MaxConsecutive(Some(length)) => {
624                    apply(&mut rules.max_consecutive, length, min)
625                }
626                _ => {}
627            };
628            rules
629        },
630    )(input)
631}
632
633/// Parse a password rules string and return its parts
634///
635/// All character requirements will be "canonicalized", which means redundant requirements will be
636/// collapsed. For instance, "allow: ascii-printable, upper" will be parsed the same as
637/// "allow: ascii-printable".
638///
639/// If `supply_default` is given, `AsciiPrintable` is added to the set of allowed characters if
640/// both the allowed and required sets are empty; this behavior is consistent with the
641/// specification requirements.
642pub fn parse_password_rules(
643    s: &str,
644    supply_default: bool,
645) -> Result<PasswordRules, PasswordRulesError> {
646    let s = s.trim();
647
648    if s.is_empty() {
649        return Err(PasswordRulesError::empty());
650    }
651
652    let mut parse_rules = complete(parse_rule_list);
653
654    let mut rules = match parse_rules(s) {
655        Ok((_, rules)) => rules,
656        Err(nom::Err::Incomplete(..)) => unreachable!(),
657        Err(nom::Err::Error(err)) | Err(nom::Err::Failure(err)) => {
658            return Err(err.extract_context(s))
659        }
660    };
661
662    rules.allowed = canonicalize(rules.allowed);
663
664    // If there are no character classes default to AsciiPrintable
665    if supply_default && rules.allowed.is_empty() && rules.required.is_empty() {
666        rules.allowed.push(CharacterClass::AsciiPrintable);
667    }
668
669    Ok(rules)
670}
671
672// TODO: replace with bitvec when const generics are stable
673struct AsciiTable {
674    table: [bool; 128],
675}
676
677#[derive(Debug, Clone)]
678enum CheckResult<I> {
679    Match,
680    Mismatch(I),
681}
682
683impl AsciiTable {
684    fn new() -> Self {
685        Self {
686            table: [false; 128],
687        }
688    }
689
690    /// Add a character to the table. Panics if the character isn't a 7-bit ascii character.
691    fn set(&mut self, b: char) {
692        self.table[b as usize] = true;
693    }
694
695    /// Add a list of characters to the table. Panics if any of them aren't a 7-bit ascii character.
696    fn set_range(&mut self, range: impl IntoIterator<Item = char>) {
697        range.into_iter().for_each(|c| self.set(c))
698    }
699
700    /// Check if a character is present in the table.
701    fn check(&self, b: char) -> bool {
702        self.table.get(b as usize).copied().unwrap_or(false)
703    }
704
705    /// Check if *all* of the given characters are present in the table
706    fn check_range(&self, range: impl IntoIterator<Item = char>) -> bool {
707        range.into_iter().all(move |b| self.check(b))
708    }
709
710    // Check if a range is completely represented in the table. If it isn't,
711    // return an iterator over the parts of the range that *are* in the table.
712    fn check_or_extract<'s>(
713        &'s self,
714        range: impl IntoIterator<Item = char> + Clone + 's,
715    ) -> CheckResult<impl Iterator<Item = char> + 's> {
716        if self.check_range(range.clone()) {
717            CheckResult::Match
718        } else {
719            CheckResult::Mismatch(range.into_iter().filter(move |&b| self.check(b)))
720        }
721    }
722}
723
724/// Converts a list of character classes into a canonicalized list of character classes
725fn canonicalize(mut classes: Vec<CharacterClass>) -> Vec<CharacterClass> {
726    // Table that stores all of the ASCII characters that we see
727    let mut table = AsciiTable::new();
728
729    // Unicode includes AsciiPrintable, and AsciiPrintable includes all other character classes
730    // so we will check for these special cases and bail out early with the largest set found
731    if classes.contains(&CharacterClass::Unicode) {
732        return vec![CharacterClass::Unicode];
733    }
734
735    if classes.contains(&CharacterClass::AsciiPrintable) {
736        return vec![CharacterClass::AsciiPrintable];
737    }
738
739    // Mark off all of the characters from each character class
740    for class in classes.drain(..) {
741        match class {
742            CharacterClass::Upper => table.set_range(UPPER_RANGE),
743            CharacterClass::Lower => table.set_range(LOWER_RANGE),
744            CharacterClass::Digit => table.set_range(DIGIT_RANGE),
745            CharacterClass::Special => table.set_range(SPECIAL_CHARS.chars()),
746            CharacterClass::Custom(chars) => table.set_range(chars.into_iter()),
747            _ => unreachable!(), // Unicode and AsciiPrintable are handled before the loop.
748        }
749    }
750
751    // Check the character table to determine what character classes should be returned
752    let mut custom_characters = vec![];
753
754    if let CheckResult::Match = table.check_or_extract(ASCII_RANGE) {
755        return vec![CharacterClass::AsciiPrintable];
756    }
757
758    match table.check_or_extract(UPPER_RANGE) {
759        CheckResult::Match => classes.push(CharacterClass::Upper),
760        CheckResult::Mismatch(chars) => custom_characters.extend(chars),
761    }
762
763    match table.check_or_extract(LOWER_RANGE) {
764        CheckResult::Match => classes.push(CharacterClass::Lower),
765        CheckResult::Mismatch(chars) => custom_characters.extend(chars),
766    }
767
768    match table.check_or_extract(DIGIT_RANGE) {
769        CheckResult::Match => classes.push(CharacterClass::Digit),
770        CheckResult::Mismatch(chars) => custom_characters.extend(chars),
771    }
772
773    match table.check_or_extract(SPECIAL_CHARS.chars()) {
774        CheckResult::Match => classes.push(CharacterClass::Special),
775        CheckResult::Mismatch(chars) => custom_characters.extend(chars),
776    }
777
778    if !custom_characters.is_empty() {
779        classes.push(CharacterClass::Custom(custom_characters));
780    }
781
782    classes
783}
784
785#[cfg(test)]
786mod test {
787    use super::*;
788
789    mod satisfies_required {
790        use super::*;
791
792        #[test]
793        fn test_satisfies_required() {
794            assert!(satisfies_required(
795                vec![vec![CharacterClass::Digit]],
796                vec![vec![
797                    CharacterClass::Digit,
798                    CharacterClass::Upper,
799                    CharacterClass::Lower,
800                ]],
801            ),);
802
803            assert!(!satisfies_required(
804                vec![vec![
805                    CharacterClass::Digit,
806                    CharacterClass::Upper,
807                    CharacterClass::Lower,
808                ]],
809                vec![vec![CharacterClass::Digit]],
810            ),);
811
812            assert!(satisfies_required(
813                vec![vec![CharacterClass::Digit], vec![CharacterClass::Digit],],
814                vec![
815                    vec![CharacterClass::Digit],
816                    vec![CharacterClass::Digit, CharacterClass::Upper],
817                ],
818            ),);
819
820            assert!(!satisfies_required(
821                vec![
822                    vec![CharacterClass::Custom(vec!['[', '#', '!', '*'])],
823                    vec![CharacterClass::Digit],
824                ],
825                vec![
826                    vec![CharacterClass::Custom(vec!['#', '!'])],
827                    vec![CharacterClass::Digit, CharacterClass::Upper],
828                ],
829            ),);
830
831            assert!(satisfies_required(
832                vec![
833                    vec![CharacterClass::Digit, CharacterClass::Upper],
834                    vec![CharacterClass::Digit],
835                ],
836                vec![vec![CharacterClass::Digit],],
837            ),);
838
839            assert!(!satisfies_required(
840                vec![vec![CharacterClass::Digit],],
841                vec![
842                    vec![CharacterClass::Digit, CharacterClass::Upper],
843                    vec![CharacterClass::Digit],
844                ],
845            ),);
846
847            assert!(satisfies_required(
848                vec![
849                    vec![CharacterClass::Custom(vec!['[', '#', '!', '*', '^', '%'])],
850                    vec![CharacterClass::Custom(vec!['[', '#', '!', '*'])],
851                ],
852                vec![vec![CharacterClass::Custom(vec!['[', '#', '!', '*'])],],
853            ),);
854
855            assert!(satisfies_required(
856                vec![vec![CharacterClass::Upper], vec![CharacterClass::Digit],],
857                vec![
858                    vec![
859                        CharacterClass::Digit,
860                        CharacterClass::Lower,
861                        CharacterClass::Upper
862                    ],
863                    vec![CharacterClass::Digit, CharacterClass::Lower],
864                ],
865            ),);
866        }
867    }
868
869    mod canonicalize {
870        use super::*;
871
872        fn test_canonicalizer(input: &str, expected: Vec<CharacterClass>) {
873            let input = input.chars().collect();
874            let classes = vec![CharacterClass::Custom(input)];
875            let res = canonicalize(classes);
876
877            assert_eq!(res, expected)
878        }
879
880        #[test]
881        fn few_characters() {
882            test_canonicalizer("abc", vec![CharacterClass::Custom(vec!['a', 'b', 'c'])])
883        }
884
885        #[test]
886        fn all_lower() {
887            test_canonicalizer("abcdefghijklmnopqrstuvwxyz", vec![CharacterClass::Lower])
888        }
889
890        #[test]
891        fn all_alpha() {
892            test_canonicalizer(
893                "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
894                vec![CharacterClass::Upper, CharacterClass::Lower],
895            )
896        }
897
898        #[test]
899        fn all_special() {
900            test_canonicalizer(
901                r##"- !"#$%&'()*+,./:;<=>?@[\^_`{|}~]"##,
902                vec![CharacterClass::Special],
903            )
904        }
905
906        #[test]
907        fn digits_and_some_lowers() {
908            test_canonicalizer(
909                "67abc1def0ghijk2ln8op9qr4stuv5wxy3z",
910                vec![
911                    CharacterClass::Digit,
912                    CharacterClass::Custom("abcdefghijklnopqrstuvwxyz".chars().collect()),
913                ],
914            )
915        }
916
917        #[test]
918        fn alphanumeric_and_some_specials() {
919            test_canonicalizer(
920                "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ()*+.",
921                vec![
922                    CharacterClass::Upper,
923                    CharacterClass::Lower,
924                    CharacterClass::Digit,
925                    CharacterClass::Custom(vec!['(', ')', '*', '+', '.']),
926                ],
927            )
928        }
929
930        #[test]
931        fn everything() {
932            test_canonicalizer(
933                r##"-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!"#$%&'()*+,./:;<=>?@[\^_`{|}~ ]"##,
934                vec![CharacterClass::AsciiPrintable],
935            )
936        }
937    }
938
939    mod parser {
940        use super::*;
941
942        /// This macro removes the boilerplate involved in creating password
943        /// rules parsing tests. It provides a maximally concise syntax for
944        /// creating test cases, each of which expands to a #[test] function
945        /// that calls test_rules_parser. Example:
946        ///
947        /// tests! {
948        ///     empty_string: "" => None;
949        ///
950        ///     basic_minlength: "minlength: 10" => password_rules!(
951        ///         minlength: 10;
952        ///         allowed: ascii;
953        ///     );
954        ///
955        ///     complex_test
956        ///     "This tests something complex described in this comment":
957        ///     "" => None;
958        /// }
959        ///
960        /// The last case demonstrates an optional string comment; if this is
961        /// present and the test fails, the comment will be included in the
962        /// assertion failure message.
963        macro_rules! tests {
964            ($($test:ident $($doc:literal)? : $input:literal => $expected:expr;)*) => {$(
965                $(#[doc = $doc])?
966                #[test]
967                fn $test() {
968                    assert_eq!(
969                        parse_password_rules($input, true).ok(),
970                        $expected,
971
972                        "{doc}\ninput: {input:?}",
973                        doc = None $(.or(Some($doc)))? .unwrap_or(""),
974                        input = $input
975                    );
976                }
977            )*};
978        }
979
980        /// Helper macro to construct a password rules with minimal boilerplate.
981        /// Example:
982        ///
983        /// password_rules! (
984        ///     max-consecutive: 10;
985        ///     maxlength: 30;
986        ///     minlength: 10;
987        ///     allowed: digit, special, ['a' 'b' 'c'];
988        ///     required: upper;
989        ///     required: lower;
990        /// )
991        ///
992        /// This macro returns a PasswordRules struct, wrapped in Some (for
993        /// easy use in tests). All of the shown fields are optional, but they
994        /// must be given in that precise order.
995        macro_rules! password_rules {
996            (
997                $(max-consecutive: $consecutive:expr;)?
998                $(maxlength: $maxlength:expr;)?
999                $(minlength: $minlength:expr;)?
1000                $(allowed: $($allowed_class:tt),* $(,)? ;)?
1001                $(required: $($required_class:tt),* $(,)? ;)*
1002            ) => {
1003                Some(PasswordRules{
1004                    max_consecutive: None $(.or(Some($consecutive)))?,
1005                    max_length: None $(.or(Some($maxlength)))?,
1006                    min_length: None $(.or(Some($minlength)))?,
1007                    allowed: vec![$($(
1008                        character_class!($allowed_class),
1009                    )*)?],
1010                    required: vec![$(
1011                        vec![$(
1012                            character_class!($required_class),
1013                        )*],
1014                    )*],
1015                })
1016            }
1017        }
1018
1019        /// Helper macro to construct a CharacterClass enum. Used in `password_rules!`.
1020        macro_rules! character_class {
1021            (upper) => {CharacterClass::Upper};
1022            (lower) => {CharacterClass::Lower};
1023            (digit) => {CharacterClass::Digit};
1024            (special) => {CharacterClass::Special};
1025            (ascii) => {CharacterClass::AsciiPrintable};
1026            (unicode) => {CharacterClass::Unicode};
1027            ([$($c:expr)*]) => {CharacterClass::Custom(vec![$($c,)*])};
1028        }
1029
1030        tests! {
1031            empty_string: "" => None;
1032
1033            missing_property: "allowed:;" => password_rules!(allowed: ascii;);
1034
1035            multiple_classes: "allowed: digit, special;" => password_rules!(
1036                allowed: digit, special;
1037            );
1038
1039            missing_integers: "max-consecutive:;minlength:;maxlength:;" => password_rules!(
1040                allowed: ascii;
1041            );
1042
1043            empty_custom_class: "allowed:[];" => password_rules!(allowed: ascii;);
1044
1045            multiple_length_constraints:
1046                "maxlength:50;\
1047                max-consecutive:40;\
1048                minlength:10;\
1049                max-consecutive:30;\
1050                minlength:12;\
1051                maxlength:20;"
1052                =>
1053                password_rules!(
1054                    max-consecutive: 30;
1055                    maxlength: 20;
1056                    minlength: 12;
1057                    allowed: ascii;
1058                );
1059
1060            custom_class_bracket_hyphen: "allowed: [-]]; required: [[]]; allowed:[-];" => password_rules!(
1061                allowed: ['-' ']'];
1062                required: ['[' ']'];
1063            );
1064
1065            invalid_hyphen: "allowed: [a-];" => None;
1066
1067            invalid_bracket: "allowed: []a];" => None;
1068
1069            complex_input:
1070                "allowed:special;\
1071                max-consecutive:3;\
1072                required: upper, digit, ['*/];\
1073                allowed: [abc], digit,special;\
1074                minlength:20;"
1075                =>
1076                password_rules!(
1077                    max-consecutive: 3;
1078                    minlength: 20;
1079                    allowed: digit, special, ['a' 'b' 'c'];
1080                    required: upper, digit, ['\'' '*' '/'];
1081                );
1082
1083            skip_unicode_characters: "allowed: [供应A商B责任C进展];" => password_rules!(
1084                allowed: ['A' 'B' 'C'];
1085            );
1086
1087            unicode_overpowers_everything:
1088                "allowed: \
1089                [abcdefghijklmnopqrstuvwxyz], \
1090                upper, digit, ascii-printable, \
1091                special, unicode;"
1092                =>
1093                password_rules!(allowed: unicode;);
1094
1095            ascii_overpowers_everything_else:
1096                "allowed: lower; \
1097                allowed: [ABCDEFGHIJKLMNOPQRSTUVWXYZ]; \
1098                allowed: special; \
1099                allowed: [0123456789]; \
1100                allowed: ascii-printable;"
1101                =>
1102                password_rules!(allowed: ascii;);
1103
1104
1105            allow_missing_trailing_semicolon: "allowed: lower; required: upper" => password_rules!(
1106                allowed: lower;
1107                required: upper;
1108            );
1109
1110            multiple_required_sets:
1111                "required: upper, lower; required: digit; allowed: digit; allowed: upper;"
1112                =>
1113                password_rules!(
1114                    allowed: upper, digit;
1115                    required: upper, lower;
1116                    required: digit;
1117                );
1118        }
1119
1120        /// Test cases taken from https://github.com/apple/password-manager-resources/issues/98#issuecomment-640105245,
1121        /// adopted for our implementation. They diverge in a few ways:
1122        /// - the original tests accept some custom character class inputs that are prohibited by the
1123        ///   spec, such as [a-].
1124        /// - the original tests return PasswordRules::default() in the event of a parse error;
1125        ///   we explicitly reject those cases with an error.
1126        mod apple_suite {
1127            use super::*;
1128
1129            tests! {
1130                empty_string: "" => None;
1131
1132                req_upper1: "    required: upper" => password_rules!(required: upper;);
1133                req_upper2: "    required: upper;" => password_rules!(required: upper;);
1134                req_upper3: "    required: upper             " => password_rules!(required: upper;);
1135                req_upper4: "required:upper" => password_rules!(required: upper;);
1136                req_upper6: "required:     upper" => password_rules!(required: upper;);
1137
1138                req_upper_case "Test that character class names are case insensitive":
1139                "required: uPPeR" => password_rules!(required: upper;);
1140
1141                all_upper1: "allowed:upper" => password_rules!(allowed: upper;);
1142                all_upper2: "allowed:     upper" => password_rules!(allowed: upper;);
1143
1144                required_canonical "Test that a custom character set that overlaps a class is omitted":
1145                "required: upper, [AZ];" => password_rules!(required: upper;);
1146
1147                allowed_reduction "Test that multiple allowed rules are collapsed together":
1148                "required: upper; allowed: upper; allowed: lower" => password_rules!(
1149                    allowed: upper, lower;
1150                    required: upper;
1151                );
1152
1153                max_consecutive1: "max-consecutive:      5" => password_rules!(
1154                    max-consecutive: 5;
1155                    allowed: ascii;
1156                );
1157                max_consecutive2: "max-consecutive:5" => password_rules!(
1158                    max-consecutive: 5;
1159                    allowed: ascii;
1160                );
1161                max_consecutive3: "      max-consecutive:5" => password_rules!(
1162                    max-consecutive: 5;
1163                    allowed: ascii;
1164                );
1165
1166
1167                max_consecutive_min1 "Test that the lowest number wins for multiple max-consecutive":
1168                "max-consecutive: 5; max-consecutive: 3" => password_rules!(
1169                    max-consecutive: 3;
1170                    allowed: ascii;
1171                );
1172                max_consecutive_min2 "Test that the lowest number wins for multiple max-consecutive":
1173                "max-consecutive: 3; max-consecutive: 5" => password_rules!(
1174                    max-consecutive: 3;
1175                    allowed: ascii;
1176                );
1177                max_consecutive_min3 "Test that the lowest number wins for multiple max-consecutive":
1178                "max-consecutive: 3; max-consecutive: 1; max-consecutive: 5" => password_rules!(
1179                    max-consecutive: 1;
1180                    allowed: ascii;
1181                );
1182                max_consecutive_min4 "Test that the lowest number wins for multiple max-consecutive":
1183                "required: ascii-printable; max-consecutive: 5; max-consecutive: 3" => password_rules!(
1184                    max-consecutive: 3;
1185                    required: ascii;
1186                );
1187
1188                require_allow1: "required: [*&^]; allowed: upper" => password_rules!(
1189                    allowed: upper;
1190                    required: ['&' '*' '^'];
1191                );
1192                require_allow2: "required: [*&^ABC]; allowed: upper" => password_rules!(
1193                    allowed: upper;
1194                    required: ['A' 'B' 'C' '&' '*' '^'];
1195                );
1196                required_allow3: "required: unicode; required: digit" => password_rules!(
1197                    required: unicode;
1198                    required: digit;
1199                );
1200
1201                require_empty "Test that an empty required set is ignored":
1202                "required: ; required: upper" => password_rules!(
1203                    required: upper;
1204                );
1205
1206                custom_unicode_dropped1 "Test that unicode characters in custom classes are ignored":
1207                "allowed: [供应商责任进展]" => password_rules!(
1208                    allowed: ascii;
1209                );
1210                custom_unicode_dropped2 "Test that unicode characters in custom classes are ignored":
1211                "allowed: [供应A商B责任C进展]" => password_rules!(
1212                    allowed: ['A' 'B' 'C'];
1213                );
1214
1215                collapse_allow1 "Test that several allow rules are collapsed together":
1216                "required: upper; allowed: upper; allowed: lower; minlength: 12; maxlength: 73;" =>
1217                password_rules!(
1218                    maxlength: 73;
1219                    minlength: 12;
1220                    allowed: upper, lower;
1221                    required: upper;
1222                );
1223                collapse_allow2 "Test that several allow rules are collapsed together":
1224                "required: upper; allowed: upper; allowed: lower; maxlength: 73; minlength: 12;" =>
1225                password_rules!(
1226                    maxlength: 73;
1227                    minlength: 12;
1228                    allowed: upper, lower;
1229                    required: upper;
1230                );
1231                collapse_allow3 "Test that several allow rules are collapsed together":
1232                "required: upper; allowed: upper; allowed: lower; maxlength: 73" => password_rules!(
1233                    maxlength: 73;
1234                    allowed: upper, lower;
1235                    required: upper;
1236                );
1237                collapse_allow4 "Test that several allow rules are collapsed together":
1238                "required: upper; allowed: upper; allowed: lower; minlength: 12;" => password_rules!(
1239                    minlength: 12;
1240                    allowed: upper, lower;
1241                    required: upper;
1242                );
1243
1244                minlength_max1 "Test that the largest number wins for multiple minlength":
1245                "minlength: 12; minlength: 7; minlength: 23" => password_rules!(
1246                    minlength: 23;
1247                    allowed: ascii;
1248                );
1249                minlength_max2 "Test that the largest number wins for multiple minlength":
1250                "minlength: 12; maxlength: 17; minlength: 10" => password_rules!(
1251                    maxlength: 17;
1252                    minlength: 12;
1253                    allowed: ascii;
1254                );
1255
1256                bad_syntax1: "allowed: upper,," => None;
1257                bad_syntax2: "allowed: upper,;" => None;
1258                bad_syntax3: "allowed: upper [a]" => None;
1259                bad_syntax4: "dummy: upper" => None;
1260                bad_syntax5: "upper: lower" => None;
1261                bad_syntax6: "max-consecutive: [ABC]" => None;
1262                bad_syntax7: "max-consecutive: upper" => None;
1263                bad_syntax8: "max-consecutive: 1+1" => None;
1264                bad_syntax9: "max-consecutive: 供" => None;
1265                bad_syntax10: "required: 1" => None;
1266                bad_syntax11: "required: 1+1" => None;
1267                bad_syntax12: "required: 供" => None;
1268                bad_syntax13: "required: A" => None;
1269                bad_syntax14: "required: required: upper" => None;
1270                bad_syntax15: "allowed: 1" => None;
1271                bad_syntax16: "allowed: 1+1" => None;
1272                bad_syntax17: "allowed: 供" => None;
1273                bad_syntax18: "allowed: A" => None;
1274                bad_syntax19: "allowed: allowed: upper" => None;
1275
1276                custom_class1
1277                "Test that a - and ] are only accepted as the first and last characters in a class":
1278                "required:         digit           ;                        required: [-]];" =>
1279                password_rules!(
1280                    required: digit;
1281                    required: ['-' ']'];
1282                );
1283                custom_class2
1284                "Test that a - and ] are only accepted as the first and last characters in a class":
1285                "required:         digit           ;                    required: [-ABC]];" =>
1286                password_rules!(
1287                    required: digit;
1288                    required: ['A' 'B' 'C' '-' ']'];
1289                );
1290                custom_class3
1291                "Test that a - and ] are only accepted as the first and last characters in a class":
1292                "required:         digit           ;                    required: [-];" =>
1293                password_rules!(
1294                    required: digit;
1295                    required: ['-'];
1296                );
1297                custom_class4
1298                "Test that a - and ] are only accepted as the first and last characters in a class":
1299                "required:         digit           ;                    required: []];" =>
1300                password_rules!(
1301                    required: digit;
1302                    required: [']'];
1303                );
1304
1305                bad_custom_class1 "Test that a hyphen is only accepted as the first character in a class":
1306                "required:         digit           ;                        required: [a-];" => None;
1307                bad_custom_class2 "Test that a hyphen is only accepted as the first character in a class":
1308                "required:         digit           ;                        required: []-];" => None;
1309                bad_custom_class3 "Test that a hyphen is only accepted as the first character in a class":
1310                "required:         digit           ;                        required: [--];" => None;
1311                bad_custom_class4 "Test that a hyphen is only accepted as the first character in a class":
1312                "required:         digit           ;                        required: [-a--------];" => None;
1313                bad_custom_class5 "Test that a hyphen is only accepted as the first character in a class":
1314                "required:         digit           ;                        required: [-a--------] ];" => None;
1315
1316                canonical1 "Test that a custom character class is converted into a named class":
1317                "required: [abcdefghijklmnopqrstuvwxyz]" => password_rules!(
1318                    required: lower;
1319                );
1320                canonical2 "Test that a custom character class is converted into a named class":
1321                "required: [abcdefghijklmnopqrstuvwxy]" => password_rules!(
1322                    required: [
1323                        'a''b''c''d''e''f''g''h''i''j''k''l''m''n''o''p''q''r''s''t''u''v''w''x''y'
1324                    ];
1325                );
1326            }
1327        }
1328    }
1329}