Add test support helpers + [integration] config/bwrap chain tests

tests/support/fixtures.{h,cpp}: find_busybox_fixture() (images/busybox.tar
relative to cwd, this project's own established manual-testing convention
-- nullopt if absent, so [net] tests can SKIP() rather than fail) and
ScratchXdgDirs, an RAII helper pointing XDG_CONFIG_HOME/XDG_STATE_HOME at a
fresh throwaway mkdtemp() directory for its lifetime, restoring the
previous environment and removing the directory on destruction -- so
integration tests that actually exercise config_file_path()/xdg_state_dir()
never touch the real developer's own config/state.

tests/integration/test_config_bwrap_chain.cpp: the user's own example --
write a config file, load it back, resolve a NamespaceConfig from it the
same way run_container() (commands.cpp) does, and confirm build_bwrap_args()'s
resulting argv actually reflects it (disabled unshare-net/unshare-uts never
requested; an all-default config matches the live host's own
detect_bwrap_unshare_args() probe exactly). Neither test mounts/runs
anything or needs privilege.

Real bug found via ~10-30 repeated combined [unit]+[integration] runs, not
assumed: ScratchXdgDirs's constructor built its mkdtemp() template vector
from two *separate* temporary std::string objects (`.begin()` off one,
`.end()` off the other) -- mixing iterators from different containers is
undefined behavior, here manifesting as an intermittent, heap-address-
dependent `std::length_error: cannot create std::vector larger than
max_size()` inside whichever test happened to run adjacent to it. Fixed by
using a single named string instance for both ends of the range; confirmed
clean across 30 repeated combined runs afterward, 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:49:51 +00:00
parent 42f9d36edf
commit dad4e75392
4 changed files with 244 additions and 1 deletions
@@ -0,0 +1,104 @@
// 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] (no net, no root): chains config_file.h's read/write with
// bwrap.h's argv assembly -- write a config file, load it back, resolve a
// NamespaceConfig from it the same way commands.cpp's run_container() does,
// and confirm build_bwrap_args()'s resulting argv actually reflects it.
// Neither step mounts/runs anything or needs any privilege -- build_bwrap_args()
// is pure argv assembly, given a `root` that's just a string here, never
// actually accessed.
#include <algorithm>
#include <string>
#include <vector>
#include <catch2/catch_test_macros.hpp>
#include "bwrap.h"
#include "config_file.h"
#include "fixtures.h"
namespace {
bool contains(const std::vector<std::string>& argv, const std::string& flag) {
return std::find(argv.begin(), argv.end(), flag) != argv.end();
}
} // namespace
TEST_CASE("config file -> NamespaceConfig -> bwrap argv: disabled namespaces are never requested", "[integration]") {
ScratchXdgDirs scratch;
auto config_path = scratch.path() / "config.yaml";
AppConfig written;
written.unshare_net = false;
written.unshare_uts = false;
REQUIRE(write_config_file(config_path, written));
auto loaded = load_config_file(config_path);
REQUIRE(loaded.has_value());
CHECK(loaded->unshare_net == std::optional<bool>(false));
CHECK(loaded->unshare_uts == std::optional<bool>(false));
// Same resolution run_container() (commands.cpp) itself does: each
// unshare-* key defaults to enabled when unset.
NamespaceConfig namespace_config{
loaded->unshare_user.value_or(true), loaded->unshare_ipc.value_or(true),
loaded->unshare_pid.value_or(true), loaded->unshare_net.value_or(true),
loaded->unshare_uts.value_or(true), loaded->unshare_cgroup.value_or(true),
};
CHECK(namespace_config.net == false);
CHECK(namespace_config.uts == false);
CHECK(namespace_config.user == true);
auto argv = build_bwrap_args("/fake/root", {"/bin/sh"}, {}, std::nullopt, std::nullopt, namespace_config, false);
CHECK_FALSE(contains(argv, "--unshare-net"));
CHECK_FALSE(contains(argv, "--unshare-uts"));
CHECK(contains(argv, "--bind"));
CHECK(contains(argv, "/fake/root"));
CHECK(contains(argv, "/bin/sh"));
}
TEST_CASE("config file -> NamespaceConfig -> bwrap argv: default (unset) config matches real kernel support",
"[integration]") {
ScratchXdgDirs scratch;
auto config_path = scratch.path() / "config.yaml";
// Nothing set -- write_config_file()/load_config_file() round-trip an
// otherwise-empty AppConfig, so every unshare-* key comes back unset.
REQUIRE(write_config_file(config_path, AppConfig{}));
auto loaded = load_config_file(config_path);
REQUIRE(loaded.has_value());
CHECK_FALSE(loaded->unshare_net.has_value());
NamespaceConfig namespace_config{
loaded->unshare_user.value_or(true), loaded->unshare_ipc.value_or(true),
loaded->unshare_pid.value_or(true), loaded->unshare_net.value_or(true),
loaded->unshare_uts.value_or(true), loaded->unshare_cgroup.value_or(true),
};
auto argv = build_bwrap_args("/fake/root", {"true"}, {}, std::nullopt, std::nullopt, namespace_config, false);
// With every policy gate open, the only thing left restricting which
// --unshare-xxx flags actually appear is real kernel support -- so the
// resulting argv should exactly match detect_bwrap_unshare_args()'s
// own live probe of this host.
for (const auto& flag : detect_bwrap_unshare_args()) {
CHECK(contains(argv, flag));
}
}
+83
View File
@@ -0,0 +1,83 @@
// 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.
#include "fixtures.h"
#include <cstdlib>
#include <system_error>
#include <vector>
std::optional<std::filesystem::path> find_busybox_fixture() {
std::error_code ec;
auto path = std::filesystem::current_path(ec) / "images" / "busybox.tar";
if (ec || !std::filesystem::exists(path, ec) || ec) {
return std::nullopt;
}
return path;
}
namespace {
std::optional<std::string> getenv_opt(const char* name) {
const char* value = std::getenv(name);
if (!value) {
return std::nullopt;
}
return std::string(value);
}
} // namespace
ScratchXdgDirs::ScratchXdgDirs() {
// Real bug found by testing, not assumed: an earlier version built
// this vector from `std::string("...").begin()` paired with a
// *separate* `std::string("...").end()` -- two distinct temporary
// objects, even though they held identical content. Mixing iterators
// from two different containers when constructing a third is
// undefined behavior; here it manifested as an intermittent (SSO/heap
// address dependent, hence flaky) `std::length_error: cannot create
// std::vector larger than max_size()` from a garbage begin/end
// distance, confirmed via ~10 repeated runs of the [integration] tests
// together before it reproduced. Fixed by using one string instance
// for both ends of the range.
std::string tmpl_str = "/tmp/slocker-lite-test-XXXXXX";
std::vector<char> tmpl(tmpl_str.begin(), tmpl_str.end());
tmpl.push_back('\0');
char* created = mkdtemp(tmpl.data());
path_ = created ? std::filesystem::path(created) : std::filesystem::temp_directory_path() / "slocker-lite-test";
std::filesystem::create_directories(path_);
previous_config_home_ = getenv_opt("XDG_CONFIG_HOME");
previous_state_home_ = getenv_opt("XDG_STATE_HOME");
setenv("XDG_CONFIG_HOME", path_.c_str(), 1);
setenv("XDG_STATE_HOME", path_.c_str(), 1);
}
ScratchXdgDirs::~ScratchXdgDirs() {
if (previous_config_home_) {
setenv("XDG_CONFIG_HOME", previous_config_home_->c_str(), 1);
} else {
unsetenv("XDG_CONFIG_HOME");
}
if (previous_state_home_) {
setenv("XDG_STATE_HOME", previous_state_home_->c_str(), 1);
} else {
unsetenv("XDG_STATE_HOME");
}
std::error_code ec;
std::filesystem::remove_all(path_, ec);
}
+54
View File
@@ -0,0 +1,54 @@
// 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.
#pragma once
#include <filesystem>
#include <optional>
#include <string>
// Path to a real, runnable OCI Image Layout tar (something with an actual
// /bin/sh, unlike tests/gen_fixture.py's minimal single-file fixture used
// by the plain mount/unmount smoke test) for [integration][net] tests that
// need to actually run a command inside a container. Searches
// images/busybox.tar relative to the current working directory -- this
// project's own established manual-testing convention (see images/ at the
// repo root, gitignored). nullopt if not found; callers should SKIP()
// rather than fail, since fetching one is optional -- see
// tests/setup-tests.py.
std::optional<std::filesystem::path> find_busybox_fixture();
// RAII scratch XDG_CONFIG_HOME/XDG_STATE_HOME: for its lifetime, both env
// vars point at a fresh throwaway directory under /tmp, so
// config_file_path()/xdg_state_dir() (config_file.cpp/pid_file.cpp)
// resolve entirely under it instead of the real developer's own $HOME --
// integration tests that actually mount/run something must never touch
// real config/state. Restores whatever the two env vars were before (unset
// if they were unset) and removes the scratch directory on destruction.
class ScratchXdgDirs {
public:
ScratchXdgDirs();
~ScratchXdgDirs();
ScratchXdgDirs(const ScratchXdgDirs&) = delete;
ScratchXdgDirs& operator=(const ScratchXdgDirs&) = delete;
const std::filesystem::path& path() const { return path_; }
private:
std::filesystem::path path_;
std::optional<std::string> previous_config_home_;
std::optional<std::string> previous_state_home_;
};