174b6b4c29
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.
382 lines
18 KiB
C++
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));
|
|
}
|