Files
ceamac 174b6b4c29 Use explicit test-only subnets and test- prefixed names in the test suite
Running the full device test suite on the real Android target found a
genuine test-harness gap: every extern-network test in
test_network_join_scenarios.cpp failed with "RTNETLINK answers: File
exists" adding its own uplink route. Root cause: each test runs under its
own ScratchXdgDirs, so its own persistent.yaml starts empty every time --
allocate_ipv4_subnet()'s own auto-allocation always picks the very first
slot (10.168.0.0/24) with no way to see whatever real, non-test networks
already exist on the host. The device happens to have a real, long-lived
"extern" network already occupying exactly that subnet from prior manual
testing, and since extern's uplink adds a route back into the (necessarily
shared, host-root) routing table -- unlike intern, whose routing lives
entirely inside its own isolated per-network namespace -- every extern
test collided with it. Not a production bug: a real end user only ever has
one persistent.yaml where allocation correctly sees every existing entry.

Fixed by giving each of the 10 test networks in
test_network_join_scenarios.cpp its own fixed, explicit subnet (--subnet)
in 10.169.0.0/16 -- a different /16 than production's own default
10.168.0.0/16 range, so a test run can't collide with a real network
regardless of how many the host already has.

Also renamed every network/hostname/container-name string literal used
across the test suite (test_network_join_scenarios.cpp,
test_root_networking.cpp, test_rootless_run.cpp,
test_session_cleanup.cpp) from "selftest*" to "test-*", to further reduce
the chance of colliding with anything a real invocation might already be
using. Low-level interface device literals (slkselftest0/thselftest0/
ethselftest in test_root_networking.cpp) are left as-is -- they're
internal identifiers for a throwaway unit test, not network or container
names.

Verified: clean rebuild, meson test, and 3 consecutive
[integration][root] suite runs (61 assertions, 14 test cases) with no
failures.
2026-09-05 14:03:14 +00:00

382 lines
18 KiB
C++

