diff --git a/meson.build b/meson.build index c9d8a52..4cb18c2 100644 --- a/meson.build +++ b/meson.build @@ -27,6 +27,8 @@ if get_option('enable_tests') 'tests/unit/test_env_spec.cpp', 'tests/unit/test_network_subnet.cpp', 'tests/unit/test_cli_args.cpp', + 'tests/support/fixtures.cpp', + 'tests/integration/test_config_bwrap_chain.cpp', 'tests/integration/test_root_networking.cpp', ] endif @@ -40,7 +42,7 @@ slocker_lite = executable('slocker-lite', 'src/network_subnet.cpp', 'src/persistent_netns.cpp', 'src/network_bridge.cpp', 'src/network_join.cpp', 'src/port_forward.cpp', 'src/network_tap_relay.cpp', 'src/network_dns.cpp'] + test_sources, - include_directories : include_directories('.', 'src'), + include_directories : include_directories('.', 'src', 'tests/support'), dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep], install : true) diff --git a/tests/integration/test_config_bwrap_chain.cpp b/tests/integration/test_config_bwrap_chain.cpp new file mode 100644 index 0000000..b4d6a3a --- /dev/null +++ b/tests/integration/test_config_bwrap_chain.cpp @@ -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 +#include +#include + +#include + +#include "bwrap.h" +#include "config_file.h" +#include "fixtures.h" + +namespace { + +bool contains(const std::vector& 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(false)); + CHECK(loaded->unshare_uts == std::optional(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)); + } +} diff --git a/tests/support/fixtures.cpp b/tests/support/fixtures.cpp new file mode 100644 index 0000000..9b78769 --- /dev/null +++ b/tests/support/fixtures.cpp @@ -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 +#include +#include + +std::optional 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 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 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); +} diff --git a/tests/support/fixtures.h b/tests/support/fixtures.h new file mode 100644 index 0000000..8a94a45 --- /dev/null +++ b/tests/support/fixtures.h @@ -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 +#include +#include + +// 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 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 previous_config_home_; + std::optional previous_state_home_; +};