Add [integration][net] rootless container-run tests

tests/integration/test_rootless_run.cpp runs a real busybox image through
the exact real -r/--run dispatch path (dispatch_command(), commands.h) --
mount, resolve, run_bwrap, unmount, cleanup, in-process rather than via a
subprocess -- and confirms bwrap's *default* sandboxing (no -n/-p at all)
is genuinely isolating: a fresh network namespace with nothing but
loopback, and pid/uts/ipc namespaces that differ from this test process's
own. No root needed, same as a plain `-r image.tar -- <command>` already
isn't.

New tests/support helpers: CapturedStdout (RAII, redirects this process's
own fd 1 -- and anything a forked/exec'd child inherits from it -- to a
throwaway temp file for its lifetime) so the sandboxed command's own
output can actually be asserted on.

Two real, non-obvious findings from getting this working, not assumed:
1. bwrap's own sandbox mounts --proc /proc and --dev /dev, but *not*
   /sys -- confirmed directly (`ls /sys/class/net` inside the sandbox:
   "No such file or directory", reproduced identically via the real CLI,
   not just this test). Switched the loopback-only check to
   /proc/net/dev instead (two header lines + one "<iface>: ..." line per
   interface), which correctly shows only "lo".
2. spdlog's default sink writes to stdout, not stderr, same as the plain
   "mounted image at: ..." success line (see CLAUDE.md) -- so a naive
   capture-and-line-split mixed slocker-lite's own status/log output in
   with the sandboxed command's real output. Fixed by having the
   sandboxed command bracket its own output between two unique markers
   and extracting only what's strictly between them.

Verified: both tests pass repeatably, 15 stress-test runs of the full
combined [unit]+[integration] suite with zero failures, plus a full run
as root.

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:55:25 +00:00
parent dd66886de8
commit 80cc49d898
4 changed files with 214 additions and 0 deletions
+152
View File
@@ -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 --
// <command>` already does unprivileged.
#include <unistd.h>
#include <algorithm>
#include <cctype>
#include <sstream>
#include <string>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "cli_args.h"
#include "commands.h"
#include "config_file.h"
#include "fixtures.h"
namespace {
std::vector<std::string> split_lines_trimmed(const std::string& text) {
std::vector<std::string> lines;
std::istringstream iss(text);
std::string line;
while (std::getline(iss, line)) {
while (!line.empty() && std::isspace(static_cast<unsigned char>(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<std::string> 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<std::string>(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<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;
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 "<iface>: ..."
// 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"));
}
+37
View File
@@ -16,7 +16,13 @@
#include "fixtures.h"
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <system_error>
#include <vector>
@@ -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<char> 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();
}
+24
View File
@@ -52,3 +52,27 @@ private:
std::optional<std::string> previous_config_home_;
std::optional<std::string> 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_;
};