// Copyright (C) 2026 Viorel Munteanu
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
// [integration][net] (rootless): runs a real busybox container via the
// exact same dispatch_command() (commands.h) entry point a real `-r/--run`
// invocation goes through -- mount, resolve, run_bwrap, unmount, cleanup,
// all for real, just called in-process instead of via a subprocess.
// Confirms bwrap's *default* sandboxing (no -n/-p involved at all) is
// genuinely isolating: a network namespace with its own identity (not the
// host's), with loopback present, and every other namespace type this
// kernel supports namespacing at all also differing from this test
// process's own. Needs a real runnable image (find_busybox_fixture()) but
// no root -- everything here works the same way a plain `-r image.tar --
// <command>` already does unprivileged.
//
// **Deliberately checks against this host's own live kernel capability
// (detect_bwrap_unshare_args(), bwrap.h) rather than assuming every
// namespace type is supported, exactly like production code (build_bwrap_args()
// itself) already has to.** Two real, non-obvious findings from running this
// on the actual Android target device, not assumed: (1) that kernel
// auto-creates several harmless placeholder tunnel interfaces (`sit0`,
// `ip6tnl0`, `ip_vti0`, `ip6_vti0`) in *every* fresh network namespace,
// alongside loopback -- confirmed genuinely isolated regardless (no real
// host interfaces present), just not "loopback-only" the way this dev
// machine's own kernel is; an earlier version of this test asserted an
// exact interface count and failed there for exactly this reason. (2) That
// device has neither PID nor IPC namespace support *as a kernel feature at
// all* -- confirmed directly: even this test process's own `readlink
// /proc/self/ns/pid` fails outright there (not merely "unshared or not"),
// matching this project's own already-documented standing lesson that this
// target has neither `CONFIG_CHECKPOINT_RESTORE` nor pid namespace support.
// Comparing against a namespace type the kernel doesn't expose at all
// wouldn't prove anything either way, so both checks below only ever look
// at types `detect_bwrap_unshare_args()` reports as real.
#include <time.h>
#include <unistd.h>
#include <algorithm>
#include <cctype>
#include <cerrno>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "bwrap.h"
#include "cli_args.h"
#include "commands.h"
#include "config_file.h"
#include "fixtures.h"
#include "session_cgroup.h"
namespace {
// split_lines_trimmed()/extract_marked_lines() moved to tests/support/fixtures.h
// once a second file (test_network_join_scenarios.cpp) needed the exact same
// marker-extraction logic.
std::string read_own_namespace_link(const char* type) {
char buf[256];
ssize_t n = readlink((std::string("/proc/self/ns/") + type).c_str(), buf, sizeof(buf) - 1);
if (n < 0) {
return {};
}
return std::string(buf, static_cast<size_t>(n));
}
// Runs `command` inside `image` via the exact real -r/--run dispatch path,
// with a scratch XDG_CONFIG_HOME/XDG_STATE_HOME already in effect (the
// caller owns the ScratchXdgDirs so it outlives this call), returning the
// sandboxed command's own captured stdout.
std::string run_in_fixture(const std::filesystem::path& image, const std::vector<std::string>& command) {
ParsedArgs args;
args.mode = Mode::run;
args.mode_arg = image.string();
args.command = command;
// A fresh copy of the current -t run's effective config (g_test_app_config,
// fixtures.h -- set once by run_self_tests() from whatever main() resolved,
// respecting -c/--config-file) rather than a hardcoded AppConfig{}, so this
// helper's containers reflect the same global settings a real -r/--run
// invocation would.
AppConfig config = g_test_app_config;
CapturedStdout capture;
// config_path is only ever consulted by modes that read/write a config
// file (-w/--write-config, -v/--volume, -n/--network); Mode::run
// doesn't touch it at all (commands.cpp's own Mode::run dispatch case
// passes `config` but not `config_path` to run_container()), so this
// placeholder is never actually read or written.
dispatch_command(args, "/nonexistent/unused-config.yaml", config);
return capture.contents();
}
// Splits a raw /proc/<pid>/cmdline blob (NUL-separated, per Linux's own
// documented format) into its individual arguments.
std::vector<std::string> split_cmdline_args(const std::string& raw) {
std::vector<std::string> args;
size_t start = 0;
while (start < raw.size()) {
size_t end = raw.find('\0', start);
if (end == std::string::npos) {
end = raw.size();
}
args.push_back(raw.substr(start, end - start));
start = end + 1;
}
return args;
}
// True if some process on the *host* is genuinely running `sleep <arg>` --
// checked from the host's own process table (works regardless of whether
// the session's own pid namespace is isolated or not: every process,
// wherever it lives namespace-wise, is still a perfectly ordinary task on
// the host with its own real pid and /proc/<pid>/cmdline entry -- only the
// pid *number* a process sees for itself differs inside an isolated
// namespace, not whether it shows up here at all). Deliberately checks
// argv[0]'s basename and argv[1] exactly, not a substring search across the
// whole cmdline -- a real bug found via this exact test session: an early
// version searched for the literal text "sleep 137" anywhere in the
// cmdline, which produced a false positive by matching this test's *own*
// diagnostic `pkill -f 'sleep 137'` cleanup commands (run by hand, outside
// the test, while investigating a separate issue) -- their own cmdline
// literally contains that text as a pkill pattern argument, despite not
// being a sleep process at all. Same directory-scanning shape as
// bwrap.cpp's own find_fuse_overlayfs_pid().
bool sleep_process_running(const std::string& arg) {
std::error_code ec;
auto it = std::filesystem::directory_iterator("/proc", ec);
if (ec) {
return false;
}
for (const auto& entry : it) {
const std::string name = entry.path().filename().string();
if (!std::all_of(name.begin(), name.end(), [](unsigned char c) { return std::isdigit(c); })) {
continue;
}
std::ifstream cmdline_file(entry.path() / "cmdline", std::ios::binary);
std::string raw((std::istreambuf_iterator<char>(cmdline_file)), std::istreambuf_iterator<char>());
auto args = split_cmdline_args(raw);
if (args.size() == 2 && std::filesystem::path(args[0]).filename() == "sleep" && args[1] == arg) {
return true;
}
}
return false;
}
// Bounded (2s, 50ms interval) poll for the `sleep <arg>` process to
// disappear from the host's own process table -- a session ending via a
// pid namespace's own kernel-guaranteed collapse-on-pid-1-exit isn't
// necessarily synchronously complete by the instant run_in_fixture() above
// returns (that guarantee is about eventual termination, not that every
// other task in the namespace has already been fully reaped), so a single
// instantaneous check right after could be flaky. The straggler sweep this
// test actually targets (run_bwrap()'s own post-exit cgroup sweep,
// bwrap.cpp) already applies a real grace period internally for the same
// reason.
bool sleep_process_gone_within(const std::string& arg, int timeout_ms) {
for (int waited = 0; waited < timeout_ms; waited += 50) {
if (!sleep_process_running(arg)) {
return true;
}
struct timespec ts {
0, 50L * 1000000L
};
while (nanosleep(&ts, &ts) != 0 && errno == EINTR) {
}
}
return !sleep_process_running(arg);
}
// Whether the current -t run's effective config (g_test_app_config,
// fixtures.h) would actually get bwrap to request --unshare-<type> for a
// container it creates -- both the config's own policy *and* live kernel
// support (detect_bwrap_unshare_args(), bwrap.h) have to allow it, the same
// two-gate check build_bwrap_args() itself applies. `type` is one of
// "user"/"ipc"/"pid"/"net"/"uts"/"cgroup", matching bwrap.cpp's own
// namespace_probes names. Root never actually requests --unshare-user
// (bwrap.cpp) regardless of policy, so "user" always reports false here --
// callers that care about that distinction (this file's own "every
// kernel-supported namespace type" test) already exclude it up front rather
// than relying on this to explain why.
bool namespace_type_would_isolate(const std::string& type) {
bool policy_enabled;
if (type == "user") {
policy_enabled = g_test_app_config.unshare_user.value_or(true);
} else if (type == "ipc") {
policy_enabled = g_test_app_config.unshare_ipc.value_or(true);
} else if (type == "pid") {
policy_enabled = g_test_app_config.unshare_pid.value_or(true);
} else if (type == "net") {
policy_enabled = g_test_app_config.unshare_net.value_or(true);
} else if (type == "uts") {
policy_enabled = g_test_app_config.unshare_uts.value_or(true);
} else if (type == "cgroup") {
policy_enabled = g_test_app_config.unshare_cgroup.value_or(true);
} else {
policy_enabled = true; // unreachable given the fixed set of types above
}
if (!policy_enabled) {
return false;
}
auto supported = detect_bwrap_unshare_args();
return std::find(supported.begin(), supported.end(), "--unshare-" + type) != supported.end();
}
// Whether run_bwrap()'s own session cgroup (session_cgroup.h) would actually
// be creatable/writable right now -- tried directly against a throwaway,
// never-joined path rather than guessing from e.g. geteuid(), since the real
// failure mode this needs to detect (no delegated subtree when rootless) is
// exactly a permissions question create_directories()/access() can answer
// directly. Deliberately never writes this process's own pid into the
// probe directory's cgroup.procs (unlike the real create_session_cgroup()),
// so cleanup is just removing the still-empty directory -- no need to
// un-join anything first.
bool session_cgroup_would_work() {
if (!cgroup_v2_available()) {
return false;
}
auto probe_path = session_cgroup_path("test-cgroup-probe", getpid());
std::error_code ec;
std::filesystem::create_directories(probe_path, ec);
if (ec) {
return false;
}
bool writable = access((probe_path / "cgroup.procs").c_str(), W_OK) == 0;
std::filesystem::remove(probe_path, ec);
return writable;
}
} // namespace
TEST_CASE("rootless -r/--run: network namespace is genuinely isolated, loopback present", "[integration][net]") {
auto image = find_busybox_fixture();
if (!image) {
SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py");
}
// Covers both "the kernel doesn't support net namespaces" (the original
// check here) and "the config's own policy disables it" (found by
// running the full suite as root with every unshare-* flag forced off
// via -c/--config-file, per the user's own explicit request) -- with
// unshare-net disabled, bwrap genuinely never requests --unshare-net, so
// the sandbox correctly shares the host's own net namespace; that's the
// production code doing exactly what it was told, not a bug, so this
// test SKIPs rather than reports a false failure.
if (!namespace_type_would_isolate("net")) {
SKIP("net namespace isolation is not available under the current config/kernel");
}
ScratchXdgDirs scratch;
// bwrap's own sandbox mounts --proc /proc and --dev /dev, but *not*
// /sys at all (confirmed directly: `ls /sys/class/net` inside the
// sandbox fails outright, "No such file or directory") -- so
// /proc/net/dev is what's actually available to enumerate interfaces
// from inside. Format: two header lines, then one "<iface>: ..." line
// per interface -- deliberately not asserted on for an exact count
// (see this file's own top-of-file comment for why).
auto output = run_in_fixture(*image, {"sh", "-c",
"echo BEGIN-TEST-OUTPUT; readlink /proc/self/ns/net; "
"cat /proc/net/dev; echo END-TEST-OUTPUT"});
auto lines = extract_marked_lines(output);
REQUIRE(lines.size() >= 3); // ns/net line + 2-line /proc/net/dev header, at least
CHECK(lines[0] != read_own_namespace_link("net"));
bool has_lo = false;
for (size_t i = 3; i < lines.size(); ++i) {
if (lines[i].substr(0, lines[i].find(':')).find("lo") != std::string::npos) {
has_lo = true;
}
}
CHECK(has_lo);
}
TEST_CASE("rootless -r/--run: every kernel-supported namespace type differs from this process's own",
"[integration][net]") {
auto image = find_busybox_fixture();
if (!image) {
SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py");
}
// "user" is excluded: build_bwrap_args() deliberately never requests
// --unshare-user when running as root (bwrap.cpp), so it wouldn't
// actually be isolated in that case even though the kernel might
// support it -- this test runs at whatever privilege invoked it
// (rootful when run via `doas`/on the real device, rootless
// otherwise), so asserting on "user" here would be wrong in the root
// case. "net" is covered by the previous test case, more specifically.
// Filters on both kernel support *and* config policy (namespace_type_would_isolate())
// rather than kernel support alone -- found by running the full suite as
// root with every unshare-* flag forced off via -c/--config-file: a type
// whose policy is disabled is correctly never requested by bwrap, so it's
// no more isolated than "user" already deliberately isn't (see above);
// asserting on it anyway was a test gap, not a product bug.
std::vector<std::string> to_check;
for (const std::string type : {"pid", "uts", "ipc", "cgroup"}) {
if (namespace_type_would_isolate(type)) {
to_check.push_back(type);
}
}
if (to_check.empty()) {
SKIP("no pid/uts/ipc/cgroup namespace isolation is available under the current config/kernel");
}
std::string script = "echo BEGIN-TEST-OUTPUT; ";
for (const auto& type : to_check) {
script += "readlink /proc/self/ns/" + type + "; ";
}
script += "echo END-TEST-OUTPUT";
ScratchXdgDirs scratch;
auto output = run_in_fixture(*image, {"sh", "-c", script});
auto lines = extract_marked_lines(output);
REQUIRE(lines.size() == to_check.size());
for (size_t i = 0; i < to_check.size(); ++i) {
CHECK(lines[i] != read_own_namespace_link(to_check[i].c_str()));
}
}
// Regression test for run_bwrap()'s own automatic post-exit straggler sweep
// (bwrap.cpp/session_cgroup.h's "Resolved" entries in CLAUDE.md) -- the
// concrete real-world shape the user reported: a container backgrounds a
// long-running process with nohup (so it survives its own parent shell's
// exit and ignores SIGHUP) and exits immediately, and that process must not
// keep running after the session itself has ended. Unlike
// test_session_cleanup.cpp's own [integration][root] test (which exercises
// kill_via_cgroup() directly against two plain forked processes, since the
// real target device's own no-pid-namespace escape shape can't be forced
// via the CLI), this goes through the exact real -r/--run path end to end
// with a real container -- on a kernel that supports pid namespaces (this
// dev machine included), the kernel's own collapse-on-pid-1-exit guarantee
// already covers this case for free, so passing here doesn't by itself
// prove the cgroup sweep specifically fired; it proves the outward, visible
// contract this feature exists for ("a stray process never survives a
// session") holds end to end regardless of which mechanism provided it.
TEST_CASE("rootless -r/--run: a nohup-backgrounded process does not survive the session", "[integration][net]") {
auto image = find_busybox_fixture();
if (!image) {
SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py");
}
// With neither a pid namespace nor a working session cgroup available,
// there is genuinely no mechanism left that could reap a reparented
// straggler -- a documented, known limitation (CLAUDE.md's
// session_cgroup.{h,cpp} entry, "Known residual limitation"), not a
// regression this test should be flagging. Skip rather than fail so a
// deliberately-crippled config (e.g. via -c/--config-file, g_test_app_config
// above) doesn't produce a misleading failure here.
if (!namespace_type_would_isolate("pid") && !session_cgroup_would_work()) {
SKIP("neither a pid namespace nor a working session cgroup is available under the "
"current config/kernel -- no mechanism exists to reap a reparented straggler here");
}
// A distinctive duration -- not a realistic value anything else on this
// host would coincidentally already be sleeping for.
const std::string marker = "137";
REQUIRE_FALSE(sleep_process_running(marker));
ScratchXdgDirs scratch;
run_in_fixture(*image, {"sh", "-c", "nohup sleep 137 >/dev/null 2>&1 & exit"});
CHECK(sleep_process_gone_within(marker, 2000));
}