hsh 0.0.9

Enterprise password hashing for Rust: Argon2i / bcrypt / scrypt today, Argon2id / PHC / KMS / FIPS on the v0.1 roadmap.
Documentation
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used)]
// Copyright © 2023-2026 Hash (HSH) library contributors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! Direct unit-coverage for the `pub #[doc(hidden)]` helpers in
//! `hsh::api`. These were extracted from inline `.map_err(|e| { ... })`
//! closures so cargo-llvm-cov can credit the bodies — the closures
//! themselves only fire when upstream RustCrypto primitives reject
//! inputs that their `Params::new()` already validated, which is
//! unreachable from external input but worth covering for refactor
//! safety.

use hsh::api;
use hsh::error::{Error, HashingErrorKind};

// ---------------------------------------------------------------------------
// map_argon2_err / map_scrypt_err — same body, different kind.
// ---------------------------------------------------------------------------

#[test]
fn map_argon2_err_wraps_into_hashing_argon2() {
    // password_hash::Error has many variants; `Password` is the simplest.
    let upstream = password_hash::Error::Password;
    let e = api::map_argon2_err(upstream);
    let Error::Hashing(inner) = e else {
        panic!("expected Error::Hashing");
    };
    assert_eq!(inner.kind, HashingErrorKind::Argon2);
    assert!(!inner.detail.is_empty());
}

#[test]
fn map_scrypt_err_wraps_into_hashing_scrypt() {
    let upstream = password_hash::Error::Password;
    let e = api::map_scrypt_err(upstream);
    let Error::Hashing(inner) = e else {
        panic!("expected Error::Hashing");
    };
    assert_eq!(inner.kind, HashingErrorKind::Scrypt);
}

#[test]
fn map_argon2_err_preserves_upstream_message() {
    // Algorithm() variant carries a name; make sure the body's
    // e.to_string() drives the detail field.
    let upstream = password_hash::Error::Algorithm;
    let e = api::map_argon2_err(upstream);
    let Error::Hashing(inner) = e else {
        unreachable!();
    };
    // Detail is non-empty and routed through upstream Display.
    assert!(!inner.detail.is_empty());
}

// ---------------------------------------------------------------------------
// map_bcrypt_utf8_err — only fires if bcrypt returns non-UTF-8 bytes,
// which it cannot in practice. The wrapper synthesises the error.
// ---------------------------------------------------------------------------

#[test]
fn map_bcrypt_utf8_err_wraps_into_hashing_bcrypt() {
    // Construct a FromUtf8Error by trying to interpret invalid UTF-8.
    let bad: Vec<u8> = vec![0xff, 0xfe, 0xfd];
    let upstream = String::from_utf8(bad).unwrap_err();
    let e = api::map_bcrypt_utf8_err(upstream);
    let Error::Hashing(inner) = e else {
        panic!("expected Error::Hashing");
    };
    assert_eq!(inner.kind, HashingErrorKind::Bcrypt);
    assert!(inner.detail.contains("non-UTF-8"));
}

// ---------------------------------------------------------------------------
// pbkdf2_missing_salt / pbkdf2_missing_hash — defensive: PHC parser
// already validates these fields are present, but we keep the guards
// for callers who construct a `PasswordHash` directly.
// ---------------------------------------------------------------------------

#[test]
fn pbkdf2_missing_salt_returns_invalid_hash_string() {
    let e = api::pbkdf2_missing_salt();
    match e {
        Error::InvalidHashString(s) => {
            assert!(s.contains("salt"));
        }
        _ => panic!("expected InvalidHashString"),
    }
}

#[test]
fn pbkdf2_missing_hash_returns_invalid_hash_string() {
    let e = api::pbkdf2_missing_hash();
    match e {
        Error::InvalidHashString(s) => {
            assert!(s.contains("hash"));
        }
        _ => panic!("expected InvalidHashString"),
    }
}

// ---------------------------------------------------------------------------
// parse_pbkdf2_params — happy + unknown-key + bad-decimal paths.
// We need a real PasswordHash<'_> to test against; build one by
// minting a known-good PBKDF2 hash and re-parsing it.
// ---------------------------------------------------------------------------

