Bonus chapter

Password Validator

👋 Anyone can read and edit this exercise. Sign up to save your progress.

A password form that says only "invalid" leaves the user guessing. Let's build one that explains which checks failed. The result will be a Rust value a caller can inspect, rather than a message printed inside the validator.

This optional project brings together borrowing, enums, structs, iterators, and Result. Try it after the iterators chapter. There are four steps:

  1. Check which character classes an input contains.
  2. Represent a score and its feedback in a report.
  3. Apply the rules and return that report.
  4. Generate sample inputs for testing, including handling an impossible request.

Each editor runs on its own. Later steps supply the helpers you've already practiced, so you can focus on the new task instead of copying earlier answers. Read the contract above each editor, try the tests, and open its hints if you get stuck.

This is a programming exercise, not a password-security tool. The scores and the label Strong describe our made-up rules; they do not measure how hard a password is to guess. Use invented inputs only: Run sends the code, including its test strings, to the Rust Playground.

The files get longer here because each editor includes its own support code and tests. Open in Web Editor opens the file on github.dev if you'd like more room. For local execution and rust-analyzer, clone the repo and run cargo test --example 19_password_validator. The unfinished steps will fail until you implement them.

Check the Characters

Start with four small questions the validator will need to ask. Does the input contain an uppercase letter, a lowercase letter, a digit, or one of our chosen special characters? Keeping these checks separate lets you test each rule before combining them.

Implement has_uppercase, has_lowercase, has_digit, and has_special. Each takes a borrowed &str and returns whether at least one character belongs to its class:

FunctionCharacters That Count
has_uppercaseA through Z
has_lowercasea through z
has_digit0 through 9
has_specialExactly !@#$%^&*

An empty input satisfies none of the checks. A matching character can appear anywhere, not just at the start. Other characters are allowed in the input, but don't satisfy these rules: É is not an ASCII uppercase letter, and ? is not in our special-character set. Don't trim or change the input.

For example, "café7?" contains lowercase ASCII letters and an ASCII digit, but neither uppercase ASCII letters nor a special character from our set.

Useful from the Standard Library

Exercise 1 of 4
Open in Web Editor

