diff --git a/meson.build b/meson.build index 4cb18c2..0ddf05e 100644 --- a/meson.build +++ b/meson.build @@ -29,6 +29,7 @@ if get_option('enable_tests') 'tests/unit/test_cli_args.cpp', 'tests/support/fixtures.cpp', 'tests/integration/test_config_bwrap_chain.cpp', + 'tests/integration/test_rootless_run.cpp', 'tests/integration/test_root_networking.cpp', ] endif diff --git a/tests/integration/test_rootless_run.cpp b/tests/integration/test_rootless_run.cpp new file mode 100644 index 0000000..d93a5fe --- /dev/null +++ b/tests/integration/test_rootless_run.cpp @@ -0,0 +1,152 @@ +// 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 fresh network namespace with no interfaces but +// loopback, and pid/uts/ipc namespaces that differ 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. + +#include + +#include +#include +#include +#include +#include + +#include + +#include "cli_args.h" +#include "commands.h" +#include "config_file.h" +#include "fixtures.h" + +namespace { + +std::vector split_lines_trimmed(const std::string& text) { + std::vector lines; + std::istringstream iss(text); + std::string line; + while (std::getline(iss, line)) { + while (!line.empty() && std::isspace(static_cast(line.back()))) { + line.pop_back(); + } + if (!line.empty()) { + lines.push_back(line); + } + } + return lines; +} + +// Captured stdout also contains slocker-lite's *own* status/log output -- +// spdlog's default sink writes to stdout, not stderr, same as the plain +// "mounted image at: ..." success line (see CLAUDE.md) -- interleaved with +// whatever the sandboxed command itself prints, since both land on the +// same fd. Confirmed directly: an early version of this test line-split +// the raw capture and expected exactly N lines, which failed with extra +// lines ("mounted image at: ...", a rootless session-cgroup permission +// warning) mixed in. Fixed by having the sandboxed command bracket its own +// real output between two unique markers and extracting only what's +// strictly between them -- robust regardless of whatever else +// slocker-lite itself prints, since in practice all of that happens +// before the sandboxed command gets to run its own first command at all. +std::vector extract_marked_lines(const std::string& text) { + auto lines = split_lines_trimmed(text); + auto begin = std::find(lines.begin(), lines.end(), "BEGIN-TEST-OUTPUT"); + auto end = std::find(lines.begin(), lines.end(), "END-TEST-OUTPUT"); + if (begin == lines.end() || end == lines.end() || end <= begin) { + return {}; + } + return std::vector(begin + 1, end); +} + +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; + + AppConfig 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(); +} + +} // namespace + +TEST_CASE("rootless -r/--run: a fresh network namespace has only loopback", "[integration][net]") { + auto image = find_busybox_fixture(); + if (!image) { + SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py"); + } + + 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. + auto output = run_in_fixture( + *image, {"sh", "-c", "echo BEGIN-TEST-OUTPUT; cat /proc/net/dev; echo END-TEST-OUTPUT"}); + + auto lines = extract_marked_lines(output); + REQUIRE(lines.size() == 3); // 2-line header + exactly one interface + CHECK(lines[2].substr(0, lines[2].find(':')).find("lo") != std::string::npos); +} + +TEST_CASE("rootless -r/--run: pid/uts/ipc namespaces differ 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"); + } + + ScratchXdgDirs scratch; + auto output = run_in_fixture(*image, {"sh", "-c", + "echo BEGIN-TEST-OUTPUT; readlink /proc/self/ns/pid; " + "readlink /proc/self/ns/uts; readlink /proc/self/ns/ipc; " + "echo END-TEST-OUTPUT"}); + + auto lines = extract_marked_lines(output); + REQUIRE(lines.size() == 3); + CHECK(lines[0] != read_own_namespace_link("pid")); + CHECK(lines[1] != read_own_namespace_link("uts")); + CHECK(lines[2] != read_own_namespace_link("ipc")); +} diff --git a/tests/support/fixtures.cpp b/tests/support/fixtures.cpp index 9b78769..7dde570 100644 --- a/tests/support/fixtures.cpp +++ b/tests/support/fixtures.cpp @@ -16,7 +16,13 @@ #include "fixtures.h" +#include +#include + +#include #include +#include +#include #include #include @@ -81,3 +87,34 @@ ScratchXdgDirs::~ScratchXdgDirs() { std::error_code ec; std::filesystem::remove_all(path_, ec); } + +CapturedStdout::CapturedStdout() { + std::string tmpl_str = "/tmp/slocker-lite-test-stdout-XXXXXX"; + std::vector tmpl(tmpl_str.begin(), tmpl_str.end()); + tmpl.push_back('\0'); + int fd = mkstemp(tmpl.data()); + temp_path_ = tmpl.data(); + + fflush(stdout); + saved_fd_ = dup(STDOUT_FILENO); + dup2(fd, STDOUT_FILENO); + close(fd); +} + +CapturedStdout::~CapturedStdout() { + fflush(stdout); + if (saved_fd_ >= 0) { + dup2(saved_fd_, STDOUT_FILENO); + close(saved_fd_); + } + std::error_code ec; + std::filesystem::remove(temp_path_, ec); +} + +std::string CapturedStdout::contents() { + fflush(stdout); + std::ifstream in(temp_path_); + std::ostringstream out; + out << in.rdbuf(); + return out.str(); +} diff --git a/tests/support/fixtures.h b/tests/support/fixtures.h index 8a94a45..86a70ec 100644 --- a/tests/support/fixtures.h +++ b/tests/support/fixtures.h @@ -52,3 +52,27 @@ private: std::optional previous_config_home_; std::optional previous_state_home_; }; + +// RAII stdout capture: for its lifetime, this process's own fd 1 (and +// anything a forked/exec'd child -- e.g. the real bwrap-sandboxed command, +// via dispatch_command()'s ordinary fork/exec chain -- inherits from it) is +// redirected to a throwaway temp file instead of the real terminal/pipe. +// contents() flushes C stdio first (so any of *this* process's own +// buffered writes land in the file before being read back) and returns +// everything captured so far; the destructor restores the original fd 1 +// and removes the temp file. Kept narrowly scoped around just the call +// under test, so Catch2's own console reporter output is never captured +// by mistake. +class CapturedStdout { +public: + CapturedStdout(); + ~CapturedStdout(); + CapturedStdout(const CapturedStdout&) = delete; + CapturedStdout& operator=(const CapturedStdout&) = delete; + + std::string contents(); + +private: + int saved_fd_ = -1; + std::filesystem::path temp_path_; +};