/* * test_identity.cpp — Tests for antheos::id and antheos::SidPool * * Verifies base-32 encoding, BID/SID generation, and SID rotation * per Antheos Protocol v9 §5.4-5.6, §11.2. * * Copyright (c) 2025-2026 Are Bjørby * SPDX-License-Identifier: MIT */ #include "test_common.hpp" #include "antheos.hpp" #include #include #include using namespace antheos; /* ── Helper: check if string contains only valid base-32 characters ── */ static bool is_valid_base32(std::string_view s) { for (char c : s) { if (std::strchr(id::BASE32_ALPHABET, c) == nullptr) return false; } return true; } /* ── Test entropy — deterministic bytes for repeatable tests ── */ static const uint8_t ENTROPY_A[] = {0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x12, 0x34}; static const uint8_t ENTROPY_B[] = {0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC}; static const uint8_t ENTROPY_C[] = {0xFF, 0x00, 0xAA, 0x55, 0xCC, 0x33, 0x99, 0x66, 0x11, 0x88}; /* ── 1. BID generation — valid base-32 string ── */ static void test_bid_generate_valid() { auto bid = id::bid_generate(8, ENTROPY_A, sizeof(ENTROPY_A)); ASSERT_TRUE(bid.has_value()); ASSERT_EQ(bid->size(), 8u); ASSERT_TRUE(is_valid_base32(*bid)); } /* ── 2. BID length — different lengths (4, 8, 16) ── */ static void test_bid_length() { auto b4 = id::bid_generate(4, ENTROPY_A, sizeof(ENTROPY_A)); ASSERT_TRUE(b4.has_value()); ASSERT_EQ(b4->size(), 4u); auto b8 = id::bid_generate(8, ENTROPY_A, sizeof(ENTROPY_A)); ASSERT_TRUE(b8.has_value()); ASSERT_EQ(b8->size(), 8u); auto b16 = id::bid_generate(16, ENTROPY_A, sizeof(ENTROPY_A)); ASSERT_TRUE(b16.has_value()); ASSERT_EQ(b16->size(), 16u); } /* ── 3. BID varies with entropy — different entropy produces different BIDs ── */ static void test_bid_varies_with_entropy() { auto a = id::bid_generate(8, ENTROPY_A, sizeof(ENTROPY_A)); auto b = id::bid_generate(8, ENTROPY_B, sizeof(ENTROPY_B)); auto c = id::bid_generate(8, ENTROPY_C, sizeof(ENTROPY_C)); ASSERT_TRUE(a.has_value()); ASSERT_TRUE(b.has_value()); ASSERT_TRUE(c.has_value()); ASSERT_TRUE(*a != *b); ASSERT_TRUE(*a != *c); ASSERT_TRUE(*b != *c); } /* ── 4. BID determinism — same entropy produces same BID ── */ static void test_bid_determinism() { auto a1 = id::bid_generate(8, ENTROPY_A, sizeof(ENTROPY_A)); auto a2 = id::bid_generate(8, ENTROPY_A, sizeof(ENTROPY_A)); ASSERT_TRUE(a1.has_value()); ASSERT_TRUE(a2.has_value()); ASSERT_TRUE(*a1 == *a2); } /* ── 5. BID entropy_needed helper ── */ static void test_bid_entropy_needed() { ASSERT_EQ(id::bid_entropy_needed(2), 2u); /* (2*5+7)/8 = 2 */ ASSERT_EQ(id::bid_entropy_needed(4), 3u); /* (4*5+7)/8 = 3 */ ASSERT_EQ(id::bid_entropy_needed(8), 5u); /* (8*5+7)/8 = 5 */ ASSERT_EQ(id::bid_entropy_needed(16), 10u); /* (16*5+7)/8 = 10 */ } /* ── 6. BID rejects insufficient entropy ── */ static void test_bid_insufficient_entropy() { uint8_t tiny[2] = {0x01, 0x02}; ASSERT_TRUE(!id::bid_generate(8, tiny, sizeof(tiny)).has_value()); ASSERT_TRUE(!id::bid_generate(8, nullptr, 0).has_value()); } /* ── 6b. BID self-contained — internal entropy, non-deterministic ── */ static void test_bid_self_contained() { auto b1 = id::bid_generate(8); auto b2 = id::bid_generate(8); ASSERT_TRUE(b1.has_value()); ASSERT_TRUE(b2.has_value()); ASSERT_EQ(b1->size(), 8u); ASSERT_TRUE(is_valid_base32(*b1)); ASSERT_TRUE(*b1 != *b2); } /* ── 7. SID generation — valid base-32 SID of expected length ── */ static void test_sid_generate_valid() { auto sid = id::sid_generate("org1", "Thermo", "SN00482", 0, 8); ASSERT_TRUE(sid.has_value()); ASSERT_EQ(sid->size(), 8u); ASSERT_TRUE(is_valid_base32(*sid)); } /* ── 8. SID determinism — same inputs + same entropy → same SID ── */ static void test_sid_determinism_with_entropy() { uint8_t ent[8] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; auto sid1 = id::sid_generate("org1", "Thermo", "SN00482", 0, 8, ent, 8); auto sid2 = id::sid_generate("org1", "Thermo", "SN00482", 0, 8, ent, 8); ASSERT_TRUE(sid1.has_value()); ASSERT_TRUE(sid2.has_value()); ASSERT_TRUE(*sid1 == *sid2); /* Different counter still differs */ auto sid3 = id::sid_generate("org1", "Thermo", "SN00482", 1, 8, ent, 8); ASSERT_TRUE(sid3.has_value()); ASSERT_TRUE(*sid1 != *sid3); } /* ── 8b. SID entropy — different entropy → different SID ── */ static void test_sid_entropy_changes_output() { uint8_t ent_a[8] = {0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE}; uint8_t ent_b[8] = {0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF}; auto sid_a = id::sid_generate("org1", "Thermo", "SN00482", 0, 8, ent_a, 8); auto sid_b = id::sid_generate("org1", "Thermo", "SN00482", 0, 8, ent_b, 8); ASSERT_TRUE(sid_a.has_value()); ASSERT_TRUE(sid_b.has_value()); ASSERT_TRUE(*sid_a != *sid_b); } /* ── 8c. SID without entropy — backward compatible (no entropy = deterministic) ── */ static void test_sid_no_entropy_deterministic() { auto sid1 = id::sid_generate("org1", "Thermo", "SN00482", 0, 8); auto sid2 = id::sid_generate("org1", "Thermo", "SN00482", 0, 8); ASSERT_TRUE(sid1.has_value()); ASSERT_TRUE(sid2.has_value()); ASSERT_TRUE(*sid1 == *sid2); } /* ── 8d. SidPool non-determinism — same identity, different SIDs each run ── */ static void test_sid_pool_non_deterministic() { /* Entropy-driven non-determinism is the property under test — exactly * what acquire_unchecked() provides via /dev/urandom. acquire_unique() * would mask the entropy with the active-set check; _unchecked is the * honest call here. */ SidPool pool_a("org1", "Thermo", "SN00482"); SidPool pool_b("org1", "Thermo", "SN00482"); auto sid_a = pool_a.acquire_unchecked(); auto sid_b = pool_b.acquire_unchecked(); ASSERT_TRUE(sid_a.has_value()); ASSERT_TRUE(sid_b.has_value()); ASSERT_TRUE(*sid_a != *sid_b); } /* ── 9. SID pool init ── */ static void test_sid_pool_init_basic() { /* Basic-construct test — uniqueness not under assertion. */ SidPool pool("org1", "Thermo", "SN00482"); auto sid = pool.acquire_unchecked(); ASSERT_TRUE(sid.has_value()); ASSERT_TRUE(sid->size() >= id::SID_MIN_LEN); } /* ── 10. SID pool acquire — valid non-empty string ── */ static void test_sid_pool_acquire() { /* Encoding-validity test — uniqueness not under assertion. */ SidPool pool("org1", "Thermo", "SN00482"); auto sid = pool.acquire_unchecked(); ASSERT_TRUE(sid.has_value()); ASSERT_TRUE(sid->size() >= id::SID_MIN_LEN); ASSERT_TRUE(is_valid_base32(*sid)); } /* ── 11. SID pool acquire multiple — all unique via acquire_unique ── */ static void test_sid_pool_acquire_multiple() { SidPool pool("org1", "Thermo", "SN00482"); std::string sids[10]; for (int i = 0; i < 10; i++) { auto sid = pool.acquire_unique([&](std::string_view candidate) -> bool { for (int j = 0; j < i; j++) { if (sids[j] == candidate) return true; } return false; }); ASSERT_TRUE(sid.has_value()); sids[i] = *sid; } for (int i = 0; i < 10; i++) { for (int j = i + 1; j < 10; j++) { ASSERT_TRUE(sids[i] != sids[j]); } } } /* ── 12. SID rotation — consecutive acquires produce different SIDs ── */ static void test_sid_rotation_fresh() { /* Counter-increment-plus-entropy property — consecutive _unchecked * acquires must produce different SIDs by construction (counter * differs, entropy differs). The spec uniqueness guarantee is * covered by test_sid_rotation_no_reuse + _unique_never_repeats. */ SidPool pool("org1", "Thermo", "SN00482"); auto sid1 = pool.acquire_unchecked(); auto sid2 = pool.acquire_unchecked(); ASSERT_TRUE(sid1.has_value()); ASSERT_TRUE(sid2.has_value()); ASSERT_TRUE(*sid1 != *sid2); } /* ── 13. SID rotation — many acquires all unique (no reuse) ── */ static void test_sid_rotation_no_reuse() { /* Spec §11.2: SIDs are never reused. Uses acquire_unique() with an * active-set callback — the spec-conformant path. Larger N than * test_sid_rotation_unique_never_repeats (32 vs 20) to exercise the * algorithm over more iterations. */ SidPool pool("org1", "Thermo", "SN00482"); std::string sids[32]; for (int i = 0; i < 32; i++) { auto s = pool.acquire_unique([&](std::string_view candidate) -> bool { for (int j = 0; j < i; j++) { if (sids[j] == candidate) return true; } return false; }); ASSERT_TRUE(s.has_value()); sids[i] = *s; } for (int i = 0; i < 32; i++) { for (int j = i + 1; j < 32; j++) { ASSERT_TRUE(sids[i] != sids[j]); } } } /* ── 14. SID rotation — acquire_unique never repeats ── */ static void test_sid_rotation_unique_never_repeats() { SidPool pool("org1", "Thermo", "SN00482"); std::string all[20]; for (int i = 0; i < 20; i++) { auto sid = pool.acquire_unique([&](std::string_view candidate) -> bool { for (int j = 0; j < i; j++) { if (all[j] == candidate) return true; } return false; }); ASSERT_TRUE(sid.has_value()); all[i] = *sid; } for (int i = 0; i < 20; i++) { for (int j = i + 1; j < 20; j++) { ASSERT_TRUE(all[i] != all[j]); } } } /* ── 15. Invalid argument handling ── */ static void test_invalid_arguments() { ASSERT_TRUE(!id::bid_generate(0, ENTROPY_A, sizeof(ENTROPY_A)).has_value()); ASSERT_TRUE(!id::bid_generate(20, ENTROPY_A, sizeof(ENTROPY_A)).has_value()); ASSERT_TRUE(!id::sid_generate("", "d", "i", 0, 8).has_value()); ASSERT_TRUE(!id::sid_generate("o", "", "i", 0, 8).has_value()); ASSERT_TRUE(!id::sid_generate("o", "d", "", 0, 8).has_value()); ASSERT_TRUE(!id::sid_generate("o", "d", "i", 0, 2).has_value()); ASSERT_TRUE(!id::base32_encode(nullptr, 1).has_value()); } /* ── 16. SID_MIN_LEN floor (v1.0.5) — len<6 rejected, len=6 accepted ── * Below 6, base-32 truncation of the hash is collision-prone (32^4 ≈ 1M; at * len=4 a big-endian byte order would collide ~82% vs ~9% for the little-endian * order actually used). The floor keeps generation in the safe regime; this * test fails if the floor is ever lowered back into it. */ static void test_sid_min_len_floor() { ASSERT_EQ(id::SID_MIN_LEN, 6u); ASSERT_TRUE(!id::sid_generate("o", "d", "i", 0, 5).has_value()); /* below floor */ ASSERT_TRUE( id::sid_generate("o", "d", "i", 0, 6).has_value()); /* at floor */ } /* ── 17. SID distribution headroom at the floor (v1.0.5) ── * The little-endian FNV truncation is near-uniform at SID_MIN_LEN: 200k * deterministic SIDs collide ~0 times over the 32^6 ≈ 1.07e9 space. Guards * against a catastrophic distribution regression at the operating length. */ static void test_sid_distribution_headroom() { const uint32_t N = 200000; std::unordered_set seen; seen.reserve(N * 2); uint32_t collisions = 0; bool all_generated = true; for (uint32_t c = 0; c < N; c++) { auto s = id::sid_generate("org1", "Thermo", "SN00482", c, id::SID_MIN_LEN); if (!s) { all_generated = false; break; } if (!seen.insert(*s).second) collisions++; } ASSERT_TRUE(all_generated); ASSERT_TRUE(collisions < N / 100); /* LE: 0; a catastrophic regression: thousands */ } /* ── Suite entry point ── */ void test_identity_run(int& out_run, int& out_passed) { std::printf("\n[identity]\n"); TEST(test_bid_generate_valid); TEST(test_bid_length); TEST(test_bid_varies_with_entropy); TEST(test_bid_determinism); TEST(test_bid_entropy_needed); TEST(test_bid_insufficient_entropy); TEST(test_bid_self_contained); TEST(test_sid_generate_valid); TEST(test_sid_determinism_with_entropy); TEST(test_sid_entropy_changes_output); TEST(test_sid_no_entropy_deterministic); TEST(test_sid_pool_non_deterministic); TEST(test_sid_pool_init_basic); TEST(test_sid_pool_acquire); TEST(test_sid_pool_acquire_multiple); TEST(test_sid_rotation_fresh); TEST(test_sid_rotation_no_reuse); TEST(test_sid_rotation_unique_never_repeats); TEST(test_invalid_arguments); TEST(test_sid_min_len_floor); TEST(test_sid_distribution_headroom); out_run = tests_run; out_passed = tests_passed; }