icydb-core 0.266.0

IcyDB — A schema-first typed query engine and persistence runtime for Internet Computer canisters
Documentation
//! Module: value::hash::tests
//! Covers the frozen seed, version, and digest vectors for canonical value
//! hashing.

use super::*;
use crate::{
    types::{Decimal, Float32 as F32, Float64 as F64, Principal, Subaccount},
    value::{Value, ValueEnum},
};

#[test]
fn hash_contract_seed_and_version_are_frozen() {
    assert_eq!(VALUE_HASH_SEED, 0);
    assert_eq!(VALUE_HASH_VERSION, 1);
}

#[test]
fn bigint_hash_streams_preserve_candid_bytes_and_single_list_framing() {
    for bit in [
        0_usize, 6, 7, 31, 32, 63, 64, 447, 448, 449, 895, 896, 897, 16384,
    ] {
        let magnitude = num_bigint::BigUint::from(1_u8) << bit;
        let mut unsigned_bytes = Vec::new();
        candid::Nat::from(magnitude.clone())
            .encode(&mut unsigned_bytes)
            .unwrap();
        let mut cases = vec![(
            Value::NatBig(crate::types::NatBig::from_biguint(magnitude.clone())),
            unsigned_bytes,
        )];
        for integer in [
            num_bigint::BigInt::from(magnitude.clone()),
            -num_bigint::BigInt::from(magnitude),
        ] {
            let mut bytes = Vec::new();
            candid::Int::from(integer.clone())
                .encode(&mut bytes)
                .unwrap();
            cases.push((
                Value::IntBig(crate::types::IntBig::from_bigint(integer)),
                bytes,
            ));
        }
        for (value, bytes) in cases {
            for list in [false, true] {
                let mut expected = Xxh3::with_seed(VALUE_HASH_SEED);
                feed_u8(&mut expected, VALUE_HASH_VERSION);
                if list {
                    feed_u8(&mut expected, ValueTag::List.to_u8());
                    feed_u32(&mut expected, 1);
                    feed_u8(&mut expected, 0xFF);
                }
                feed_u8(&mut expected, value.canonical_tag().to_u8());
                feed_len_u32(&mut expected, bytes.len()).unwrap();
                feed_bytes(&mut expected, &bytes);
                let expected = expected.digest128().to_be_bytes();
                if list {
                    assert_eq!(
                        hash_value(&Value::List(vec![value.clone()])).unwrap(),
                        expected
                    );
                    assert_eq!(
                        hash_single_list_identity_canonical_value(&value).unwrap(),
                        Some(expected)
                    );
                } else {
                    assert_eq!(hash_value(&value).unwrap(), expected);
                }
            }
        }
    }
}

#[test]
fn hash_digest_contract_vectors_are_frozen_for_upgrade_stability() {
    let vectors = vec![
        (
            "null",
            Value::Null,
            0x07d3_310a_0679_d482_1974_aae7_68bf_e723,
        ),
        (
            "empty_list",
            Value::List(Vec::new()),
            0xc961_6022_4099_6188_601a_2e1d_a3d4_577d,
        ),
        (
            "empty_map",
            Value::Map(Vec::new()),
            0xfe53_d0af_a864_e41f_7540_c693_50b9_b8d7,
        ),
        (
            "nat_42",
            Value::Nat64(42),
            0x8c99_03a0_7f2c_731c_2e7a_9cd6_52cb_010f,
        ),
        (
            "int_neg7",
            Value::Int64(-7),
            0x7470_6cc5_9093_df80_0d3b_e517_da6b_0104,
        ),
        (
            "text_alpha",
            Value::Text("alpha".to_string()),
            0x6ec7_96a5_45c2_ad82_58ff_9d4a_4ea8_1c2b,
        ),
        (
            "decimal_1",
            Value::Decimal(Decimal::new(10, 1)),
            0x7d42_1e3f_fffc_9100_0be6_fa20_26b6_0b82,
        ),
        (
            "map_a1_z9",
            Value::Map(vec![
                (Value::Text("a".to_string()), Value::Nat64(1)),
                (Value::Text("z".to_string()), Value::Nat64(9)),
            ]),
            0xea0e_28c9_f878_6d85_c240_88c8_f0d7_9d81,
        ),
        (
            "nested_list_map",
            Value::List(vec![
                Value::Map(vec![
                    (Value::Text("z".to_string()), Value::Nat64(9)),
                    (Value::Text("a".to_string()), Value::Nat64(1)),
                ]),
                Value::Decimal(Decimal::new(25, 0)),
            ]),
            0x65e9_3e05_85b6_72cc_de03_6d05_eaa9_8c57,
        ),
    ];
    for (label, value, expected_digest) in vectors {
        let actual_digest = hash_value(&value).expect("hash value");
        assert_eq!(
            u128::from_be_bytes(actual_digest),
            expected_digest,
            "value-hash digest vector drift for {label}; canonical encoding/seed/version contract changed",
        );
    }
}