Results

    Compiler / runtime output
    
                
    Stuck? Show a hint No spoilers, just a nudge

    Ask whether any character matches each rule; you don't need to count matches or allocate a new string. Check the difference between the ASCII predicates and their Unicode counterparts. For the special rule, treat the allowed characters as a small set, not as a substring that must appear in full.

    Reveal the full solution Spoiler: the complete answer
    /// Checks for an ASCII uppercase letter without trimming or normalizing.
    fn has_uppercase(password: &str) -> bool {
        password.chars().any(|c| c.is_ascii_uppercase())
    }
    
    /// Checks for an ASCII lowercase letter without trimming or normalizing.
    fn has_lowercase(password: &str) -> bool {
        password.chars().any(|c| c.is_ascii_lowercase())
    }
    
    /// Checks for an ASCII digit without trimming or normalizing.
    fn has_digit(password: &str) -> bool {
        password.chars().any(|c| c.is_ascii_digit())
    }
    
    /// Checks for one of exactly `!@#$%^&*`.
    fn has_special(password: &str) -> bool {
        password.chars().any(|c| "!@#$%^&*".contains(c))
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn uppercase_accepts_ascii_including_a_late_match() {
            for password in ["A", "Z", "é١!?lowercaseZ", " A "] {
                assert!(has_uppercase(password));
            }
        }
    
        #[test]
        fn uppercase_rejects_empty_other_classes_and_unicode() {
            for password in ["", "lowercase012!", "É", "é", "١", "?_-."] {
                assert!(!has_uppercase(password));
            }
        }
    
        #[test]
        fn lowercase_accepts_ascii_including_a_late_match() {
            for password in ["a", "z", "É١!?ABCz", " a "] {
                assert!(has_lowercase(password));
            }
        }
    
        #[test]
        fn lowercase_rejects_empty_other_classes_and_unicode() {
            for password in ["", "UPPERCASE012!", "É", "é", "١", "?_-."] {
                assert!(!has_lowercase(password));
            }
        }
    
        #[test]
        fn digit_accepts_ascii_including_a_late_match() {
            for password in ["0", "9", "Éé١!?Letters9", " 0 "] {
                assert!(has_digit(password));
            }
        }
    
        #[test]
        fn digit_rejects_empty_other_classes_and_unicode() {
            for password in ["", "Letters!", "É", "é", "١", "?_-."] {
                assert!(!has_digit(password));
            }
        }
    
        #[test]
        fn special_accepts_every_allowed_symbol_including_a_late_match() {
            for symbol in "!@#$%^&*".chars() {
                assert!(has_special(&symbol.to_string()));
                assert!(has_special(&format!("Éé١?Letters09{symbol}")));
            }
        }
    
        #[test]
        fn special_rejects_empty_other_classes_and_other_punctuation() {
            for password in [
                "",
                "Letters09",
                "É",
                "é",
                "١",
                "?_-.,:;/\\()[]{}+=~`|<>\"'",
                " \n\t",
            ] {
                assert!(!has_special(password));
            }
        }
    }
    

    Give the Report a Type

    The checks tell us what is present, but the caller will need a summary. PasswordReport groups a numeric score, a list of feedback messages, and a PasswordStrength label. Using an enum for the label means callers can match on known cases instead of comparing strings such as "strong" and hoping nobody misspells one. The report deliberately does not keep a copy of the password.

    Implement two methods:

    This step doesn't calculate a password's score yet. The tests supply scores and reports directly so you can check the boundaries independently of the character rules.

    Useful from the Standard Library

    • A match must cover every variant or value it can receive. You can use ranges or conditions to classify the score; choose whichever makes the boundaries clearest to you.
    • The supplied PartialEq derive allows two PasswordStrength values to be compared.
    Exercise 2 of 4
    Open in Web Editor

    Results

      Compiler / runtime output
      
                  
      Stuck? Show a hint No spoilers, just a nudge

      Separate the two jobs. from_score chooses a label from a number; is_strong reads a label from an existing report. Walk through the values immediately below and at each boundary before you run the tests. If you use range patterns, ..= includes its upper endpoint.

      Reveal the full solution Spoiler: the complete answer
      #[derive(Debug, Clone, PartialEq, Eq)]
      enum PasswordStrength {
          Weak,
          Medium,
          Strong,
      }
      
      impl PasswordStrength {
          /// Classifies any u8: 0..=29 is Weak, 30..=69 is Medium, 70..=255 is
          /// Strong.
          const fn from_score(score: u8) -> Self {
              match score {
                  0..=29 => Self::Weak,
                  30..=69 => Self::Medium,
                  _ => Self::Strong,
              }
          }
      }
      
      #[derive(Debug, Clone)]
      struct PasswordReport {
          score: u8,
          feedback: Vec<String>,
          strength: PasswordStrength,
      }
      
      impl PasswordReport {
          /// Checks the stored strength label; does not recompute it from the score.
          fn is_strong(&self) -> bool {
              self.strength == PasswordStrength::Strong
          }
      }
      
      #[cfg(test)]
      mod tests {
          use super::*;
      
          #[test]
          fn classifies_weak_boundaries() {
              assert_eq!(PasswordStrength::from_score(0), PasswordStrength::Weak);
              assert_eq!(PasswordStrength::from_score(29), PasswordStrength::Weak);
          }
      
          #[test]
          fn classifies_medium_boundaries() {
              assert_eq!(PasswordStrength::from_score(30), PasswordStrength::Medium);
              assert_eq!(PasswordStrength::from_score(69), PasswordStrength::Medium);
          }
      
          #[test]
          fn classifies_strong_through_the_full_u8_range() {
              for score in [70, 100, 255] {
                  assert_eq!(
                      PasswordStrength::from_score(score),
                      PasswordStrength::Strong
                  );
              }
          }
      
          #[test]
          fn weak_report_is_not_strong() {
              let report = PasswordReport {
                  score: 29,
                  feedback: vec!["Add an uppercase ASCII letter.".to_string()],
                  strength: PasswordStrength::Weak,
              };
              assert!(!report.is_strong());
          }
      
          #[test]
          fn medium_report_is_not_strong() {
              let report = PasswordReport {
                  score: 69,
                  feedback: Vec::new(),
                  strength: PasswordStrength::Medium,
              };
              assert!(!report.is_strong());
          }
      
          #[test]
          fn strong_report_is_strong() {
              let report = PasswordReport {
                  score: 70,
                  feedback: Vec::new(),
                  strength: PasswordStrength::Strong,
              };
              assert!(report.is_strong());
          }
      
          #[test]
          fn stored_label_is_authoritative_even_if_a_report_is_manually_inconsistent() {
              // This method reads the label, not the score. Only the validator
              // guarantees consistency; a manually constructed report need not have
              // that invariant.
              for (score, strength, expected) in [
                  (100, PasswordStrength::Weak, false),
                  (100, PasswordStrength::Medium, false),
                  (0, PasswordStrength::Strong, true),
              ] {
                  let report = PasswordReport {
                      score,
                      feedback: Vec::new(),
                      strength,
                  };
                  assert_eq!(report.is_strong(), expected);
              }
          }
      }
      

      Return a Useful Report

      Now put the pieces together in PasswordValidator::validate(password). A caller should get both a summary and every missing base requirement from a single call. Unlike a parser that stops at its first error, this function must keep checking after it finds a problem.

      The character helpers and report methods are already implemented in this editor. Your task is only the body of validate. Borrow the input, apply the following scoring rules, and return a PasswordReport.

      RulePointsFeedback When Missing
      At least 8 characters20Use at least 8 characters.
      An uppercase ASCII letter15Add an uppercase ASCII letter.
      A lowercase ASCII letter15Add a lowercase ASCII letter.
      An ASCII digit15Add an ASCII digit.
      A character from !@#$%^&*15Add one of !@#$%^&*.
      At least 12 characters10 moreNone
      At least 16 characters10 moreNone

      Here, "characters" means Unicode scalar values, not bytes or visible symbols. Keep whitespace and punctuation as they are; they count toward length even when they don't satisfy a character-class rule. The length rewards accumulate, so an input of at least 16 characters earns all 40 length points. Each character class earns its points once, regardless of how many matching characters appear.

      Use the exact feedback strings in the table, in table order, and only for failed base rules. The two extra length rewards do not create complaints. Use PasswordStrength::from_score for the label rather than writing the classification rules again.

      For example, "Rust1234" has eight characters and three of the four classes. Its report has a score of 65, the label Medium, and one feedback message: "Add one of !@#$%^&*.". An empty input scores zero and receives all five base-rule messages.

      The fixed rules make the tests precise. Once they pass, you can experiment with another policy, but change its tests too.

      Useful from the Standard Library

      Exercise 3 of 4
      Open in Web Editor

      Results

        Compiler / runtime output
        
                    
        Stuck? Show a hint No spoilers, just a nudge

        Keep a score and an initially empty feedback vector. For each base rule, either award its points or add its message, then continue to the next rule. Check the two length bonuses independently: reaching 16 characters must not skip the reward for reaching 12. Classify the finished score with the supplied method and construct the report last.

        If only the non-ASCII tests fail, check how you measure length. If a long input fails, check whether you converted its length to u8 before comparing it with the thresholds.

        Reveal the full solution Spoiler: the complete answer
        #[derive(Debug, Clone, PartialEq, Eq)]
        enum PasswordStrength {
            Weak,
            Medium,
            Strong,
        }
        
        impl PasswordStrength {
            /// Classifies any u8: 0..=29 is Weak, 30..=69 is Medium, 70..=255 is
            /// Strong.
            const fn from_score(score: u8) -> Self {
                match score {
                    0..=29 => Self::Weak,
                    30..=69 => Self::Medium,
                    _ => Self::Strong,
                }
            }
        }
        
        #[derive(Debug, Clone)]
        struct PasswordReport {
            score: u8,
            feedback: Vec<String>,
            strength: PasswordStrength,
        }
        
        impl PasswordReport {
            /// Checks the stored strength label; does not recompute it from the score.
            fn is_strong(&self) -> bool {
                self.strength == PasswordStrength::Strong
            }
        }
        
        fn has_uppercase(password: &str) -> bool {
            password.chars().any(|c| c.is_ascii_uppercase())
        }
        
        fn has_lowercase(password: &str) -> bool {
            password.chars().any(|c| c.is_ascii_lowercase())
        }
        
        fn has_digit(password: &str) -> bool {
            password.chars().any(|c| c.is_ascii_digit())
        }
        
        fn has_special(password: &str) -> bool {
            password.chars().any(|c| "!@#$%^&*".contains(c))
        }
        
        struct PasswordValidator {}
        
        impl PasswordValidator {
            /// Applies a toy scoring scheme, not a real-world password security
            /// assessment.
            ///
            /// Count Unicode scalar values without trimming or normalization. Lengths
            /// of at least 8, 12, and 16 earn 20, 10, and 10 cumulative points. Each
            /// ASCII character class earns 15 points. Report only failed base rules, in
            /// order: length >= 8, uppercase, lowercase, digit, special (`!@#$%^&*`).
            /// Never store the input in the report.
            fn validate(password: &str) -> PasswordReport {
                let length = password.chars().count();
                let mut score = 0;
                let mut feedback = Vec::new();
        
                if length >= 8 {
                    score += 20;
                } else {
                    feedback.push("Use at least 8 characters.".to_string());
                }
                if length >= 12 {
                    score += 10;
                }
                if length >= 16 {
                    score += 10;
                }
        
                for (present, message) in [
                    (has_uppercase(password), "Add an uppercase ASCII letter."),
                    (has_lowercase(password), "Add a lowercase ASCII letter."),
                    (has_digit(password), "Add an ASCII digit."),
                    (has_special(password), "Add one of !@#$%^&*."),
                ] {
                    if present {
                        score += 15;
                    } else {
                        feedback.push(message.to_string());
                    }
                }
        
                PasswordReport {
                    score,
                    feedback,
                    strength: PasswordStrength::from_score(score),
                }
            }
        }
        
        #[cfg(test)]
        mod tests {
            use super::*;
        
            fn assert_report(password: &str, score: u8, feedback: &[&str], strength: PasswordStrength) {
                let report = PasswordValidator::validate(password);
                assert_eq!(report.score, score);
                assert_eq!(report.feedback, feedback);
                assert_eq!(report.strength, strength);
                assert_eq!(report.strength, PasswordStrength::from_score(report.score));
                assert_eq!(report.is_strong(), strength == PasswordStrength::Strong);
            }
        
            #[test]
            fn scores_exact_length_boundaries_with_all_classes_held_constant() {
                for (length, score) in [(7, 60), (8, 80), (11, 80), (12, 90), (15, 90), (16, 100)] {
                    let password = format!("Aa1!{}", "?".repeat(length - 4));
                    let feedback: &[&str] = if length < 8 {
                        &["Use at least 8 characters."]
                    } else {
                        &[]
                    };
                    let strength = if length < 8 {
                        PasswordStrength::Medium
                    } else {
                        PasswordStrength::Strong
                    };
                    assert_report(&password, score, feedback, strength);
                }
            }
        
            #[test]
            fn reports_only_missing_base_length() {
                assert_report(
                    "Aa1!",
                    60,
                    &["Use at least 8 characters."],
                    PasswordStrength::Medium,
                );
            }
        
            #[test]
            fn reports_only_missing_uppercase() {
                assert_report(
                    "aa1!????",
                    65,
                    &["Add an uppercase ASCII letter."],
                    PasswordStrength::Medium,
                );
            }
        
            #[test]
            fn reports_only_missing_lowercase() {
                assert_report(
                    "AA1!????",
                    65,
                    &["Add a lowercase ASCII letter."],
                    PasswordStrength::Medium,
                );
            }
        
            #[test]
            fn reports_only_missing_digit() {
                assert_report(
                    "Aaa!????",
                    65,
                    &["Add an ASCII digit."],
                    PasswordStrength::Medium,
                );
            }
        
            #[test]
            fn reports_only_missing_special() {
                assert_report(
                    "Aa11?_-.",
                    65,
                    &["Add one of !@#$%^&*."],
                    PasswordStrength::Medium,
                );
            }
        
            #[test]
            fn empty_input_has_zero_score_and_all_five_messages_in_order() {
                assert_report(
                    "",
                    0,
                    &[
                        "Use at least 8 characters.",
                        "Add an uppercase ASCII letter.",
                        "Add a lowercase ASCII letter.",
                        "Add an ASCII digit.",
                        "Add one of !@#$%^&*.",
                    ],
                    PasswordStrength::Weak,
                );
            }
        
            #[test]
            fn multiple_missing_rules_keep_base_rule_order() {
                assert_report(
                    "a",
                    15,
                    &[
                        "Use at least 8 characters.",
                        "Add an uppercase ASCII letter.",
                        "Add an ASCII digit.",
                        "Add one of !@#$%^&*.",
                    ],
                    PasswordStrength::Weak,
                );
            }
        
            #[test]
            fn counts_unicode_scalars_not_bytes_at_every_length_boundary() {
                for (length, score) in [(7, 60), (8, 80), (11, 80), (12, 90), (15, 90), (16, 100)] {
                    let password = format!("Aa1!{}", "é".repeat(length - 4));
                    let feedback: &[&str] = if length < 8 {
                        &["Use at least 8 characters."]
                    } else {
                        &[]
                    };
                    let strength = if length < 8 {
                        PasswordStrength::Medium
                    } else {
                        PasswordStrength::Strong
                    };
                    assert_report(&password, score, feedback, strength);
                }
            }
        
            #[test]
            fn unicode_letters_and_arabic_digits_contribute_only_to_length() {
                for (length, score, strength) in [
                    (8, 20, PasswordStrength::Weak),
                    (12, 30, PasswordStrength::Medium),
                    (16, 40, PasswordStrength::Medium),
                ] {
                    let password: String = "Éé١".chars().cycle().take(length).collect();
                    assert_report(
                        &password,
                        score,
                        &[
                            "Add an uppercase ASCII letter.",
                            "Add a lowercase ASCII letter.",
                            "Add an ASCII digit.",
                            "Add one of !@#$%^&*.",
                        ],
                        strength,
                    );
                }
            }
        
            #[test]
            fn every_allowed_special_symbol_earns_points() {
                for special in "!@#$%^&*".chars() {
                    let password = format!("Aa1{special}????");
                    assert_report(&password, 80, &[], PasswordStrength::Strong);
                }
            }
        
            #[test]
            fn whitespace_is_not_trimmed() {
                assert_report(" Aa1! \t\n", 80, &[], PasswordStrength::Strong);
            }
        
            #[test]
            fn combining_marks_are_separate_scalars_without_normalization() {
                assert_report("Aa1!e\u{301}??", 80, &[], PasswordStrength::Strong);
            }
        
            #[test]
            fn long_inputs_do_not_truncate_the_count_or_repeat_class_points() {
                for length in [255, 256, 257, 300, 512] {
                    let password = format!("Aa1!{}", "é".repeat(length - 4));
                    assert_report(&password, 100, &[], PasswordStrength::Strong);
                }
                assert_report(&"Aa1!".repeat(100), 100, &[], PasswordStrength::Strong);
            }
        
            #[test]
            fn borrowed_input_remains_unchanged_and_reusable() {
                let password = String::from("Aa1!????");
                let report = PasswordValidator::validate(&password);
                assert_eq!(password, "Aa1!????");
                assert_eq!(report.score, 80);
                assert_report(&password, 80, &[], PasswordStrength::Strong);
            }
        }
        

        Generate Test Inputs

        So far you've checked strings supplied by a caller. Now work in the other direction: construct sample strings with known properties, so you can try different lengths without typing every input by hand. This is test-data generation, not random password generation.

        Implement PasswordGenerator::generate_example_password(length):

        Any string meeting the contract is acceptable. It can be predictable and identical across calls; the tests do not require randomness or a particular order. The Result makes the impossible request visible to the caller instead of silently returning a shorter string or panicking.

        Don't call this function to create real passwords. Its job is to produce fixtures for tests, and a fixture can satisfy every formatting rule while being trivial to guess.

        Useful from the Standard Library

        • String::new creates an empty output buffer.
        • String::push appends one character.
        • Ok and Err let the caller distinguish a generated string from an unsupported length.
        Exercise 4 of 4
        Open in Web Editor

        Results

          Compiler / runtime output
          
                      
          Stuck? Show a hint No spoilers, just a nudge

          Deal with an impossible length before constructing the string. Guaranteeing each class appears is easier than generating arbitrary characters and hoping all four classes turn up. Once those requirements are satisfied, extend the string to the requested length using allowed characters. Since every allowed character is ASCII, its byte length and scalar count are the same. No clock, random-number generator, or external crate is needed.

          Reveal the full solution Spoiler: the complete answer
          struct PasswordGenerator {}
          
          impl PasswordGenerator {
              /// Generates deterministic test data ONLY, never real secrets.
              ///
              /// Reject lengths below 4 with `"Need at least 4 characters."`. Otherwise,
              /// return exactly `length` ASCII characters drawn from uppercase letters,
              /// lowercase letters, digits, and `!@#$%^&*`, including at least one of
              /// each. No randomness or clock is needed; this is not a secure password
              /// generator.
              fn generate_example_password(length: usize) -> Result<String, &'static str> {
                  if length < 4 {
                      return Err("Need at least 4 characters.");
                  }
                  Ok("Aa1!".chars().cycle().take(length).collect())
              }
          }
          
          #[cfg(test)]
          mod tests {
              use super::*;
          
              fn assert_example_contract(length: usize) {
                  let password = PasswordGenerator::generate_example_password(length)
                      .expect("lengths of at least four must succeed");
                  assert_eq!(password.len(), length);
                  assert_eq!(password.chars().count(), length);
                  assert!(password.is_ascii());
                  assert!(password.chars().all(|c| {
                      c.is_ascii_uppercase()
                          || c.is_ascii_lowercase()
                          || c.is_ascii_digit()
                          || "!@#$%^&*".contains(c)
                  }));
                  assert!(password.chars().any(|c| c.is_ascii_uppercase()));
                  assert!(password.chars().any(|c| c.is_ascii_lowercase()));
                  assert!(password.chars().any(|c| c.is_ascii_digit()));
                  assert!(password.chars().any(|c| "!@#$%^&*".contains(c)));
              }
          
              #[test]
              fn rejects_every_length_below_four() {
                  for length in 0..4 {
                      assert_eq!(
                          PasswordGenerator::generate_example_password(length),
                          Err("Need at least 4 characters.")
                      );
                  }
              }
          
              #[test]
              fn minimum_length_includes_all_four_classes() {
                  assert_example_contract(4);
              }
          
              #[test]
              fn handles_odd_even_and_longer_lengths() {
                  for length in [5, 8, 12, 16, 33] {
                      assert_example_contract(length);
                  }
              }
          }
          

          Wrapping Up the Password Validator

          You started with small borrowed-string checks, gave their summary a type, and combined the rules into a report. The generator reversed the problem: it constructed an input with known properties and rejected requests it couldn't satisfy.

          What We Learned

          • Small predicates let you test each rule separately from the scoring policy.
          • A report can own its feedback without owning or retaining the input password.
          • An enum gives callers a fixed set of labels to match on.
          • Collecting all missing requirements is a different error-handling choice from returning after the first failure.
          • Precise boundary tests catch mistakes that one "weak" and one "strong" example would miss.
          • Passing a set of formatting checks does not establish security.

          If you want to take the project further, add a PasswordPolicy struct with a configurable minimum length. Keep the feedback and score consistent with that setting, and write boundary tests before changing the validator. Another useful experiment is to replace feedback strings with an enum of missing requirements, leaving human-readable wording to a separate display function.

          Next chapter 20Modules and Visibility

          Optional Chapters

          Extra practice to explore at your own pace. These chapters do not count toward course progress.