// 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 -- // ` 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 #include #include #include #include #include #include #include #include #include #include #include #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(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& 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//cmdline blob (NUL-separated, per Linux's own // documented format) into its individual arguments. std::vector split_cmdline_args(const std::string& raw) { std::vector 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 ` -- // 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//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(cmdline_file)), std::istreambuf_iterator()); 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 ` 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- 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 ": ..." 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 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)); }