fn v_f64(x: f64) -> Value {
    Value::Float64(F64::try_new(x).expect("finite f64"))
}
fn v_f32(x: f32) -> Value {
    Value::Float32(F32::try_new(x).expect("finite f32"))
}
fn v_i(x: i64) -> Value {
    Value::Int64(x)
}
fn v_txt(s: &str) -> Value {
    Value::Text(s.to_string())
}

#[test]
fn hash_is_deterministic_for_int() {
    let v = Value::Int64(42);
    let a = hash_value(&v).expect("hash value");
    let b = hash_value(&v).expect("hash value");
    assert_eq!(a, b, "hash should be deterministic for same value");
}

#[test]
fn different_variants_produce_different_hashes() {
    let a = hash_value(&Value::Int64(5)).expect("hash value");
    let b = hash_value(&Value::Nat64(5)).expect("hash value");
    assert_ne!(
        a, b,
        "Int(5) and Nat(5) must hash differently (different tag)"
    );
}

#[test]
fn enum_hash_tracks_type_identity() {
    let first_type = Value::Enum(ValueEnum::test_unit(1, 1));
    let second_type = Value::Enum(ValueEnum::test_unit(2, 1));
    assert_ne!(
        hash_value(&first_type).expect("hash value"),
        hash_value(&second_type).expect("hash value"),
        "enum hashes must include store-local type identity"
    );
}

#[test]
fn enum_hash_includes_payload() {
    let with_one = Value::Enum(ValueEnum::test_payload(1, 1, Value::Nat64(1)));
    let with_two = Value::Enum(ValueEnum::test_payload(1, 1, Value::Nat64(2)));

    assert_ne!(
        hash_value(&with_one).expect("hash value"),
        hash_value(&with_two).expect("hash value"),
        "Enum payload must influence hash/fingerprint"
    );
}

#[test]
fn float32_and_float64_hash_differ() {
    let a = hash_value(&v_f32(1.0)).expect("hash value");
    let b = hash_value(&v_f64(1.0)).expect("hash value");
    assert_ne!(
        a, b,
        "Float32 and Float64 must hash differently (different tag)"
    );
}

#[test]
fn decimal_hash_normalizes_equivalent_values() {
    let one = Value::Decimal(Decimal::new(1, 0));
    let one_point_zero = Value::Decimal(Decimal::new(10, 1));

    assert_eq!(
        one, one_point_zero,
        "decimal values should be semantically equal"
    );
    assert_eq!(
        hash_value(&one).expect("hash value"),
        hash_value(&one_point_zero).expect("hash value"),
        "equivalent decimals must hash to the same fingerprint"
    );
}

#[test]
fn text_is_length_and_content_sensitive() {
    let a = hash_value(&v_txt("foo")).expect("hash value");
    let b = hash_value(&v_txt("bar")).expect("hash value");
    assert_ne!(a, b, "different strings should hash differently");

    let c = hash_value(&v_txt("foo")).expect("hash value");
    assert_eq!(a, c, "same string should hash the same");
}

#[test]
fn list_hash_is_order_sensitive() {
    let l1 = Value::from_slice(&[v_i(1), v_i(2)]);
    let l2 = Value::from_slice(&[v_i(2), v_i(1)]);
    assert_ne!(
        hash_value(&l1).expect("hash value"),
        hash_value(&l2).expect("hash value"),
        "list order should affect hash"
    );
}

#[test]
fn list_hash_is_length_sensitive() {
    let l1 = Value::from_slice(&[v_i(1)]);
    let l2 = Value::from_slice(&[v_i(1), v_i(1)]);
    assert_ne!(
        hash_value(&l1).expect("hash value"),
        hash_value(&l2).expect("hash value"),
        "list length should affect hash"
    );
}

