passay-rs 0.1.0

A password validation library inspired by the Java Passay library.
Documentation
use crate::rule::character::CharacterRule;
use crate::rule::rule_result::RuleResult;
use crate::rule::{HasCharacters, PasswordData, Rule};
use std::collections::HashMap;

pub const ERROR_CODE: &str = "INSUFFICIENT_CHARACTERISTICS";

/// Rule for determining if a password contains the desired mix of character types. In order to meet the criteria of this
/// rule, passwords must meet any number of supplied character rules.
///
/// # Example
///
/// ```
///    use passay_rs::rule::PasswordData;
///    use passay_rs::rule::Rule;
///    use passay_rs::rule::character_characteristics::CharacterCharacteristics;
///    use passay_rs::rule::character::CharacterRule;
///    use passay_rs::rule::character_data::EnglishCharacterData;
///
///    let char_rules = vec![
///        CharacterRule::new(Box::new(EnglishCharacterData::Digit), 1).unwrap(),
///        CharacterRule::new(Box::new(EnglishCharacterData::Special), 1).unwrap(),
///        CharacterRule::new(Box::new(EnglishCharacterData::UpperCase), 1).unwrap(),
///        CharacterRule::new(Box::new(EnglishCharacterData::LowerCase), 1).unwrap(),
///    ];
///
///    let rule = CharacterCharacteristics::new(char_rules, 4, false, true).unwrap();
///    let password = PasswordData::with_password("pwUiNh0248".to_string());
///    let result = rule.validate(&password);
///    // password is invalid because it has no special character in it.
///    assert!(!result.valid());
/// ```
pub struct CharacterCharacteristics {
    rules: Vec<CharacterRule>,
    num_characteristics: usize,
    report_failure: bool,
    report_rule_failures: bool,
}

impl CharacterCharacteristics {
    pub fn new(
        rules: Vec<CharacterRule>,
        num_characteristics: usize,
        report_failure: bool,
        report_rule_failures: bool,
    ) -> Result<CharacterCharacteristics, String> {
        if num_characteristics < 1 {
            return Err("Number of characteristics must be greater than zero".to_string());
        }
        if num_characteristics > rules.len() {
            return Err("Number of characteristics must be <= to the number of rules".to_string());
        }
        Ok(CharacterCharacteristics {
            rules,
            num_characteristics,
            report_failure,
            report_rule_failures,
        })
    }
    pub fn with_rules_and_characteristics(
        rules: Vec<CharacterRule>,
        num_characteristics: usize,
    ) -> Result<CharacterCharacteristics, String> {
        Self::new(rules, num_characteristics, true, true)
    }
    pub fn from_rules(rules: Vec<CharacterRule>) -> Result<CharacterCharacteristics, String> {
        Self::with_rules_and_characteristics(rules, 1)
    }
    fn create_rule_result_detail_parameters(&self, success: usize) -> HashMap<String, String> {
        let mut map = HashMap::with_capacity(3);
        map.insert("successCount".to_string(), success.to_string());
        map.insert(
            "minimumRequired".to_string(),
            self.num_characteristics.to_string(),
        );
        map.insert("ruleCount".to_string(), self.rules.len().to_string());
        map
    }
}

impl Rule for CharacterCharacteristics {
    fn validate(&self, password_data: &PasswordData) -> RuleResult {
        let mut success_count = 0usize;
        let mut result = RuleResult::default();
        for rule in &self.rules {
            let mut rr = rule.validate(password_data);
            if rr.valid() {
                success_count += 1;
            } else if self.report_rule_failures {
                result.details_mut().append(rr.details_mut())
            }
            result.metadata_mut().merge(rr.metadata())
        }
        if success_count < self.num_characteristics {
            result.set_valid(false);
            if self.report_failure {
                result.add_error(
                    ERROR_CODE,
                    Some(self.create_rule_result_detail_parameters(success_count)),
                )
            }
        }
        result
    }
    fn as_has_characters(&self) -> Option<&dyn HasCharacters> {
        Some(self)
    }
}

impl HasCharacters for CharacterCharacteristics {
    fn characters(&self) -> String {
        self.rules.iter().map(CharacterRule::characters).collect::<String>()
    }
}

#[cfg(test)]
mod tests {
    use crate::rule::character::CharacterRule;
    use crate::rule::character_characteristics::{CharacterCharacteristics, ERROR_CODE};
    use crate::rule::character_data::{CharacterData, EnglishCharacterData};
    use crate::rule::rule_result::CountCategory;
    use crate::rule::{PasswordData, Rule};
    use crate::test::{RulePasswordTestItem, check_messages, check_passwords};

