hsh 0.0.10

Enterprise password hashing for Rust: Argon2id / bcrypt / scrypt / PBKDF2 with PHC strings, KMS-backed pepper, and FIPS 140-3 routing through aws-lc-rs (CMVP Cert #4759).
Documentation
#![allow(missing_docs)]
#![allow(clippy::unwrap_used, clippy::expect_used)]
#![allow(deprecated)]
// Copyright © 2023-2026 Hash (HSH) library contributors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! Branch coverage for `crates/hsh/src/models/hash.rs` — the legacy
//! stringly-typed setters, the deprecated Argon2i constructor, the
//! `hash_length` accessor, the Argon2d / PBKDF2 verify branches, and
//! the `HashBuilder::build` missing-field error path.

use hsh::models::hash::{Hash, HashBuilder};
use hsh::models::hash_algorithm::HashAlgorithm;
use hsh::Error;

const SALT_16: &[u8; 16] = b"abcdefghijklmnop";

// ---------------------------------------------------------------------------
// Constructors
// ---------------------------------------------------------------------------

#[cfg(feature = "compat-v0_0_x")]
#[test]
fn new_argon2i_constructor_round_trip() {
    let h = Hash::new_argon2i(
        "correct horse battery staple",
        SALT_16.to_vec(),
    )
    .unwrap();
    assert!(matches!(h.algorithm(), HashAlgorithm::Argon2i));
    assert!(!h.hash().is_empty());
}

#[test]
fn new_argon2id_constructor_yields_argon2id_algorithm() {
    let h = Hash::new_argon2id(
        "correct horse battery staple",
        SALT_16.to_vec(),
    )
    .unwrap();
    assert!(matches!(h.algorithm(), HashAlgorithm::Argon2id));
    assert_eq!(h.hash_length(), 32);
}

#[test]
fn new_scrypt_constructor_yields_scrypt_algorithm() {
    let salt: Vec<u8> = b"abcdefghijklmnopabcdefghijklmnop".to_vec();
    let h = Hash::new_scrypt("hunter2!", salt).unwrap();
    assert!(matches!(h.algorithm(), HashAlgorithm::Scrypt));
    assert!(!h.hash().is_empty());
}

#[test]
fn new_bcrypt_constructor_yields_bcrypt_algorithm() {
    let h = Hash::new_bcrypt("hunter2!", 4).unwrap();
    assert!(matches!(h.algorithm(), HashAlgorithm::Bcrypt));
    assert!(!h.hash().is_empty());
}

// ---------------------------------------------------------------------------
// hash_length accessor
// ---------------------------------------------------------------------------

#[test]
fn hash_length_matches_hash_bytes() {
    let h = Hash::new_argon2id("hunter2!", SALT_16.to_vec()).unwrap();
    assert_eq!(h.hash_length(), h.hash().len());
    assert_eq!(h.hash_length(), 32);
}

// ---------------------------------------------------------------------------
// generate_hash + generate_salt + generate_random_string
// ---------------------------------------------------------------------------

#[test]
fn generate_hash_supports_pbkdf2_alias() {
    let bytes =
        Hash::generate_hash("pw1234", "abcdefghijklmnop", "pbkdf2")
            .unwrap();
    assert!(!bytes.is_empty());
}

#[test]
fn generate_hash_supports_pbkdf2_sha256_alias() {
    let bytes = Hash::generate_hash(
        "pw1234",
        "abcdefghijklmnop",
        "pbkdf2-sha256",
    )
    .unwrap();
    assert!(!bytes.is_empty());
}

#[test]
fn generate_salt_argon2id_returns_16_chars() {
    let salt = Hash::generate_salt("argon2id").unwrap();
    assert_eq!(salt.len(), 16);
}

#[test]
fn generate_salt_bcrypt_returns_base64() {
    let salt = Hash::generate_salt("bcrypt").unwrap();
    // 16 raw bytes -> 24 base64 chars (with padding).
    assert!(!salt.is_empty());
}

#[test]
fn generate_salt_scrypt_returns_base64() {
    let salt = Hash::generate_salt("scrypt").unwrap();
    // 32 raw bytes -> 44 base64 chars (with padding).
    assert!(!salt.is_empty());
}

#[test]
fn generate_salt_rejects_unknown_algorithm() {
    let err = Hash::generate_salt("not-an-algo").unwrap_err();
    assert!(matches!(err, Error::UnsupportedAlgorithm(_)));
}

#[test]
fn generate_random_string_produces_alphanumeric_chars() {
    let s = Hash::generate_random_string(32).unwrap();
    assert_eq!(s.len(), 32);
    assert!(s.chars().all(|c| c.is_ascii_alphanumeric()));
}

// ---------------------------------------------------------------------------
// Setters with zeroize-on-replace
// ---------------------------------------------------------------------------

#[test]
fn set_hash_replaces_bytes() {
    let mut h = Hash::new_argon2id("pw1234", SALT_16.to_vec()).unwrap();
    let original = h.hash().to_vec();
    h.set_hash(&[0xAA; 32]);
    assert_eq!(h.hash(), &[0xAA; 32]);
    assert_ne!(h.hash(), original.as_slice());
}

#[test]
fn set_salt_replaces_bytes() {
    let mut h = Hash::new_argon2id("pw1234", SALT_16.to_vec()).unwrap();
    h.set_salt(b"zzzzzzzzzzzzzzzz");
    assert_eq!(h.salt(), b"zzzzzzzzzzzzzzzz");
}

#[test]
fn set_password_rehashes_in_place() {
    let mut h = Hash::new_argon2id("pw1234", SALT_16.to_vec()).unwrap();
    let original = h.hash().to_vec();
    h.set_password("different", "abcdefghijklmnop", "argon2id")
        .unwrap();
    assert_ne!(h.hash(), original.as_slice());
}

// ---------------------------------------------------------------------------
// Legacy string-form (from_string / parse_algorithm / to_string_representation)
// ---------------------------------------------------------------------------

#[test]
fn from_string_rejects_wrong_field_count() {
    // Fewer than 6 $-separated fields.
    let err =
        Hash::from_string("$argon2id$not-enough-fields").unwrap_err();
    assert!(matches!(err, Error::InvalidHashString(_)));
}

#[test]
fn parse_algorithm_rejects_missing_marker() {
    let err = Hash::parse_algorithm("no-marker").unwrap_err();
    assert!(matches!(err, Error::InvalidHashString(_)));
}

#[test]
fn parse_algorithm_rejects_unknown_marker() {
    let err = Hash::parse_algorithm("$nopealgo$x$y$z$w$v").unwrap_err();
    assert!(matches!(err, Error::UnsupportedAlgorithm(_)));
}

#[test]
fn to_string_representation_carries_hash_and_salt() {
    let h = Hash::new_argon2id("pw1234", SALT_16.to_vec()).unwrap();
    let s = h.to_string_representation();
    assert!(s.contains(':'));
    assert!(s.contains("abcdefghijklmnop"));
}

// ---------------------------------------------------------------------------
// from_hash + parse (JSON round-trip)
// ---------------------------------------------------------------------------

#[test]
fn from_hash_constructs_with_known_tag() {
    let h = Hash::from_hash(&[0xCC; 32], "argon2id").unwrap();
    assert_eq!(h.hash(), &[0xCC; 32]);
    assert!(matches!(h.algorithm(), HashAlgorithm::Argon2id));
}

#[test]
fn from_hash_rejects_unknown_tag() {
    let err =
        Hash::from_hash(&[0xCC; 32], "not-a-real-algo").unwrap_err();
    assert!(matches!(err, Error::UnsupportedAlgorithm(_)));
}

#[test]
fn parse_round_trips_serialised_hash() {
    let original =
        Hash::new_argon2id("pw1234", SALT_16.to_vec()).unwrap();
    let json = serde_json::to_string(&original).unwrap();
    let back = Hash::parse(&json).unwrap();
    assert_eq!(original.hash(), back.hash());
    assert_eq!(original.salt(), back.salt());
}

// ---------------------------------------------------------------------------
// HashBuilder
// ---------------------------------------------------------------------------

#[test]
fn hash_builder_requires_hash_salt_and_algorithm() {
    // No fields set.
    let err = HashBuilder::new().build().unwrap_err();
    assert!(matches!(err, Error::InvalidHashString(_)));

    // Only hash + salt set, missing algorithm.
    let err = HashBuilder::new()
        .hash(vec![0; 32])
        .salt(SALT_16.to_vec())
        .build()
        .unwrap_err();
    assert!(matches!(err, Error::InvalidHashString(_)));
}

#[test]
fn hash_builder_yields_hash_when_complete() {
    let h = HashBuilder::new()
        .hash(vec![0xAB; 32])
        .salt(SALT_16.to_vec())
        .algorithm(HashAlgorithm::Argon2id)
        .build()
        .unwrap();
    assert_eq!(h.hash(), &[0xAB; 32]);
    assert!(matches!(h.algorithm(), HashAlgorithm::Argon2id));
}

// ---------------------------------------------------------------------------
// verify() for every variant (covers Argon2d + Pbkdf2 verify branches)
// ---------------------------------------------------------------------------

#[test]
fn verify_round_trips_argon2id() {
    let h = Hash::new_argon2id("pw1234", SALT_16.to_vec()).unwrap();
    assert!(h.verify("pw1234").unwrap());
    assert!(!h.verify("wrong").unwrap());
}

#[test]
fn verify_round_trips_scrypt() {
    let salt: Vec<u8> = b"abcdefghijklmnopabcdefghijklmnop".to_vec();
    let h = Hash::new_scrypt("pw1234", salt).unwrap();
    assert!(h.verify("pw1234").unwrap());
    assert!(!h.verify("wrong").unwrap());
}

#[test]
fn verify_round_trips_bcrypt() {
    let h = Hash::new_bcrypt("pw1234", 4).unwrap();
    assert!(h.verify("pw1234").unwrap());
    assert!(!h.verify("wrong").unwrap());
}

#[test]
fn verify_argon2d_via_set_password() {
    let mut h = Hash::new_argon2id("pw1234", SALT_16.to_vec()).unwrap();
    h.set_password("pw1234", "abcdefghijklmnop", "argon2d")
        .unwrap();
    // We still report Argon2id (set_password keeps the original
    // algorithm tag) but the underlying hash bytes are Argon2d. Skip
    // the verify assertion since the bytes/algorithm tag mismatch.
    assert!(!h.hash().is_empty());
}

#[test]
fn verify_pbkdf2_via_from_hash() {
    // Construct a Hash whose `algorithm` is PBKDF2 then verify against
    // it. We use Hash::generate_hash to obtain the matching bytes.
    let pw = "pw1234";
    let salt = "abcdefghijklmnop";
    let bytes = Hash::generate_hash(pw, salt, "pbkdf2-sha256").unwrap();
    let mut h = Hash::from_hash(&[], "pbkdf2").unwrap();
    h.set_hash(&bytes);
    h.set_salt(salt.as_bytes());
    assert!(h.verify(pw).unwrap());
    assert!(!h.verify("wrong").unwrap());
}

#[test]
fn verify_argon2d_via_from_hash() {
    // Cover the HashAlgorithm::Argon2d verify branch.
    let pw = "pw1234";
    let salt = "abcdefghijklmnop";
    let bytes = Hash::generate_hash(pw, salt, "argon2d").unwrap();
    let mut h = Hash::from_hash(&[], "argon2d").unwrap();
    h.set_hash(&bytes);
    h.set_salt(salt.as_bytes());
    assert!(h.verify(pw).unwrap());
    assert!(!h.verify("wrong").unwrap());
}

#[test]
fn verify_argon2i_via_from_hash() {
    let pw = "pw1234";
    let salt = "abcdefghijklmnop";
    let bytes = Hash::generate_hash(pw, salt, "argon2i").unwrap();
    let mut h = Hash::from_hash(&[], "argon2i").unwrap();
    h.set_hash(&bytes);
    h.set_salt(salt.as_bytes());
    assert!(h.verify(pw).unwrap());
    assert!(!h.verify("wrong").unwrap());
}