#[test]
fn list_blob_boundaries_are_length_framed() {
    let left = Value::List(vec![
        Value::Blob(vec![0x10, 0xFF, 0x02, 0x11]),
        Value::Blob(vec![0x12]),
    ]);
    let right = Value::List(vec![
        Value::Blob(vec![0x10]),
        Value::Blob(vec![0x11, 0xFF, 0x02, 0x12]),
    ]);

    assert_ne!(
        hash_value(&left).expect("hash value"),
        hash_value(&right).expect("hash value"),
        "blob boundaries must be length-framed to avoid collisions"
    );
}

#[test]
fn map_hash_is_order_independent_for_non_canonical_construction_order() {
    let left = Value::Map(vec![
        (Value::Text("z".to_string()), Value::Nat64(9)),
        (Value::Text("a".to_string()), Value::Nat64(1)),
    ]);
    let right = Value::Map(vec![
        (Value::Text("a".to_string()), Value::Nat64(1)),
        (Value::Text("z".to_string()), Value::Nat64(9)),
    ]);

    assert_eq!(
        hash_value(&left).expect("hash value"),
        hash_value(&right).expect("hash value"),
        "map hash must be deterministic regardless of insertion order"
    );
}

#[test]
fn single_list_identity_canonical_hash_matches_generic_hash_contract() {
    let values = vec![
        Value::Account(crate::types::Account::from_owner_and_subaccount(
            Principal::from_slice(&[7]),
            Some(Subaccount::from_array([7; 32])),
        )),
        Value::Blob(vec![1u8, 2u8, 3u8]),
        Value::Bool(true),
        Value::Date(crate::types::Date::try_new(2024, 1, 2).expect("valid date")),
        Value::Duration(crate::types::Duration::from_secs(1)),
        Value::Float32(F32::try_new(1.25).expect("finite f32")),
        Value::Float64(F64::try_new(2.5).expect("finite f64")),
        Value::Int64(-7),
        Value::Int128(123i128),
        Value::IntBig(crate::types::IntBig::from(99i32)),
        Value::Principal(crate::types::Principal::from_slice(&[1u8, 2u8, 3u8])),
        Value::Subaccount(crate::types::Subaccount::from_array([1u8; 32])),
        Value::Text("example".to_string()),
        Value::Timestamp(crate::types::Timestamp::from_secs(1)),
        Value::Nat64(7),
        Value::Nat128(9u128),
        Value::NatBig(crate::types::NatBig::from(11u64)),
        Value::Ulid(crate::types::Ulid::from_u128(42)),
    ];

    for value in values {
        let fast = hash_single_list_identity_canonical_value(&value)
            .expect("fast single-list hash should succeed")
            .expect("value should stay on the identity-canonical fast path");
        let generic = hash_value(&Value::List(vec![value]))
            .expect("generic grouped single-list hash should succeed");

        assert_eq!(
            fast, generic,
            "fast single-list identity-canonical hash must match generic grouped hash contract",
        );
    }
}

#[test]
fn cryptographic_fingerprints_stream_current_canonical_bytes() {
    for (value, bytes) in [
        (
            Value::Text("victim".into()),
            b"\x01\x12\x00\x00\x00\x06victim".to_vec(),
        ),
        (
            Value::Blob(vec![0, 1, 255]),
            vec![1, 2, 1, 0, 0, 0, 3, 0, 1, 255],
        ),
        (Value::Null, vec![1, 15]),
        (Value::Unit, vec![1, 24]),
    ] {
        assert_eq!(
            fingerprint_value(&value).unwrap(),
            <[u8; 32]>::from(Sha256::digest(bytes))
        );
    }
    let entries = vec![
        (Value::Text("b".into()), Value::Nat64(2)),
        (Value::Text("a".into()), Value::Nat64(1)),
    ];
    assert_eq!(
        fingerprint_value(&Value::Map(entries.clone())).unwrap(),
        fingerprint_value(&Value::Map(entries.into_iter().rev().collect())).unwrap()
    );
    assert_eq!(
        fingerprint_value(&Value::Decimal(Decimal::new(100, 2))).unwrap(),
        fingerprint_value(&Value::Decimal(Decimal::new(1, 0))).unwrap()
    );
}