Wire -t/--test to Catch2's own argv-driven Session::run()

-t now accepts trailing args (mirroring -r/-x's own trailing-command
capture, ParsedArgs::test_args), forwarded unmodified to Catch2. Bare -t
runs every registered TEST_CASE (currently none); a tag expression after
'--' selects a subset once real tests land, e.g. -t -- "[unit]". The '--'
matters since Catch2's own -r/--reporter and -c/--section collide with
slocker-lite's -r/--run and -c/--cleanup.

Guarded by config.h's ENABLE_TESTS macro (from Meson's existing
enable_tests option, already linking catch2_dep into the binary but never
actually used until now) -- a -Denable_tests=false build prints a clear
message instead of failing to link.

The 3 hand-rolled root-only self-tests this replaced (persistent-netns,
tap-relay, dns-resolver) are being ported to proper tagged TEST_CASEs in
a follow-up commit, not lost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-09-04 09:31:56 +00:00
parent cdc4b75309
commit 48fd221d74
5 changed files with 74 additions and 490 deletions
+20 -3
View File
@@ -133,7 +133,7 @@ void print_usage(const char* prog) {
" {0} --list-processes\n"
" {0} --clean-processes\n"
" {0} -w|--write-config\n"
" {0} -t|--test\n"
" {0} -t|--test [-- <catch-command-line-options>]\n"
" {0} -h|--help\n"
" {0} -V|--version\n"
"\n"
@@ -279,7 +279,20 @@ void print_usage(const char* prog) {
" every option's current or default value --\n"
" useful to bootstrap one for hand-editing.\n"
" Prints the config file's full path\n"
" -t, --test run the test suite\n"
" -t, --test [-- <catch-command-line-options>]\n"
" run the built-in Catch2 test suite. Bare -t\n"
" runs everything; select a category with a tag\n"
" expression, e.g. -t -- \"[unit]\",\n"
" -t -- \"[integration]~[net]\",\n"
" -t -- \"[integration][net]~[root]\",\n"
" -t -- \"[integration][root]\", or filter out\n"
" slow tests with -t -- \"~[slow]\". The '--' is\n"
" needed before any option that looks like one\n"
" of slocker-lite's own (Catch2's -r/--reporter\n"
" collides with -r/--run, for example) -- see\n"
" -t -- --help for Catch2's own full option list\n"
" (not compiled in if built with\n"
" -Denable_tests=false)\n"
" --log-level <level> set log verbosity (trace, debug, info, warn,\n"
" error, critical, off)\n"
" -h, --help print this help and exit\n"
@@ -572,7 +585,7 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
print_usage(argv[0]);
return 1;
}
if (out.mode != Mode::run && out.mode != Mode::exec && optind != argc) {
if (out.mode != Mode::run && out.mode != Mode::exec && out.mode != Mode::test && optind != argc) {
print_usage(argv[0]);
return 1;
}
@@ -586,6 +599,10 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
out.command.assign(argv + optind, argv + argc);
}
if (out.mode == Mode::test) {
out.test_args.assign(argv + optind, argv + argc);
}
if (out.mode == Mode::exec) {
if (out.command.empty()) {
spdlog::error("--exec requires a command to run");
+9
View File
@@ -89,6 +89,15 @@ struct ParsedArgs {
// Trailing argv (after getopt_long stops), populated only for
// Mode::run/Mode::exec -- the command to run, or to run under -x/--exec.
std::vector<std::string> command;
// Trailing argv (after getopt_long stops), populated only for
// Mode::test -- forwarded as-is to Catch2's own Session::run() (see
// self_test.h), not a command to run, hence its own field rather than
// reusing `command` above. A literal '--' before any Catch2 flag that
// looks like one of slocker-lite's own short options (Catch2's own
// -r/--reporter collides with -r/--run, -c/--section with -c/--cleanup)
// is required for the same reason -x/--exec's own trailing command
// needs one for a command starting with '-'.
std::vector<std::string> test_args;
// Parsed and range-validated from mode_arg when mode == Mode::exec.
std::optional<pid_t> exec_pid;
// Parsed and range-validated from mode_arg when mode == Mode::kill.
+1 -1
View File
@@ -891,7 +891,7 @@ int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config
case Mode::clean_processes:
return clean_processes_command();
case Mode::test:
return run_self_tests();
return run_self_tests(args.test_args);
case Mode::exec:
return exec_in_session(*args.exec_pid, args.command, args.user_flag, args.group_flag);
case Mode::kill:
+28 -479
View File
@@ -16,489 +16,38 @@
#include "self_test.h"
#include <sched.h>
#include <signal.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include <cerrno>
#include <string>
#include "config.h"
#if ENABLE_TESTS
#include <catch2/catch_session.hpp>
#else
#include <fmt/core.h>
#include <spdlog/spdlog.h>
#endif
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/time.h>
int run_self_tests(const std::vector<std::string>& args) {
#if ENABLE_TESTS
// args[0] is conventionally the program name in Catch2's eyes (used in
// some of its own diagnostic/help output) -- it never sees the real
// argv[0], since ParsedArgs::test_args only ever holds what came after
// -t/--test on the command line (see cli_args.cpp). The vector<string>
// is kept alive for the duration of the call so the char* vector handed
// to Catch2 stays valid.
std::vector<std::string> owned_args = {"slocker-lite -t"};
owned_args.insert(owned_args.end(), args.begin(), args.end());
#include <cstdint>
#include <cstdio>
#include <cstring>
#include "config_file.h"
#include "network_dns.h"
#include "network_tap_relay.h"
#include "persistent_netns.h"
#include "process.h"
#include "sandbox_process.h"
namespace {
bool test_persistent_netns() {
constexpr std::string_view test_netns_name = "selftest";
// Clean up a leftover from a previous interrupted run, if any, before
// starting -- create_persistent_netns() refuses to overwrite an existing
// live namespace.
if (persistent_netns_exists(test_netns_name)) {
remove_persistent_netns(test_netns_name);
std::vector<char*> argv;
argv.reserve(owned_args.size());
for (auto& arg : owned_args) {
argv.push_back(arg.data());
}
if (!create_persistent_netns(test_netns_name)) {
spdlog::error("self-test: failed to create persistent network namespace");
return false;
}
// Checked from this (parent) process, after the child that actually did
// the unshare()/bind-mount has already exited -- this is exactly what
// confirms the namespace outlives its creating process, the whole point
// of the bind-mount technique.
if (!persistent_netns_exists(test_netns_name)) {
spdlog::error("self-test: persistent network namespace missing right after creating it");
remove_persistent_netns(test_netns_name);
return false;
}
if (!remove_persistent_netns(test_netns_name)) {
spdlog::error("self-test: failed to remove persistent network namespace");
return false;
}
if (persistent_netns_exists(test_netns_name)) {
spdlog::error("self-test: persistent network namespace still exists after removing it");
return false;
}
fmt::print("persistent network namespace create/verify/remove: OK\n");
return true;
}
// Exercises network_tap_relay.h's create/verify/teardown cycle end to end: a
// throwaway bridge stands in for a real network's bridge, inside a throwaway
// persistent namespace (persistent_netns.h) standing in for a real network's
// own dedicated one -- create_tap_relay() now always enters that namespace
// first (network_bridge.cpp's wrap_for_network() reaches a real network's
// bridge the same way, both kinds), so this test needs one too, even though
// network_bridge.h's own provisioning isn't otherwise exercised here (a tap
// device only cares that *some* bridge interface exists to attach to). A
// throwaway network namespace (kept alive by a child blocked in pause())
// stands in for a real -r/--run session's isolated net namespace. Confirms:
// the host-side tap gets created and attached to the bridge; the
// container-side tap gets created, with the requested name, inside the
// target namespace; and, once stop_tap_relay() stops the relay, the
// host-side device is explicitly removed (`ip link del`, since it's now
// created via `ip tuntap add` and no longer disappears on its own just
// because its one-and-only fd closes -- see stop_tap_relay()'s own doc
// comment) while the container-side device deliberately survives, unaffected
// by the relay stopping: it only goes away once the container's own network
// namespace itself is torn down (below, when this test kills container_pid).
bool test_tap_relay() {
const std::string test_network_name = "selftest-tap-relay";
const std::string test_bridge = "slkselftest0";
const std::string host_tap = "thselftest0";
const std::string container_if = "ethselftest";
if (persistent_netns_exists(test_network_name)) {
remove_persistent_netns(test_network_name);
}
if (!create_persistent_netns(test_network_name)) {
spdlog::error("self-test: failed to create persistent namespace for tap-relay test");
return false;
}
std::vector<std::string> netns_wrap = {
"nsenter", fmt::format("--net={}", persistent_netns_path(test_network_name).string()), "--"};
auto in_netns = [&netns_wrap](std::vector<std::string> argv) {
std::vector<std::string> wrapped = netns_wrap;
wrapped.insert(wrapped.end(), argv.begin(), argv.end());
return wrapped;
};
if (run_process(in_netns({"ip", "link", "add", test_bridge, "type", "bridge"})).exit_code != 0) {
spdlog::error("self-test: failed to create throwaway test bridge");
remove_persistent_netns(test_network_name);
return false;
}
if (run_process(in_netns({"ip", "link", "set", test_bridge, "up"})).exit_code != 0) {
spdlog::error("self-test: failed to bring up throwaway test bridge");
run_process(in_netns({"ip", "link", "del", test_bridge}));
remove_persistent_netns(test_network_name);
return false;
}
// A throwaway, otherwise-empty network namespace standing in for a real
// session's own -- kept alive only by this child blocking in pause()
// until signaled, mirroring how bwrap's own sandboxed child keeps a real
// session's namespace alive for as long as it runs.
pid_t container_pid = fork();
if (container_pid < 0) {
spdlog::error("self-test: failed to fork throwaway container namespace holder");
run_process(in_netns({"ip", "link", "del", test_bridge}));
remove_persistent_netns(test_network_name);
return false;
}
if (container_pid == 0) {
if (unshare(CLONE_NEWNET) != 0) {
_exit(1);
}
pause();
_exit(0);
}
// fork() returning to this (parent) process doesn't mean the child has
// actually reached its own unshare(CLONE_NEWNET) call yet -- the same
// race network_join.cpp's wait_for_isolated_net_namespace() already
// guards against for a real session's namespace. Poll (bounded, 1s) for
// container_pid's own net namespace to actually differ from ours before
// trusting its pid.
bool isolated = false;
for (int elapsed_ms = 0; elapsed_ms <= 1000; elapsed_ms += 20) {
if (namespace_isolated(getpid(), container_pid, "net")) {
isolated = true;
break;
}
struct timespec ts {
0, 20L * 1000000L
};
while (nanosleep(&ts, &ts) != 0 && errno == EINTR) {
}
}
if (!isolated) {
spdlog::error("self-test: throwaway container namespace never isolated");
kill(container_pid, SIGKILL);
int reap_status = 0;
waitpid(container_pid, &reap_status, 0);
run_process(in_netns({"ip", "link", "del", test_bridge}));
remove_persistent_netns(test_network_name);
return false;
}
bool ok = true;
NetworkEntry network{test_network_name, NetworkKind::extern_, "", false, "", true};
auto relay = create_tap_relay(network, test_bridge, host_tap, container_pid, container_if);
if (!relay) {
spdlog::error("self-test: failed to create tap relay");
ok = false;
}
if (ok) {
auto host_check = run_process(in_netns({"ip", "link", "show", host_tap}));
if (host_check.exit_code != 0 || host_check.stdout_output.find("master " + test_bridge) == std::string::npos) {
spdlog::error("self-test: host-side tap device missing or not attached to the bridge");
ok = false;
}
}
if (ok) {
auto container_check = run_process(
{"nsenter", fmt::format("--net=/proc/{}/ns/net", container_pid), "--", "ip", "link", "show", container_if});
if (container_check.exit_code != 0) {
spdlog::error("self-test: container-side tap device missing inside the target namespace");
ok = false;
}
}
if (relay) {
stop_tap_relay(*relay);
if (run_process(in_netns({"ip", "link", "show", host_tap})).exit_code == 0) {
spdlog::error("self-test: host-side tap device still exists after stopping the relay");
ok = false;
}
// The container-side device is expected to survive the relay
// stopping -- it's persistent now (create_persistent_tap(),
// network_tap_relay.cpp) and lives inside the container's own
// network namespace, which stopping the relay doesn't touch at all.
// It only disappears once that namespace itself is destroyed (this
// test does that below, by killing container_pid) -- not re-checked
// here, since nsenter can't target a namespace whose only holding
// process has already exited.
if (run_process({"nsenter", fmt::format("--net=/proc/{}/ns/net", container_pid), "--", "ip", "link", "show",
container_if})
.exit_code != 0) {
spdlog::error("self-test: container-side tap device unexpectedly gone after stopping the relay");
ok = false;
}
}
kill(container_pid, SIGKILL);
int status = 0;
waitpid(container_pid, &status, 0);
run_process(in_netns({"ip", "link", "del", test_bridge}));
remove_persistent_netns(test_network_name);
if (ok) {
fmt::print("tap-relay create/attach/teardown: OK\n");
}
return ok;
}
// Builds a minimal DNS query (one question, A record, class IN) for `name`,
// wire format -- just enough to exercise the resolver's own answer path, not
// a general-purpose DNS client.
std::vector<uint8_t> build_dns_a_query(const std::string& name, uint16_t id) {
std::vector<uint8_t> packet;
auto push16 = [&](uint16_t v) {
packet.push_back(static_cast<uint8_t>(v >> 8));
packet.push_back(static_cast<uint8_t>(v & 0xff));
};
push16(id);
push16(0x0100); // standard query, recursion desired
push16(1); // qdcount
push16(0);
push16(0);
push16(0); // ancount/nscount/arcount
size_t start = 0;
while (start <= name.size()) {
size_t dot = name.find('.', start);
std::string label = name.substr(start, dot == std::string::npos ? std::string::npos : dot - start);
packet.push_back(static_cast<uint8_t>(label.size()));
packet.insert(packet.end(), label.begin(), label.end());
if (dot == std::string::npos) {
break;
}
start = dot + 1;
}
packet.push_back(0);
push16(1); // qtype A
push16(1); // qclass IN
return packet;
}
// Sends build_dns_a_query(name) to 127.0.0.1:53 from *inside* `ns_pid`'s own
// network namespace (a plain setns() here, same technique
// network_tap_relay.cpp's own enter_namespace() uses, done in a throwaway
// forked child so this process's own namespace is untouched either way) and
// parses the first answer's 4-byte A-record rdata back out. Returns false on
// any failure (including NXDOMAIN/no answer) -- out_ip is only meaningful
// when this returns true.
bool query_dns_a_record(pid_t ns_pid, const std::string& name, std::string& out_ip) {
int report_pipe[2];
if (pipe(report_pipe) != 0) {
return false;
}
pid_t pid = fork();
if (pid < 0) {
close(report_pipe[0]);
close(report_pipe[1]);
return false;
}
if (pid == 0) {
close(report_pipe[0]);
int ns_fd = open(fmt::format("/proc/{}/ns/net", ns_pid).c_str(), O_RDONLY);
if (ns_fd < 0 || setns(ns_fd, CLONE_NEWNET) != 0) {
_exit(1);
}
close(ns_fd);
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0) {
_exit(1);
}
struct timeval tv {
2, 0
};
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
struct sockaddr_in addr {};
addr.sin_family = AF_INET;
addr.sin_port = htons(53);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
auto query = build_dns_a_query(name, 0x1234);
if (sendto(sock, query.data(), query.size(), 0, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) <
0) {
_exit(1);
}
uint8_t buf[512];
socklen_t addrlen = sizeof(addr);
ssize_t n = recvfrom(sock, buf, sizeof(buf), 0, reinterpret_cast<struct sockaddr*>(&addr), &addrlen);
if (n < 12) {
_exit(1);
}
uint16_t rcode = static_cast<uint16_t>(buf[3] & 0x0f);
uint16_t ancount = static_cast<uint16_t>((buf[6] << 8) | buf[7]);
if (ancount == 0) {
// Exit code carries the RCODE (offset by 10 to stay clear of the
// other exit codes above) so a failure's own spdlog::debug below
// can say *why* -- e.g. 13 = NXDOMAIN, 15 = REFUSED (the latter
// is exactly what caught this project's own dnsmasq
// privilege-drop bug during development, see start_dns_resolver()'s
// own --user=root/--group=root comment).
_exit(10 + rcode);
}
// Skip the 12-byte header, then the question (the exact same qname
// this process just sent, plus 4 bytes qtype/qclass).
size_t pos = 12;
while (pos < static_cast<size_t>(n) && buf[pos] != 0) {
pos += static_cast<size_t>(buf[pos]) + 1;
}
pos += 1 + 4;
// The answer's own name field: a compression pointer (top two bits
// set) is 2 bytes; a literal label sequence is read the same way as
// the question's own qname above.
if (pos < static_cast<size_t>(n) && (buf[pos] & 0xc0) == 0xc0) {
pos += 2;
} else {
while (pos < static_cast<size_t>(n) && buf[pos] != 0) {
pos += static_cast<size_t>(buf[pos]) + 1;
}
pos += 1;
}
pos += 2 + 2 + 4; // type, class, ttl
if (pos + 2 > static_cast<size_t>(n)) {
_exit(1);
}
uint16_t rdlength = static_cast<uint16_t>((buf[pos] << 8) | buf[pos + 1]);
pos += 2;
if (rdlength != 4 || pos + 4 > static_cast<size_t>(n)) {
_exit(1);
}
char ip_str[32];
std::snprintf(ip_str, sizeof(ip_str), "%u.%u.%u.%u", buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]);
ssize_t unused = write(report_pipe[1], ip_str, std::strlen(ip_str));
(void)unused;
_exit(0);
}
close(report_pipe[1]);
char buf[64] = {0};
ssize_t n = read(report_pipe[0], buf, sizeof(buf) - 1);
close(report_pipe[0]);
int status = 0;
waitpid(pid, &status, 0);
if (n <= 0) {
if (WIFEXITED(status)) {
spdlog::debug("self-test: DNS query for '{}' got no answer, child exit code {}", name,
WEXITSTATUS(status));
}
return false;
}
out_ip.assign(buf, static_cast<size_t>(n));
return true;
}
// Exercises network_dns.h's create/answer/teardown cycle end to end: a
// throwaway network namespace (same fork+unshare(CLONE_NEWNET)+pause()
// technique test_tap_relay() uses) stands in for a real -r/--run session's
// own isolated namespace. A single dns-hosts record is written by hand
// (record_dns_host()) for a fabricated `intern` network, start_dns_resolver()
// is pointed at it, and query_dns_a_record() above confirms the resolver
// actually answers with the recorded address -- real UDP wire format, not
// just "the process started". Skipped (not a failure) if dnsmasq isn't
// installed, same as the whole suite's own root-only skip.
bool test_dns_resolver() {
if (!is_dnsmasq_available()) {
fmt::print("skipping DNS resolver test (dnsmasq not found in PATH)\n");
return true;
}
const std::string test_network_name = "selftest-dns";
const std::string test_hostname = "selftest-peer";
const std::string test_ip = "10.99.99.2";
pid_t container_pid = fork();
if (container_pid < 0) {
spdlog::error("self-test: failed to fork throwaway container namespace holder for DNS test");
return false;
}
if (container_pid == 0) {
if (unshare(CLONE_NEWNET) != 0) {
_exit(1);
}
// Not automatically up in a fresh network namespace -- the resolver
// needs to bind 127.0.0.1.
run_process({"ip", "link", "set", "lo", "up"});
pause();
_exit(0);
}
bool isolated = false;
for (int elapsed_ms = 0; elapsed_ms <= 1000; elapsed_ms += 20) {
if (namespace_isolated(getpid(), container_pid, "net")) {
isolated = true;
break;
}
struct timespec ts {
0, 20L * 1000000L
};
while (nanosleep(&ts, &ts) != 0 && errno == EINTR) {
}
}
if (!isolated) {
spdlog::error("self-test: throwaway container namespace never isolated (DNS test)");
kill(container_pid, SIGKILL);
int reap_status = 0;
waitpid(container_pid, &reap_status, 0);
return false;
}
std::error_code ec;
std::filesystem::remove_all(dns_hosts_dir(test_network_name), ec);
record_dns_host(test_network_name, "selftest-peer-container", 1, test_ip, test_hostname);
bool ok = true;
NetworkEntry test_network{test_network_name, NetworkKind::intern, "", false, "", true};
JoinedNetwork joined_network{test_network, test_ip, std::nullopt};
auto resolver = start_dns_resolver("selftest-dns-session", container_pid, container_pid, {joined_network});
if (!resolver) {
spdlog::error("self-test: failed to start DNS resolver");
ok = false;
}
if (ok) {
std::string answered_ip;
if (!query_dns_a_record(container_pid, test_hostname, answered_ip) || answered_ip != test_ip) {
spdlog::error("self-test: DNS resolver did not answer '{}' correctly (got '{}', expected '{}')",
test_hostname, answered_ip, test_ip);
ok = false;
}
}
if (resolver) {
stop_dns_resolver(*resolver);
}
remove_dns_resolver_record("selftest-dns-session", container_pid);
remove_dns_host_record(test_network_name, "selftest-peer-container", 1);
std::filesystem::remove_all(dns_hosts_dir(test_network_name), ec);
kill(container_pid, SIGKILL);
int status = 0;
waitpid(container_pid, &status, 0);
if (ok) {
fmt::print("dns-resolver create/answer/teardown: OK\n");
}
return ok;
}
} // namespace
int run_self_tests() {
// Both tests below need root: create_persistent_netns() bind-mounts, and
// test_tap_relay() creates bridges/tap devices -- report and skip rather
// than treating a rootless dev machine as a failure.
if (geteuid() != 0) {
fmt::print("skipping persistent network namespace and tap-relay tests (requires root)\n");
return 0;
}
bool ok = test_persistent_netns();
ok = test_tap_relay() && ok;
ok = test_dns_resolver() && ok;
return ok ? 0 : 1;
Catch::Session session;
return session.run(static_cast<int>(argv.size()), argv.data());
#else
(void)args;
fmt::print(stderr,
"tests were not compiled into this build (reconfigure with "
"-Denable_tests=true and rebuild)\n");
return 1;
#endif
}
+16 -7
View File
@@ -16,10 +16,19 @@
#pragma once
// Implements -t/--test, this project's own built-in self-test mode (distinct
// from the Meson-driven fixture smoke test under tests/). Currently exercises
// persistent_netns.{h,cpp}'s create/verify/remove cycle and
// network_tap_relay.{h,cpp}'s create/attach/teardown cycle (both root-only,
// skipped with a message otherwise -- see docs/networking-design.md); more
// real tests are expected here as more of that feature lands.
int run_self_tests();
#include <string>
#include <vector>
// Implements -t/--test, this project's own built-in Catch2-driven test
// suite (distinct from the Meson-driven fixture smoke test under tests/,
// which stays a separate, always-on mount/unmount/cleanup check).
// `args` is `ParsedArgs::test_args` (cli_args.h) -- everything on the
// command line after `-t`, forwarded to Catch2's own Session::run()
// unmodified (tag expressions, --list-tests, --reporter, etc.); a bare
// `-t` (empty `args`) runs every registered TEST_CASE. Only compiled to
// actually run Catch2 when this build has ENABLE_TESTS set (config.h,
// from Meson's `enable_tests` option, default on) -- otherwise prints a
// clear "not compiled into this build" message and returns nonzero,
// since a -Denable_tests=false build has no TEST_CASEs (or Catch2 itself)
// linked in at all.
int run_self_tests(const std::vector<std::string>& args);