OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / test / test_protomux_gc.cpp

91 lines · cpp ref:
// Protomux ProtocolInfo lifetime — the reject path frees an info twice.
//
// Build: SAN=address ./build_tests.sh && ./test_protomux_gc
// (glibc catches it too, but a sanitizer names the two frees.)
//
// WHY THIS EXISTS. Protomux is a port of holepunchto/protomux v3.10.1, and JS
// `_gc(info)` only DELETES THE MAP ENTRIES — the object survives until nothing
// references it, so JS may call `_gc` on the same info twice and does:
//
//     if (session !== null) session._close(true)   // Session#_close -> this._mux._gc(this._info)
//     this._gc(info)                               // ... and again, right here
//
// The port made `_gc` `delete info`, and kept both calls. So a reject frees the
// info inside _close() and then walks it again — a use-after-free read, and a
// second delete of the same block. It reaches the game whenever a peer opens a
// channel the other side has no handler for, which is exactly what happens when
// a peer joins a mesh before its overlay is attached: every already-meshed peer
// gets a reject back and the process dies in an allocator, far from here.
//
// Two muxes wired to each other, one channel for a protocol only one side knows.
// No sockets, no threads, no DHT — the reject is provoked by the protocol layer
// itself, which is where the bug lives.

#include "protomux.h"

#include <cstdio>
#include <string>
#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);
	}
}

int main() {
	printf("=== Protomux ProtocolInfo lifetime ===\n");

	Protomux a, b;
	a.set_write_callback([&b](const uint8_t *d, size_t n) { b.recv(d, n); return true; });
	b.set_write_callback([&a](const uint8_t *d, size_t n) { a.recv(d, n); return true; });

	// B answers for this one. A pair callback is how a real consumer accepts a
	// protocol it did not open first — the overlay attaches exactly this way —
	// and it is what makes the accepted case distinguishable from the rejected
	// one in a loopback, where the two Opens cannot be in flight at once.
	ProtomuxChannel *b_known = nullptr;
	b.pair("known/1", [&b, &b_known](const std::string &proto, const std::vector<uint8_t> &id) {
		b_known = b.create_channel(proto, id, {}, true);
		if (b_known) b_known->open();
	});

	ProtomuxChannel *a_known = a.create_channel("known/1", {}, {}, true);
	check("A creates the agreed channel", a_known != nullptr);
	a_known->open();
	check("B paired and answered", b_known != nullptr);
	check("the agreed channel opens on A", a_known->opened);

	// And this one B has never heard of, with no pair callback to catch it — so B
	// rejects, and A processes that reject. THIS is the line under test.
	bool a_closed = false;
	ProtomuxChannel *a_orphan = a.create_channel("nobody-has-this/1", {}, {}, true);
	check("A creates the unmatched channel", a_orphan != nullptr);
	a_orphan->on_close = [&a_closed](bool) { a_closed = true; };
	a_orphan->open();

	check("A saw the channel rejected", a_closed);

	// Survival is the assertion. If _gc double-freed, we are already dead — the
	// allocator aborts inside the reject, before this line.
	check("the mux still works after the reject", !a.opened("nobody-has-this/1", {}));
	check("the agreed channel is unaffected", a.opened("known/1", {}));

	// A second reject for a second unknown protocol: the free-list and the info
	// map must both still be sane after the first one.
	ProtomuxChannel *a_orphan2 = a.create_channel("nobody-has-this/2", {}, {}, true);
	check("A creates a second unmatched channel", a_orphan2 != nullptr);
	a_orphan2->open();
	check("survives a second reject", !a.opened("nobody-has-this/2", {}));

	printf("\n=== %d/%d passed ===\n", tests_passed, tests_run);
	return tests_passed == tests_run ? 0 : 1;
}