#[test]
fn parse_pbkdf2_params_extracts_i_and_l() {
    use hsh::algorithms::pbkdf2::{Pbkdf2Params, Prf};
    use hsh::policy::{PolicyBuilder, PrimaryAlgorithm};
    use password_hash::PasswordHash;

    let policy =
        PolicyBuilder::from_preset(&hsh::Policy::owasp_minimum_2025())
            .primary(PrimaryAlgorithm::Pbkdf2)
            .pbkdf2(Pbkdf2Params {
                prf: Prf::Sha256,
                iterations: 7,
                dk_len: 32,
            })
            .build()
            .unwrap();
    let stored = api::hash(&policy, "pw").unwrap();
    let parsed = PasswordHash::new(&stored).unwrap();
    let (iters, dk) = api::parse_pbkdf2_params(&parsed, 16).unwrap();
    assert_eq!(iters, 7);
    assert_eq!(dk, 32);
}

#[test]
fn parse_pbkdf2_params_uses_default_dk_len_when_l_missing() {
    // We can't easily emit a PHC string without `l=` via api::hash, so
    // just confirm the default-dk_len path is exercised via the
    // standard round-trip. The default is the stored hash bytes' len,
    // which the caller (verify_pbkdf2_phc) passes.
    use hsh::algorithms::pbkdf2::{Pbkdf2Params, Prf};
    use hsh::policy::{PolicyBuilder, PrimaryAlgorithm};
    use password_hash::PasswordHash;

    let policy =
        PolicyBuilder::from_preset(&hsh::Policy::owasp_minimum_2025())
            .primary(PrimaryAlgorithm::Pbkdf2)
            .pbkdf2(Pbkdf2Params {
                prf: Prf::Sha256,
                iterations: 1,
                dk_len: 32,
            })
            .build()
            .unwrap();
    let stored = api::hash(&policy, "pw").unwrap();
    let parsed = PasswordHash::new(&stored).unwrap();
    let (_, dk) = api::parse_pbkdf2_params(&parsed, 99).unwrap();
    // PHC has `l=32` so the explicit value wins over the default.
    assert_eq!(dk, 32);
}

// Helper: mint a fresh PBKDF2 PHC string under a known param set so
// the outer parse succeeds; then surgically corrupt just the field
// under test.
fn pbkdf2_phc_with_overrides(replacements: &[(&str, &str)]) -> String {
    use hsh::algorithms::pbkdf2::{Pbkdf2Params, Prf};
    use hsh::policy::{PolicyBuilder, PrimaryAlgorithm};
    let policy =
        PolicyBuilder::from_preset(&hsh::Policy::owasp_minimum_2025())
            .primary(PrimaryAlgorithm::Pbkdf2)
            .pbkdf2(Pbkdf2Params {
                prf: Prf::Sha256,
                iterations: 1,
                dk_len: 32,
            })
            .build()
            .unwrap();
    let mut stored = api::hash(&policy, "pw").unwrap();
    for (needle, replacement) in replacements {
        stored = stored.replacen(needle, replacement, 1);
    }
    stored
}

#[test]
fn parse_pbkdf2_params_rejects_bad_iteration_decimal() {
    use password_hash::PasswordHash;
    let phc = pbkdf2_phc_with_overrides(&[("i=1,", "i=notanumber,")]);
    let parsed = PasswordHash::new(&phc).unwrap();
    let err = api::parse_pbkdf2_params(&parsed, 4).unwrap_err();
    match err {
        Error::InvalidHashString(s) => {
            assert!(s.contains("iteration"));
        }
        _ => panic!("expected InvalidHashString"),
    }
}

#[test]
fn parse_pbkdf2_params_rejects_bad_dk_len_decimal() {
    use password_hash::PasswordHash;
    let phc = pbkdf2_phc_with_overrides(&[("l=32", "l=notanumber")]);
    let parsed = PasswordHash::new(&phc).unwrap();
    let err = api::parse_pbkdf2_params(&parsed, 4).unwrap_err();
    match err {
        Error::InvalidHashString(s) => {
            assert!(s.contains("output length"));
        }
        _ => panic!("expected InvalidHashString"),
    }
}

