Functional Weave
Code in TypeScript

auth.password-policy@1.0.0

impl/rust/check_password.rs

6,005 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::auth_password_policy_data::COMMON_PASSWORDS;  ← this capability’s own data, compiled from data/policies.json into the same file by fune build

/// Words shorter than this in a name or email are not refused inside a
/// password: "al" or "jo" would refuse half of all passwords.
const MIN_PERSONAL: usize = 3;

/// Pieces between ASCII punctuation and spaces, lower-cased (ASCII only, as
/// in the other languages), 3 or more characters.
fn words(text: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut current = String::new();
    for ch in text.chars() {
        if ch.is_ascii() && !ch.is_ascii_alphanumeric() {
            if current.chars().count() >= MIN_PERSONAL {
                out.push(current.to_ascii_lowercase());
            }
            current.clear();
        } else {
            current.push(ch);
        }
    }
    if current.chars().count() >= MIN_PERSONAL {
        out.push(current.to_ascii_lowercase());
    }
    out
}

fn failure(code: &str, message: String) -> PasswordFailure {
    PasswordFailure { code: code.to_string(), message }
}

/// Every rule of the policy the password breaks, in a fixed order, each with
/// a sentence to show beside the field. The same function runs in the
/// browser and on the server.
///
/// # Panics
/// Panics on a policy whose numbers make no sense.
pub fn check_password(password: &str, email: Option<&str>, name: Option<&str>, policy: &PasswordPolicy) -> PasswordCheck {
    if policy.min_length < 1 {
        panic!("policy minLength must be a whole number of at least 1");
    }
    if policy.max_length < policy.min_length {
        panic!("policy maxLength must be a whole number no smaller than minLength");
    }
    if !(0..=4).contains(&policy.min_character_classes) {
        panic!("policy minCharacterClasses must be a whole number from 0 to 4");
    }
    let mut failures = Vec::new();
    let length = password.chars().count() as i64;

    if length < policy.min_length {
        failures.push(failure("too_short", format!("Use at least {} characters.", policy.min_length)));
    }
    if length > policy.max_length {
        failures.push(failure("too_long", format!("Use no more than {} characters.", policy.max_length)));
    }
    if policy.min_character_classes > 0 {
        let (mut lower, mut upper, mut digit, mut other) = (0, 0, 0, 0);
        for ch in password.chars() {
            match ch {
                'a'..='z' => lower = 1,
                'A'..='Z' => upper = 1,
                '0'..='9' => digit = 1,
                _ => other = 1,
            }
        }
        if lower + upper + digit + other < policy.min_character_classes {
            failures.push(failure(
                "too_few_character_classes",
                format!(
                    "Use at least {} of these: lower-case letters, capital letters, digits, symbols.",
                    policy.min_character_classes
                ),
            ));
        }
    }

    let folded = password.to_ascii_lowercase();
    if policy.block_common && COMMON_PASSWORDS.iter().any(|row| row.password == folded) {
        failures.push(failure(
            "too_common",
            "This password is too common. Choose something harder to guess.".to_string(),
        ));
    }
    if policy.block_personal {
        if let Some(email) = email {
            let local = match email.rfind('@') {
                Some(at) => &email[..at],
                None => email,
            };
            let mut candidates = words(local);
            if local.chars().count() >= MIN_PERSONAL {
                candidates.push(local.to_ascii_lowercase());
            }
            if candidates.iter().any(|word| folded.contains(word.as_str())) {
                failures.push(failure(
                    "contains_email",
                    "Do not include your email address in your password.".to_string(),
                ));
            }
        }
        if let Some(name) = name {
            if words(name).iter().any(|word| folded.contains(word.as_str())) {
                failures.push(failure("contains_name", "Do not include your name in your password.".to_string()));
            }
        }
    }
    PasswordCheck { valid: failures.is_empty(), failures }
}

pub fn password_check_to_value(check: &PasswordCheck) -> Value {
    Value::obj(vec![
        ("valid", Value::Bool(check.valid)),
        (
            "failures",
            Value::Arr(
                check
                    .failures
                    .iter()
                    .map(|f| Value::obj(vec![("code", Value::str(&f.code)), ("message", Value::str(&f.message))]))
                    .collect(),
            ),
        ),
    ])
}

/// A PasswordPolicy from vector JSON, refusing a fractional or missing
/// number with the words the policy check uses.
pub fn password_policy_from_value(value: &Value) -> PasswordPolicy {
    let int = |key: &str, message: &str| match value.get(key) {
        Value::Int(i) => *i,
        _ => panic!("{}", message),
    };
    PasswordPolicy {
        name: value.get("name").as_str().to_string(),
        min_length: int("minLength", "policy minLength must be a whole number of at least 1"),
        max_length: int("maxLength", "policy maxLength must be a whole number no smaller than minLength"),
        min_character_classes: int("minCharacterClasses", "policy minCharacterClasses must be a whole number from 0 to 4"),
        block_common: value.get("blockCommon").as_bool(),
        block_personal: value.get("blockPersonal").as_bool(),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    let text = |v: &Value| match v {
        Value::Str(s) => Some(s.clone()),
        _ => None,
    };
    let password = match &args[0] {
        Value::Str(s) => s.clone(),
        _ => panic!("password must be a string"),
    };
    let email = text(&args[1]);
    let name = text(&args[2]);
    let policy = password_policy_from_value(&args[3]);
    password_check_to_value(&check_password(&password, email.as_deref(), name.as_deref(), &policy))
}