// Each peer's DHT bootstrap list belongs to that peer.
//
// Build: ./build_tests.sh && ./test_bootstrap_ownership
// (SAN=address is the interesting run: the defect this covers was a read of
// freed memory, and only a sanitizer names it before it becomes a wrong answer.)
//
// WHY THIS EXISTS. start_dht does not use the bootstrap list — it queues
// CMD_DHT_INIT and returns, and _init_dht parses the strings later, on the UDX
// thread. The transport used to keep the caller's `const char **`, and join_dht
// kept the strings alive in vectors that were `static`: one list shared by every
// HyperswarmMultiplayerPeer in the process. A second peer's join_dht cleared and
// refilled them, so the first peer's transport was left pointing into a vector
// that had been emptied and reallocated underneath it — while it was still
// waiting to read it on another thread.
//
// A second defect sat inside the same lines: the pointers were taken in the same
// pass that filled the strings. A vector of SHORT strings stores them inside its
// own element block, so each growth frees the block that every c_str() taken so
// far points into. At one bootstrap node the vector never grows and nothing goes
// wrong, which is exactly what the integration suite passes; at two it is a
// heap-use-after-free.
//
// So the lists here are THREE nodes long, and they are built in a scope that ends
// before anything is asserted: a transport that still needed the caller's memory
// would be reading a dead vector, which is the whole claim.
//
// NOTE ON REDNESS: this gate asserts the ownership contract that the fix
// introduces, and could not have been run against the previous shape — there was
// no per-transport list to ask about, only a borrowed pointer. The defect itself
// was measured separately, as a standalone ASan repro of the same-pass pointer
// capture.
#include "udx_transport.h"
#include <sodium.h>
#include <chrono>
#include <cstdio>
#include <string>
#include <thread>
#include <vector>
static int tests_run = 0;
static int tests_passed = 0;
static void check(const char *name, bool ok) {
tests_run++;
if (ok) {
tests_passed++;
printf(" PASS %s\n", name);
} else {
printf(" FAIL %s\n", name);
}
}
static void sleep_ms(int ms) {
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}
// Start a transport the way a caller does, from buffers that DIE when this
// returns. Unroutable addresses, so nothing leaves the machine.
static void start_with(UdxTransport &t, const std::vector<std::string> &want) {
Ed25519KeyPair kp;
crypto_sign_keypair(kp.public_key, kp.secret_key);
std::vector<std::string> strs(want.begin(), want.end());
std::vector<const char *> ptrs;
ptrs.reserve(strs.size());
for (const std::string &s : strs) ptrs.push_back(s.c_str());
t.start_dht(kp, ptrs.data(), (int)ptrs.size());
}
static bool same(const std::vector<std::string> &a, const std::vector<std::string> &b) {
if (a.size() != b.size()) return false;
for (size_t i = 0; i < a.size(); i++)
if (a[i] != b[i]) return false;
return true;
}
int main() {
printf("=== DHT bootstrap ownership ===\n");
if (sodium_init() < 0) {
printf(" FAIL sodium_init\n");
return 1;
}
// Deliberately not the same list, so "A has B's" is distinguishable from
// "both have the right one". Port 1 answers nothing.
const std::vector<std::string> want_a = {
"127.0.0.1:1", "127.0.0.2:2", "bootstrap-a.invalid.example:49737"};
const std::vector<std::string> want_b = {
"127.0.0.3:3", "127.0.0.4:4", "bootstrap-b.invalid.example:49737"};
UdxTransport a;
UdxTransport b;
start_with(a, want_a);
check("A kept the list after the caller's buffers died", same(a.bootstrap_nodes(), want_a));
// THE SECOND JOIN IS THE TEST. This is what used to reach into the first
// peer's list, because there was only ever one.
start_with(b, want_b);
check("B has its own list", same(b.bootstrap_nodes(), want_b));
check("and A's is untouched by B starting", same(a.bootstrap_nodes(), want_a));
// Churn the heap: anything still borrowing freed memory is now reading
// somebody else's bytes rather than a lucky copy of its own.
{
std::vector<std::string> noise;
for (int i = 0; i < 256; i++) noise.push_back("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
}
check("A still, after the heap was churned", same(a.bootstrap_nodes(), want_a));
check("B still", same(b.bootstrap_nodes(), want_b));
// And the copy is what actually gets used: _init_dht runs on the UDX thread
// and parses this list. If it read freed memory, this is where it would.
sleep_ms(300);
check("A's DHT initialised from it", a.is_dht_active());
check("B's DHT initialised from it", b.is_dht_active());
check("the two bound different ports", a.bound_port() > 0 && b.bound_port() > 0 &&
a.bound_port() != b.bound_port());
a.stop();
b.stop();
check("both stopped clean", !a.is_running() && !b.is_running());
printf("\n=== %d/%d passed ===\n", tests_passed, tests_run);
return tests_passed == tests_run ? 0 : 1;
}