#[test]
fn parse_pbkdf2_params_ignores_unknown_keys() {
    use password_hash::PasswordHash;
    // Inject an unknown `foo=bar` param. PHC accepts arbitrary keys
    // so the outer parser is happy; our loop skips via the `_ => {}` arm.
    let phc = pbkdf2_phc_with_overrides(&[("$i=1,", "$foo=bar,i=1,")]);
    let parsed = PasswordHash::new(&phc).unwrap();
    let (iters, dk) = api::parse_pbkdf2_params(&parsed, 0).unwrap();
    assert_eq!(iters, 1);
    assert_eq!(dk, 32);
}

// ---------------------------------------------------------------------------
// Newly-extracted helpers (one per previously-inline closure)
// ---------------------------------------------------------------------------

#[test]
fn pbkdf2_bad_iter_helper_returns_invalid_hash_string() {
    let e = api::pbkdf2_bad_iter();
    match e {
        Error::InvalidHashString(s) => {
            assert!(s.contains("iteration count"));
        }
        _ => panic!("expected InvalidHashString"),
    }
}

#[test]
fn pbkdf2_bad_dk_len_helper_returns_invalid_hash_string() {
    let e = api::pbkdf2_bad_dk_len();
    match e {
        Error::InvalidHashString(s) => {
            assert!(s.contains("output length"));
        }
        _ => panic!("expected InvalidHashString"),
    }
}

#[test]
fn bcrypt_requires_utf8_helper_returns_invalid_password() {
    let e = api::bcrypt_requires_utf8();
    match e {
        Error::InvalidPassword(s) => {
            assert!(s.contains("bcrypt"));
            assert!(s.contains("UTF-8"));
        }
        _ => panic!("expected InvalidPassword"),
    }
}

#[test]
fn bcrypt_verify_requires_utf8_helper_returns_invalid_password() {
    let e = api::bcrypt_verify_requires_utf8();
    match e {
        Error::InvalidPassword(s) => {
            assert!(s.contains("verification"));
        }
        _ => panic!("expected InvalidPassword"),
    }
}

#[test]
fn pepper_malformed_prefix_helper_returns_invalid_hash_string() {
    let e = api::pepper_malformed_prefix();
    match e {
        Error::InvalidHashString(s) => {
            assert!(s.contains("pepper"));
        }
        _ => panic!("expected InvalidHashString"),
    }
}

#[test]
fn pepper_keyver_not_int_helper_returns_invalid_hash_string() {
    let e = api::pepper_keyver_not_int();
    match e {
        Error::InvalidHashString(s) => {
            assert!(s.contains("keyver"));
        }
        _ => panic!("expected InvalidHashString"),
    }
}

#[test]
fn phc_not_recognised_helper_returns_invalid_hash_string() {
    let e = api::phc_not_recognised();
    match e {
        Error::InvalidHashString(s) => {
            assert!(s.contains("PHC"));
        }
        _ => panic!("expected InvalidHashString"),
    }
}

#[test]
fn fips_primary_must_be_pbkdf2_helper_includes_primary_name() {
    use hsh::policy::PrimaryAlgorithm;
    let e =
        api::fips_primary_must_be_pbkdf2(PrimaryAlgorithm::Argon2id);
    match e {
        Error::InvalidParameter(s) => {
            assert!(s.contains("Argon2id"));
            assert!(s.contains("FIPS"));
            assert!(s.contains("PBKDF2"));
        }
        _ => panic!("expected InvalidParameter"),
    }
}

#[test]
fn fips_feature_not_built_helper_mentions_fips_feature_flag() {
    let e = api::fips_feature_not_built();
    match e {
        Error::InvalidParameter(s) => {
            assert!(s.contains("fips"));
            assert!(s.contains("feature"));
        }
        _ => panic!("expected InvalidParameter"),
    }
}