    #[test]
    fn test_passwords() {
        let test_cases: Vec<RulePasswordTestItem> = vec![
            // valid ascii password
            RulePasswordTestItem(
                create_rule1(),
                PasswordData::with_password("r%scvEW2e93)".to_string()),
                vec![],
            ),
            // valid non-ascii password
            RulePasswordTestItem(
                create_rule1(),
                PasswordData::with_password("r¢sCvE±2e93".to_string()),
                vec![],
            ),
            // issue #32
            RulePasswordTestItem(
                create_rule1(),
                PasswordData::with_password("r~scvEW2e93b".to_string()),
                vec![],
            ),
            // missing lowercase
            RulePasswordTestItem(
                create_rule1(),
                PasswordData::with_password("r%5#8EW2393)".to_string()),
                vec![
                    ERROR_CODE,
                    EnglishCharacterData::Alphabetical.error_code(),
                    EnglishCharacterData::LowerCase.error_code(),
                ],
            ),
            // missing 3 digits
            RulePasswordTestItem(
                create_rule1(),
                PasswordData::with_password("r%scvEW2e9e)".to_string()),
                vec![ERROR_CODE, EnglishCharacterData::Digit.error_code()],
            ),
            // missing 2 uppercase
            RulePasswordTestItem(
                create_rule1(),
                PasswordData::with_password("r%scv3W2e9)".to_string()),
                vec![ERROR_CODE, EnglishCharacterData::UpperCase.error_code()],
            ),
            // missing 2 lowercase
            RulePasswordTestItem(
                create_rule1(),
                PasswordData::with_password("R%s4VEW239)".to_string()),
                vec![ERROR_CODE, EnglishCharacterData::LowerCase.error_code()],
            ),
            // missing 1 special
            RulePasswordTestItem(
                create_rule1(),
                PasswordData::with_password("r5scvEW2e9b".to_string()),
                vec![ERROR_CODE, EnglishCharacterData::Special.error_code()],
            ),
            // previous passwords all valid under different rule set
            RulePasswordTestItem(
                create_rule2(),
                PasswordData::with_password("r%scvEW2e93)".to_string()),
                vec![],
            ),
            RulePasswordTestItem(
                create_rule2(),
                PasswordData::with_password("r¢sCvE±2e93".to_string()),
                vec![],
            ),
            RulePasswordTestItem(
                create_rule2(),
                PasswordData::with_password("r%5#8EW2393)".to_string()),
                vec![],
            ),
            RulePasswordTestItem(
                create_rule2(),
                PasswordData::with_password("r%scvEW2e9e)".to_string()),
                vec![],
            ),
            RulePasswordTestItem(
                create_rule2(),
                PasswordData::with_password("r%scv3W2e9)".to_string()),
                vec![],
            ),
            RulePasswordTestItem(
                create_rule2(),
                PasswordData::with_password("R%s4VEW239)".to_string()),
                vec![],
            ),
            RulePasswordTestItem(
                create_rule2(),
                PasswordData::with_password("r5scvEW2e9b".to_string()),
                vec![],
            ),
        ];
        check_passwords(test_cases);
    }
    #[test]
    fn test_messages() {
        let test_cases: Vec<RulePasswordTestItem> = vec![
            RulePasswordTestItem(
                create_rule1(),
                PasswordData::with_password("r%scvEW2e3)".to_string()),
                vec!["INSUFFICIENT_DIGIT,3,2", "INSUFFICIENT_CHARACTERISTICS,4,5"],
            ),
            RulePasswordTestItem(
                create_rule1(),
                PasswordData::with_password("R»S7VEW2e3)".to_string()),
                vec!["INSUFFICIENT_LOWERCASE,2,1", "INSUFFICIENT_CHARACTERISTICS,4,5"],
            ),
            RulePasswordTestItem(
                create_rule2(),
                PasswordData::with_password("rscvew2e3".to_string()),
                vec!["INSUFFICIENT_SPECIAL,1,0", "INSUFFICIENT_UPPERCASE,1,0"],
            ),
        ];
        check_messages(test_cases);
    }

    #[test]
    fn check_consistency() {
        let result = CharacterCharacteristics::from_rules(vec![]);
        assert!(result.is_err_and(|e| {
            "Number of characteristics must be <= to the number of rules" == e
        }));
    }
    #[test]
    fn check_metadata() {
        let rules = vec![
            CharacterRule::new(Box::new(EnglishCharacterData::Digit), 1).unwrap(),
            CharacterRule::new(Box::new(EnglishCharacterData::LowerCase), 1).unwrap(),
        ];
        let rule = CharacterCharacteristics::with_rules_and_characteristics(rules, 2).unwrap();

        let password_data = PasswordData::with_password("meTAdata01".to_string());
        let result = rule.validate(&password_data);
        assert!(result.valid());
        assert_eq!(2, result.metadata().get_count(CountCategory::Digit));
        assert_eq!(6, result.metadata().get_count(CountCategory::LowerCase));
    }

    fn create_rule1() -> Box<CharacterCharacteristics> {
        let char_rules = vec![
            CharacterRule::new(Box::new(EnglishCharacterData::Alphabetical), 4).unwrap(),
            CharacterRule::new(Box::new(EnglishCharacterData::Digit), 3).unwrap(),
            CharacterRule::new(Box::new(EnglishCharacterData::UpperCase), 2).unwrap(),
            CharacterRule::new(Box::new(EnglishCharacterData::LowerCase), 2).unwrap(),
            CharacterRule::new(Box::new(EnglishCharacterData::Special), 1).unwrap(),
        ];
        Box::new(CharacterCharacteristics::with_rules_and_characteristics(char_rules, 5).unwrap())
    }
    fn create_rule2() -> Box<CharacterCharacteristics> {
        let char_rules = vec![
            CharacterRule::new(Box::new(EnglishCharacterData::Digit), 1).unwrap(),
            CharacterRule::new(Box::new(EnglishCharacterData::Special), 1).unwrap(),
            CharacterRule::new(Box::new(EnglishCharacterData::UpperCase), 1).unwrap(),
            CharacterRule::new(Box::new(EnglishCharacterData::LowerCase), 1).unwrap(),
        ];
        Box::new(CharacterCharacteristics::new(char_rules, 3, false, true).unwrap())
    }
}