test_subdb_identity.cpp 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. // v2.4.4 — sub-db identity sentinel tests.
  2. //
  3. // Regression cover for the production incident in which an invalidated
  4. // MDB_dbi was reused after LMDB reassigned its slot, so writes aimed at
  5. // `image_hashes` landed in `executions` and succeeded silently. 31 documents
  6. // across smartbotic-automation ended up in a sub-db other than the one they
  7. // declared. See storage/subdb_identity.hpp for the full mechanism.
  8. //
  9. // The core test is `misbound_handle_is_refused`: it reproduces the misbinding
  10. // directly by handing the verifier a handle for a different sub-db, which is
  11. // what a stale cache entry amounts to.
  12. #include <cassert>
  13. #include <atomic>
  14. #include <filesystem>
  15. #include <iostream>
  16. #include <string>
  17. #include <unistd.h>
  18. #include <lmdb.h>
  19. #include <nlohmann/json.hpp>
  20. #include "document.hpp"
  21. #include "storage/document_store_lmdb.hpp"
  22. #include "storage/lmdb_env.hpp"
  23. #include "storage/lmdb_txn.hpp"
  24. #include "storage/subdb_identity.hpp"
  25. namespace fs = std::filesystem;
  26. using smartbotic::database::Document;
  27. using smartbotic::db::storage::is_identity_key;
  28. using smartbotic::db::storage::kSubdbIdentityKey;
  29. using smartbotic::db::storage::LmdbDocumentStore;
  30. using smartbotic::db::storage::LmdbEnv;
  31. using smartbotic::db::storage::LmdbEnvOpts;
  32. using smartbotic::db::storage::read_subdb_identity;
  33. using smartbotic::db::storage::ReadTxn;
  34. using smartbotic::db::storage::verify_subdb_identity;
  35. using smartbotic::db::storage::write_subdb_identity;
  36. using smartbotic::db::storage::WriteTxn;
  37. namespace {
  38. int g_pass = 0;
  39. int g_fail = 0;
  40. void check(bool cond, const char* msg) {
  41. if (cond) {
  42. ++g_pass;
  43. } else {
  44. ++g_fail;
  45. std::cerr << "FAIL: " << msg << "\n";
  46. }
  47. }
  48. std::string make_tmpdir(const char* tag) {
  49. static std::atomic<int> counter{0};
  50. std::string path = "/tmp/subdb-identity-test-" + std::to_string(::getpid()) +
  51. "-" + std::to_string(counter.fetch_add(1)) + "-" + tag;
  52. std::error_code ec;
  53. fs::remove_all(path, ec);
  54. return path;
  55. }
  56. struct TmpEnv {
  57. std::string path;
  58. LmdbEnv env;
  59. explicit TmpEnv(const char* tag)
  60. : path(make_tmpdir(tag)),
  61. env(LmdbEnvOpts{path, 64ULL << 20, 256, 126, false}) {}
  62. ~TmpEnv() {
  63. std::error_code ec;
  64. fs::remove_all(path, ec);
  65. }
  66. TmpEnv(const TmpEnv&) = delete;
  67. TmpEnv& operator=(const TmpEnv&) = delete;
  68. };
  69. // Open (creating) a named sub-db inside a write txn and return its handle.
  70. unsigned int open_subdb(WriteTxn& txn, const char* name) {
  71. MDB_dbi dbi = 0;
  72. int rc = mdb_dbi_open(txn.raw(), name, MDB_CREATE, &dbi);
  73. assert(rc == MDB_SUCCESS);
  74. (void)rc;
  75. return dbi;
  76. }
  77. Document make_doc(const std::string& id, const std::string& collection) {
  78. Document d;
  79. d.id = id;
  80. d.collection = collection;
  81. d.set_data(nlohmann::json{{"seenCount", 1}, {"who", collection}});
  82. return d;
  83. }
  84. // -------------------------------------------------------------------------
  85. void test_sentinel_roundtrip() {
  86. TmpEnv t("roundtrip");
  87. {
  88. WriteTxn w(t.env);
  89. unsigned int dbi = open_subdb(w, "image_hashes");
  90. write_subdb_identity(w, dbi, "image_hashes");
  91. w.commit();
  92. }
  93. {
  94. WriteTxn w(t.env);
  95. unsigned int dbi = open_subdb(w, "image_hashes");
  96. bool threw = false;
  97. try {
  98. verify_subdb_identity(w, dbi, "image_hashes");
  99. } catch (const std::exception&) {
  100. threw = true;
  101. }
  102. check(!threw, "matching sentinel must verify without throwing");
  103. w.commit();
  104. }
  105. {
  106. ReadTxn r(t.env);
  107. MDB_dbi dbi = 0;
  108. mdb_dbi_open(r.raw(), "image_hashes", 0, &dbi);
  109. check(read_subdb_identity(r, dbi) == "image_hashes",
  110. "read_subdb_identity returns the stamped name");
  111. }
  112. }
  113. // THE regression test. A stale cache entry is, in effect, a handle that
  114. // addresses someone else's sub-db. Hand the verifier exactly that.
  115. void test_misbound_handle_is_refused() {
  116. TmpEnv t("misbound");
  117. unsigned int executions_dbi = 0;
  118. {
  119. WriteTxn w(t.env);
  120. unsigned int ih = open_subdb(w, "image_hashes");
  121. write_subdb_identity(w, ih, "image_hashes");
  122. executions_dbi = open_subdb(w, "executions");
  123. write_subdb_identity(w, executions_dbi, "executions");
  124. w.commit();
  125. }
  126. WriteTxn w(t.env);
  127. // Re-open so the handle is valid in this txn, then deliberately verify it
  128. // under the WRONG name — the production misbinding, reproduced.
  129. unsigned int exec = open_subdb(w, "executions");
  130. bool threw = false;
  131. std::string msg;
  132. try {
  133. verify_subdb_identity(w, exec, "image_hashes");
  134. } catch (const std::exception& e) {
  135. threw = true;
  136. msg = e.what();
  137. }
  138. check(threw, "handle for 'executions' verified as 'image_hashes' must throw");
  139. check(msg.find("image_hashes") != std::string::npos &&
  140. msg.find("executions") != std::string::npos,
  141. "misbinding error names both the requested and actual sub-db");
  142. w.abort();
  143. }
  144. // Existing deployments have sub-dbs with no sentinel. Those must keep working.
  145. void test_unstamped_subdb_is_permitted() {
  146. TmpEnv t("unstamped");
  147. WriteTxn w(t.env);
  148. unsigned int dbi = open_subdb(w, "legacy");
  149. bool threw = false;
  150. try {
  151. verify_subdb_identity(w, dbi, "legacy");
  152. } catch (const std::exception&) {
  153. threw = true;
  154. }
  155. check(!threw, "sub-db without a sentinel must verify (absence is unknown, not wrong)");
  156. w.commit();
  157. }
  158. void test_identity_key_predicate() {
  159. check(is_identity_key(kSubdbIdentityKey), "sentinel key recognised");
  160. check(!is_identity_key("__subdb_identity__"),
  161. "same text without the leading NUL is NOT the sentinel");
  162. check(!is_identity_key("e63c1b90"), "a document id is not the sentinel");
  163. check(kSubdbIdentityKey[0] == '\0',
  164. "sentinel must start with NUL so it cannot collide with a doc id");
  165. }
  166. // The sentinel is an implementation detail: it must never surface through the
  167. // DocumentStore API as a document, nor inflate a count.
  168. void test_sentinel_invisible_through_store() {
  169. TmpEnv t("invisible");
  170. LmdbDocumentStore store(t.env);
  171. store.put("image_hashes", "aaa", make_doc("aaa", "image_hashes"));
  172. store.put("image_hashes", "bbb", make_doc("bbb", "image_hashes"));
  173. check(store.count("image_hashes") == 2,
  174. "count() must exclude the identity sentinel");
  175. smartbotic::database::Query q;
  176. q.limit = 100;
  177. auto res = store.scan("image_hashes", q);
  178. check(res.documents.size() == 2, "scan() must exclude the identity sentinel");
  179. check(res.total_matched == 2, "scan() total_matched must exclude the sentinel");
  180. for (const auto& d : res.documents) {
  181. check(d.id == "aaa" || d.id == "bbb",
  182. "scan() must not surface the sentinel as a document");
  183. }
  184. // And it really is on disk.
  185. ReadTxn r(t.env);
  186. MDB_dbi dbi = 0;
  187. int rc = mdb_dbi_open(r.raw(), "image_hashes", 0, &dbi);
  188. check(rc == MDB_SUCCESS, "sub-db exists");
  189. check(read_subdb_identity(r, dbi) == "image_hashes",
  190. "store.put() stamps the sentinel on first write");
  191. }
  192. // A vector sub-db gets stamped with its own (prefixed) name, and scan_vectors
  193. // must skip the sentinel rather than trying to read it as float32 bytes.
  194. void test_vector_subdb_sentinel() {
  195. TmpEnv t("vectors");
  196. LmdbDocumentStore store(t.env);
  197. store.put_vector("emb", "v1", {1.0f, 2.0f, 3.0f});
  198. store.put_vector("emb", "v2", {4.0f, 5.0f, 6.0f});
  199. int seen = 0;
  200. bool bad = false;
  201. store.scan_vectors("emb", [&](std::string_view id, const float*, size_t n) {
  202. ++seen;
  203. if (n != 3) bad = true;
  204. if (is_identity_key(id)) bad = true;
  205. });
  206. check(seen == 2, "scan_vectors must skip the sentinel");
  207. check(!bad, "scan_vectors must not decode the sentinel as float data");
  208. ReadTxn r(t.env);
  209. MDB_dbi dbi = 0;
  210. mdb_dbi_open(r.raw(), "_vectors_emb", 0, &dbi);
  211. check(read_subdb_identity(r, dbi) == "_vectors_emb",
  212. "vector sub-db is stamped with its prefixed name");
  213. }
  214. // v2.4.4 Count is LMDB-first. Unfiltered it uses count(); filtered it uses
  215. // scan() with limit=0 and reads total_matched. Pin that contract: total_matched
  216. // is computed BEFORE pagination, so limit=0 must still report the true total
  217. // while returning no documents.
  218. void test_scan_limit_zero_reports_total() {
  219. TmpEnv t("counting");
  220. LmdbDocumentStore store(t.env);
  221. for (int i = 0; i < 5; ++i) {
  222. Document d;
  223. d.id = "id" + std::to_string(i);
  224. d.collection = "things";
  225. d.set_data(nlohmann::json{{"kind", i < 3 ? "alpha" : "beta"}});
  226. store.put("things", d.id, d);
  227. }
  228. smartbotic::database::Query q;
  229. q.limit = 0;
  230. auto all = store.scan("things", q);
  231. check(all.total_matched == 5, "limit=0 reports the full total");
  232. check(all.documents.empty(), "limit=0 returns no documents");
  233. check(store.count("things") == 5, "unfiltered count matches");
  234. smartbotic::database::Query fq;
  235. fq.limit = 0;
  236. smartbotic::database::Filter f;
  237. f.field = "kind";
  238. f.op = smartbotic::database::FilterOp::EQ;
  239. f.value = "alpha";
  240. fq.filters.push_back(f);
  241. auto filtered = store.scan("things", fq);
  242. check(filtered.total_matched == 3,
  243. "filtered limit=0 reports the matching total, not the collection size");
  244. }
  245. } // namespace
  246. int main() {
  247. std::cout << "=== test_subdb_identity ===\n";
  248. test_sentinel_roundtrip();
  249. test_misbound_handle_is_refused();
  250. test_unstamped_subdb_is_permitted();
  251. test_identity_key_predicate();
  252. test_sentinel_invisible_through_store();
  253. test_vector_subdb_sentinel();
  254. test_scan_limit_zero_reports_total();
  255. std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
  256. return g_fail == 0 ? 0 : 1;
  257. }