diff --git a/CLAUDE.md b/CLAUDE.md index b2d2a38..3b996f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,29 +6,51 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `slocker-lite` (C++20, built with Meson) mounts an OCI Image Layout tar (`oci-layout` + `index.json` + `blobs/sha256/*`, as produced by `skopeo`/`podman save --format -oci-archive`/modern `docker save`) using `containers-storage` and `fuse-overlayfs`. Given -an image tar path, it validates the layout, imports each layer into containers-storage's -layer store in order (chained by parent), mounts the assembled top layer, and prints the -resulting merged path. There is no README or broader architecture doc yet — treat this -repo as still early-stage. +oci-archive`/modern `docker save`) using `containers-storage` and `fuse-overlayfs`, then +(via `-r/--run`) runs a sandboxed command against it with `bwrap`. The real deployment +target is Android with a stock kernel, where `podman`/`docker` don't run (missing +namespace support) and there's no kernel overlayfs (hence `fuse-overlayfs`); `bwrap` is +invoked in "degraded mode" using only whichever `--unshare-xxx` namespaces the running +kernel actually supports. There is no README or broader architecture doc yet — treat +this repo as still early-stage. Source layout (all under `src/`): -- `main.cpp` — CLI entry point, dependency checks, orchestration. +- `main.cpp` — CLI entry point, dependency checks, orchestration (`mount_image()`, + `run_container()`, `cleanup_image()`, `unmount_image()`). - `oci_image.{h,cpp}` — validates/parses the OCI Image Layout tar (libarchive + nlohmann_json) and extracts layer blobs. - `containers_storage.{h,cpp}` — wraps the `containers-storage` CLI (`import-layer`, - `mount`), forcing `fuse-overlayfs` as the overlay `mount_program`. -- `process.{h,cpp}` — argv-based subprocess helper (fork/execvp/pipe, no shell). + `mount`, `unmount`, `layer --json`, `delete-layer`), forcing `fuse-overlayfs` as the + overlay `mount_program`. `cleanup_layer_chain()` walks a layer's parent chain + (children before parents) deleting each one. +- `bwrap.{h,cpp}` — `detect_bwrap_unshare_args()` probes the kernel (via a forked + `unshare(2)` per namespace type) for which `--unshare-xxx` flags `bwrap` can actually + use; `build_bwrap_args()`/`run_bwrap()` assemble and run the sandboxed command. +- `process.{h,cpp}` — argv-based subprocess helpers (fork/execvp, no shell): + `run_process()` captures stdout (used for `containers-storage` calls), + `run_process_foreground()` inherits all of stdio (used for the interactive `bwrap` + run). Also `find_in_path()`, a shared `$PATH` lookup. Errors are logged via `spdlog::error`; every external command is also traced at debug -level in `run_process()` (`src/process.cpp`) — visible via `SPDLOG_LEVEL=debug`, since -spdlog's default level is `info` — and a failed external command additionally logs a -`spdlog::warn`, which is visible by default (no env var needed). The final "mounted -image at: ..." success line is direct stdout program output, not a log. +level in `run_process()`/`run_process_foreground()` (`src/process.cpp`) — visible via +`SPDLOG_LEVEL=debug`, since spdlog's default level is `info` — and a failed external +command additionally logs a `spdlog::warn`, which is visible by default (no env var +needed). The final "mounted image at: ..." success line is direct stdout program +output, not a log. -Because `containers-storage mount` runs rootless, the resulting mount lives in a private -user+mount namespace; the printed path is only directly usable from within that same -namespace (e.g. via `containers-storage unshare`), not from an arbitrary external shell. +Because `containers-storage mount` runs rootless, it reexecs itself into a private +user+mount namespace to gain the privilege it needs for the overlay mount — which +leaves the result invisible to a plain shell or child process outside that namespace. +Confirmed `containers-storage unshare` does **not** rejoin an already-running mount's +namespace; only `nsenter` targeting the live `fuse-overlayfs` daemon's PID does. +`-r/--run` handles this automatically by locating that PID and running `bwrap` via +`nsenter` into its namespaces. **Running as root sidesteps all of this**: no privilege +reexec is needed, so the mount is already directly visible in the current namespace, +and `nsenter --user=...` into it then fails ("reassociate to namespace 'ns/user' +failed: Invalid argument") since the caller is already in that same user namespace. +`-r/--run` detects `geteuid() == 0` and skips `nsenter` automatically in that case; +`-n/--no-nsenter` forces it off manually for any other situation where the mount turns +out to already be directly visible. ## Build & test commands @@ -37,8 +59,8 @@ Build directory is `buildDir/` (already configured). - Configure (only needed if `buildDir/` is missing or deleted): `meson setup buildDir` - Build: `meson compile -C buildDir` (or `ninja -C buildDir`) - Run the executable: `./buildDir/slocker_lite -m ` (see `--help` for the - full flag list: `-m/--mount`, `-u/--umount`, `-t/--test`, `-l/--log-level`, - `-h/--help`, `-V/--version`) + full flag list: `-m/--mount`, `-r/--run`, `-u/--umount`, `-c/--cleanup`, + `-n/--no-nsenter`, `-t/--test`, `-l/--log-level`, `-h/--help`, `-V/--version`) - Run tests: `meson test -C buildDir` ## Code style diff --git a/meson.build b/meson.build index 30d2bc2..08ce5bb 100644 --- a/meson.build +++ b/meson.build @@ -27,4 +27,4 @@ fixture_tar = custom_target('oci-fixture', output : 'fixture.tar', command : [find_program('python3'), files('tests/gen_fixture.py'), '@OUTPUT@']) -test('test', slocker_lite, args : ['-m', fixture_tar]) +test('test', find_program('python3'), args : [files('tests/run_test.py'), slocker_lite, fixture_tar]) diff --git a/src/bwrap.cpp b/src/bwrap.cpp index 7d0fee6..7a138a2 100644 --- a/src/bwrap.cpp +++ b/src/bwrap.cpp @@ -17,13 +17,23 @@ #include "bwrap.h" #include +#include #include #include +#include #include +#include +#include +#include +#include +#include +#include #include +#include "process.h" + namespace { struct NamespaceProbe { @@ -56,6 +66,49 @@ bool kernel_supports_namespace(int clone_flag) { return WIFEXITED(status) && WEXITSTATUS(status) == 0; } +// Scans /proc for a fuse-overlayfs process whose command line references +// `merged_path`, mirroring `ps aux | grep fuse-overlayfs`. /proc is inherently racy +// (processes come and go while it's being scanned), so filesystem errors from a +// vanished entry are treated as "not this one" rather than propagated. +std::optional find_fuse_overlayfs_pid(const std::string& merged_path) { + std::error_code ec; + auto it = std::filesystem::directory_iterator("/proc", ec); + if (ec) { + return std::nullopt; + } + + 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::error_code exe_ec; + auto exe_target = std::filesystem::read_symlink(entry.path() / "exe", exe_ec); + if (exe_ec || exe_target.filename() != "fuse-overlayfs") { + continue; + } + + std::ifstream cmdline_file(entry.path() / "cmdline", std::ios::binary); + std::string cmdline((std::istreambuf_iterator(cmdline_file)), + std::istreambuf_iterator()); + + size_t start = 0; + while (start < cmdline.size()) { + size_t end = cmdline.find('\0', start); + if (end == std::string::npos) { + end = cmdline.size(); + } + if (cmdline.compare(start, end - start, merged_path) == 0) { + return static_cast(std::stoi(name)); + } + start = end + 1; + } + } + return std::nullopt; +} + } // namespace std::vector detect_bwrap_unshare_args() { @@ -69,3 +122,80 @@ std::vector detect_bwrap_unshare_args() { } return args; } + +std::vector build_bwrap_args(const std::string& root, + const std::vector& command) { + std::vector args = {"bwrap", "--die-with-parent", "--new-session"}; + + auto unshare_args = detect_bwrap_unshare_args(); + bool has_pid_ns = false; + for (const auto& arg : unshare_args) { + args.push_back(arg); + if (arg == "--unshare-pid") { + has_pid_ns = true; + } + } + if (has_pid_ns) { + args.push_back("--as-pid-1"); + } + + std::vector filesystem_args = { + "--bind", + root, + "/", + "--proc", + "/proc", + "--dev", + "/dev", + "--perms", + "01777", + "--tmpfs", + "/dev/shm", + "--perms", + "01777", + "--tmpfs", + "/tmp", + "--chdir", + "/", + "--clearenv", + "--setenv", + "PATH", + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "--setenv", + "HOME", + "/root", + }; + args.insert(args.end(), filesystem_args.begin(), filesystem_args.end()); + + if (const char* term = std::getenv("TERM")) { + args.insert(args.end(), {"--setenv", "TERM", term}); + } + + args.push_back("--"); + args.insert(args.end(), command.begin(), command.end()); + + return args; +} + +int run_bwrap(const std::string& root, const std::vector& command, bool use_nsenter) { + std::vector argv; + + if (use_nsenter) { + auto pid = find_fuse_overlayfs_pid(root); + if (!pid) { + spdlog::error("could not find the fuse-overlayfs process serving {}", root); + return -1; + } + if (!find_in_path("nsenter")) { + spdlog::error("nsenter not found in PATH"); + return -1; + } + argv = {"nsenter", fmt::format("--user=/proc/{}/ns/user", *pid), + fmt::format("--mount=/proc/{}/ns/mnt", *pid), "--"}; + } + + auto bwrap_args = build_bwrap_args(root, command); + argv.insert(argv.end(), bwrap_args.begin(), bwrap_args.end()); + + return run_process_foreground(argv); +} diff --git a/src/bwrap.h b/src/bwrap.h index 207389f..974e602 100644 --- a/src/bwrap.h +++ b/src/bwrap.h @@ -25,3 +25,20 @@ // support (e.g. stock Android kernels), where blindly passing every // --unshare-xxx flag to bwrap would make it fail outright. std::vector detect_bwrap_unshare_args(); + +// Assembles the full bwrap argv (program name included) to run `command` with +// `root` bound as the sandbox's filesystem root, using whichever --unshare-xxx +// flags the kernel supports (see detect_bwrap_unshare_args()). +std::vector build_bwrap_args(const std::string& root, + const std::vector& command); + +// Runs bwrap against `root` (the merged mount path from mount_layer()) in the +// foreground and waits for it to exit. If `use_nsenter` is true, first locates the +// fuse-overlayfs process serving `root` and runs bwrap via nsenter into that +// process's user+mount namespaces -- needed because containers-storage mount +// (rootless) creates the overlay mount inside a private namespace invisible to a +// plain child process on kernels where fuse-overlayfs isolates it that way. Pass +// use_nsenter=false on kernels where the mount is already directly visible +// (observed on kernels older than 4.18, per fuse-overlayfs's own release notes). +// Returns bwrap's exit code, or -1 on failure to launch. +int run_bwrap(const std::string& root, const std::vector& command, bool use_nsenter); diff --git a/src/containers_storage.cpp b/src/containers_storage.cpp index a91b13b..03f3ae7 100644 --- a/src/containers_storage.cpp +++ b/src/containers_storage.cpp @@ -16,10 +16,15 @@ #include "containers_storage.h" +#include +#include + #include "process.h" namespace { +using json = nlohmann::json; + std::string trim(const std::string& s) { size_t begin = s.find_first_not_of(" \t\r\n"); if (begin == std::string::npos) { @@ -69,3 +74,47 @@ bool unmount_layer(const std::string& layer_id) { ProcessResult result = run_process(argv); return result.exit_code == 0; } + +std::optional get_layer_parent(const std::string& layer_id) { + std::vector argv = {"containers-storage", "layer", "--json", layer_id}; + + ProcessResult result = run_process(argv); + if (result.exit_code != 0) { + return std::nullopt; + } + + try { + json layers = json::parse(result.stdout_output); + if (!layers.is_array() || layers.empty()) { + return std::nullopt; + } + return layers[0].value("parent", ""); + } catch (const json::parse_error& e) { + spdlog::error("failed to parse layer info for {}: {}", layer_id, e.what()); + return std::nullopt; + } +} + +bool delete_layer(const std::string& layer_id) { + std::vector argv = {"containers-storage", "delete-layer", layer_id}; + + ProcessResult result = run_process(argv); + return result.exit_code == 0; +} + +bool cleanup_layer_chain(const std::string& top_layer_id) { + std::string layer_id = top_layer_id; + while (!layer_id.empty()) { + auto parent = get_layer_parent(layer_id); + if (!parent) { + spdlog::error("failed to look up parent of layer {}", layer_id); + return false; + } + if (!delete_layer(layer_id)) { + spdlog::error("failed to delete layer {}", layer_id); + return false; + } + layer_id = *parent; + } + return true; +} diff --git a/src/containers_storage.h b/src/containers_storage.h index 80f07d1..ef70d57 100644 --- a/src/containers_storage.h +++ b/src/containers_storage.h @@ -37,3 +37,17 @@ std::optional mount_layer(const std::string& layer_id); // Runs `containers-storage unmount `. Returns true on success. bool unmount_layer(const std::string& layer_id); + +// Runs `containers-storage layer --json ` and returns the layer's parent +// ID, or an empty string if it has none (i.e. it's a base layer). Returns nullopt on +// failure (layer not found, command failed, or unparsable output). +std::optional get_layer_parent(const std::string& layer_id); + +// Runs `containers-storage delete-layer `. Returns true on success. +bool delete_layer(const std::string& layer_id); + +// Deletes layer_id and, walking parent by parent, every ancestor layer (children +// before parents, so delete-layer's has-children safety check never trips). Stops at +// the first layer with no parent, or the first deletion/lookup failure. Returns true +// only if every layer in the chain was deleted successfully. +bool cleanup_layer_chain(const std::string& top_layer_id); diff --git a/src/main.cpp b/src/main.cpp index 861dace..4e0e42f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -31,36 +32,53 @@ #include "config.h" #include "containers_storage.h" #include "oci_image.h" +#include "process.h" namespace { constexpr std::array kRequiredTools = {"containers-storage", "bwrap"}; -enum class Mode { kNone, kMount, kUnmount, kTest }; +enum class Mode { kNone, kMount, kUnmount, kTest, kRun, kCleanup }; -constexpr std::array kLongOptions = {{ +constexpr std::array kLongOptions = {{ {"help", no_argument, nullptr, 'h'}, {"version", no_argument, nullptr, 'V'}, {"test", no_argument, nullptr, 't'}, {"log-level", required_argument, nullptr, 'l'}, {"mount", required_argument, nullptr, 'm'}, {"umount", required_argument, nullptr, 'u'}, + {"run", required_argument, nullptr, 'r'}, + {"cleanup", required_argument, nullptr, 'c'}, + {"no-nsenter", no_argument, nullptr, 'n'}, {nullptr, 0, nullptr, 0}, }}; void print_usage(const char* prog) { fmt::print( "usage: {0} -m|--mount \n" + " {0} -r|--run [-- [args...]]\n" " {0} -u|--umount \n" + " {0} -c|--cleanup \n" " {0} -t|--test\n" " {0} -h|--help\n" " {0} -V|--version\n" "\n" "options:\n" " -m, --mount validate and mount an OCI Image Layout tar\n" + " -r, --run mount, run bwrap in the foreground (default\n" + " command: /bin/sh; pass -- [args...]\n" + " to override), then unmount and clean up when\n" + " it exits\n" " -u, --umount unmount a previously mounted image layer (the\n" - " ID printed by --mount, or from\n" + " ID printed by --mount/--run, or from\n" " `containers-storage layers`)\n" + " -c, --cleanup delete a layer and its ancestor chain from\n" + " local storage (unmount it first with --umount)\n" + " -n, --no-nsenter with --run, bind the mount directly instead of\n" + " nsenter-ing into fuse-overlayfs's namespace\n" + " (this is automatic when running as root, where\n" + " the mount is already directly visible; pass\n" + " this to force it off otherwise)\n" " -t, --test run the test suite\n" " -l, --log-level set log verbosity (trace, debug, info, warn,\n" " error, critical, off)\n" @@ -98,34 +116,6 @@ int run_tests() { return 0; } -bool is_executable_file(const std::filesystem::path& path) { - std::error_code ec; - return std::filesystem::is_regular_file(path, ec) && access(path.c_str(), X_OK) == 0; -} - -std::optional find_in_path(std::string_view name) { - const char* path_env = std::getenv("PATH"); - if (!path_env) { - return std::nullopt; - } - std::string_view path{path_env}; - for (size_t start = 0; start <= path.size();) { - size_t end = path.find(':', start); - if (end == std::string_view::npos) { - end = path.size(); - } - std::filesystem::path dir{path.substr(start, end - start)}; - if (!dir.empty()) { - std::filesystem::path candidate = dir / name; - if (is_executable_file(candidate)) { - return candidate; - } - } - start = end + 1; - } - return std::nullopt; -} - } // namespace bool check_required_dependencies() { @@ -151,15 +141,101 @@ int unmount_image(const std::string& layer_id) { return 0; } +struct MountedImage { + std::string merged_path; + std::string top_layer_id; +}; + +std::optional mount_image(const std::filesystem::path& image_tar) { + if (!check_required_dependencies()) { + return std::nullopt; + } + + auto mount_program = find_in_path("fuse-overlayfs"); + if (!mount_program) { + spdlog::error("fuse-overlayfs not found in PATH"); + return std::nullopt; + } + kMountProgram = mount_program->string(); + + auto layers = read_oci_layers(image_tar); + if (!layers) { + return std::nullopt; + } + + std::string parent_id; + for (const auto& layer : *layers) { + std::filesystem::path tmp_file = + std::filesystem::temp_directory_path() / + fmt::format("slocker-lite-{}-{}", getpid(), oci_digest_hex(layer.digest)); + + if (!extract_blob_to_file(image_tar, oci_digest_hex(layer.digest), tmp_file)) { + return std::nullopt; + } + + auto layer_id = import_layer(tmp_file, parent_id); + std::filesystem::remove(tmp_file); + if (!layer_id) { + spdlog::error("failed to import layer {}", layer.digest); + return std::nullopt; + } + parent_id = *layer_id; + } + + auto merged = mount_layer(parent_id); + if (!merged) { + spdlog::error("failed to mount assembled image"); + return std::nullopt; + } + + return MountedImage{*merged, parent_id}; +} + +int cleanup_image(const std::string& layer_id) { + if (!check_required_dependencies()) { + return 1; + } + if (!cleanup_layer_chain(layer_id)) { + spdlog::error("failed to clean up layer {}", layer_id); + return 1; + } + fmt::print("cleaned up layer {}\n", layer_id); + return 0; +} + +int run_container(const std::filesystem::path& image_tar, const std::vector& command, + bool use_nsenter) { + auto mounted = mount_image(image_tar); + if (!mounted) { + return 1; + } + fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id); + + int exit_code = run_bwrap(mounted->merged_path, command, use_nsenter); + if (exit_code < 0) { + spdlog::error("failed to run bwrap"); + } + + if (!unmount_layer(mounted->top_layer_id)) { + spdlog::error("failed to unmount layer {}", mounted->top_layer_id); + } + if (!cleanup_layer_chain(mounted->top_layer_id)) { + spdlog::error("failed to clean up layer {}", mounted->top_layer_id); + } + + return exit_code < 0 ? 1 : exit_code; +} + int main(int argc, char* argv[]) { spdlog::cfg::load_env_levels(); Mode mode = Mode::kNone; std::string mode_arg; + bool disable_nsenter = false; opterr = 0; int opt; - while ((opt = getopt_long(argc, argv, ":hVtl:m:u:", kLongOptions.data(), nullptr)) != -1) { + while ((opt = getopt_long(argc, argv, ":hVtl:m:u:r:c:n", kLongOptions.data(), nullptr)) != -1) { switch (opt) { case 'h': print_usage(argv[0]); @@ -169,8 +245,27 @@ int main(int argc, char* argv[]) { return 0; case 't': case 'm': - case 'u': { - Mode requested = opt == 't' ? Mode::kTest : (opt == 'm' ? Mode::kMount : Mode::kUnmount); + case 'u': + case 'r': + case 'c': { + Mode requested; + switch (opt) { + case 't': + requested = Mode::kTest; + break; + case 'm': + requested = Mode::kMount; + break; + case 'u': + requested = Mode::kUnmount; + break; + case 'r': + requested = Mode::kRun; + break; + default: + requested = Mode::kCleanup; + break; + } if (mode != Mode::kNone && mode != requested) { spdlog::error("multiple actions specified"); print_usage(argv[0]); @@ -182,6 +277,9 @@ int main(int argc, char* argv[]) { } break; } + case 'n': + disable_nsenter = true; + break; case 'l': if (!apply_log_level(optarg)) { return 1; @@ -199,7 +297,11 @@ int main(int argc, char* argv[]) { } } - if (mode == Mode::kNone || optind != argc) { + if (mode == Mode::kNone) { + print_usage(argv[0]); + return 1; + } + if (mode != Mode::kRun && optind != argc) { print_usage(argv[0]); return 1; } @@ -210,50 +312,29 @@ int main(int argc, char* argv[]) { if (mode == Mode::kUnmount) { return unmount_image(mode_arg); } - - const std::filesystem::path image_tar = mode_arg; - - if (!check_required_dependencies()) { - return 1; + if (mode == Mode::kCleanup) { + return cleanup_image(mode_arg); } - - auto mount_program = find_in_path("fuse-overlayfs"); - if (!mount_program) { - spdlog::error("fuse-overlayfs not found in PATH"); - return 1; - } - kMountProgram = mount_program->string(); - - auto layers = read_oci_layers(image_tar); - if (!layers) { - return 1; - } - - std::string parent_id; - for (const auto& layer : *layers) { - std::filesystem::path tmp_file = - std::filesystem::temp_directory_path() / - fmt::format("slocker-lite-{}-{}", getpid(), oci_digest_hex(layer.digest)); - - if (!extract_blob_to_file(image_tar, oci_digest_hex(layer.digest), tmp_file)) { - return 1; + if (mode == Mode::kRun) { + std::vector command(argv + optind, argv + argc); + if (command.empty()) { + command = {"/bin/sh"}; } - - auto layer_id = import_layer(tmp_file, parent_id); - std::filesystem::remove(tmp_file); - if (!layer_id) { - spdlog::error("failed to import layer {}", layer.digest); - return 1; + // As root, containers-storage mount doesn't need to reexec into a private + // user namespace to gain privilege, so the mount is already directly + // visible; nsenter into it then fails ("reassociate to namespace 'ns/user' + // failed: Invalid argument") since we're already in that same namespace. + bool use_nsenter = !disable_nsenter && geteuid() != 0; + if (geteuid() == 0 && !disable_nsenter) { + spdlog::debug("running as root; skipping nsenter (the mount is already directly visible)"); } - parent_id = *layer_id; + return run_container(mode_arg, command, use_nsenter); } - auto merged = mount_layer(parent_id); - if (!merged) { - spdlog::error("failed to mount assembled image"); + auto mounted = mount_image(mode_arg); + if (!mounted) { return 1; } - - fmt::print("mounted image at: {} (layer {})\n", *merged, parent_id); + fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id); return 0; } diff --git a/src/process.cpp b/src/process.cpp index 5ffdd1e..9cbf29a 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -19,11 +19,31 @@ #include #include +#include #include #include #include +namespace { + +std::vector to_c_argv(const std::vector& argv) { + std::vector c_argv; + c_argv.reserve(argv.size() + 1); + for (const auto& arg : argv) { + c_argv.push_back(const_cast(arg.c_str())); + } + c_argv.push_back(nullptr); + return c_argv; +} + +bool is_executable_file(const std::filesystem::path& path) { + std::error_code ec; + return std::filesystem::is_regular_file(path, ec) && access(path.c_str(), X_OK) == 0; +} + +} // namespace + ProcessResult run_process(const std::vector& argv) { spdlog::debug("running external command: {}", fmt::join(argv, " ")); @@ -44,13 +64,7 @@ ProcessResult run_process(const std::vector& argv) { dup2(stdout_pipe[1], STDOUT_FILENO); close(stdout_pipe[1]); - std::vector c_argv; - c_argv.reserve(argv.size() + 1); - for (const auto& arg : argv) { - c_argv.push_back(const_cast(arg.c_str())); - } - c_argv.push_back(nullptr); - + auto c_argv = to_c_argv(argv); execvp(c_argv[0], c_argv.data()); const char* msg = "run_process: execvp failed\n"; write(STDERR_FILENO, msg, std::strlen(msg)); @@ -76,3 +90,52 @@ ProcessResult run_process(const std::vector& argv) { } return {exit_code, output}; } + +int run_process_foreground(const std::vector& argv) { + spdlog::debug("running external command: {}", fmt::join(argv, " ")); + + pid_t pid = fork(); + if (pid < 0) { + return -1; + } + + if (pid == 0) { + auto c_argv = to_c_argv(argv); + execvp(c_argv[0], c_argv.data()); + const char* msg = "run_process_foreground: execvp failed\n"; + write(STDERR_FILENO, msg, std::strlen(msg)); + _exit(127); + } + + int status = 0; + waitpid(pid, &status, 0); + + int exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : -1; + if (exit_code != 0) { + spdlog::warn("external command failed (exit code {}): {}", exit_code, fmt::join(argv, " ")); + } + return exit_code; +} + +std::optional find_in_path(std::string_view name) { + const char* path_env = std::getenv("PATH"); + if (!path_env) { + return std::nullopt; + } + std::string_view path{path_env}; + for (size_t start = 0; start <= path.size();) { + size_t end = path.find(':', start); + if (end == std::string_view::npos) { + end = path.size(); + } + std::filesystem::path dir{path.substr(start, end - start)}; + if (!dir.empty()) { + std::filesystem::path candidate = dir / name; + if (is_executable_file(candidate)) { + return candidate; + } + } + start = end + 1; + } + return std::nullopt; +} diff --git a/src/process.h b/src/process.h index 82464aa..a769383 100644 --- a/src/process.h +++ b/src/process.h @@ -16,7 +16,10 @@ #pragma once +#include +#include #include +#include #include struct ProcessResult { @@ -27,3 +30,12 @@ struct ProcessResult { // Runs argv[0] with the given arguments via fork/execvp, capturing stdout. // stderr is inherited so the child's own error messages reach the user directly. ProcessResult run_process(const std::vector& argv); + +// Runs argv[0] with the given arguments via fork/execvp, with stdin/stdout/stderr +// all inherited from the caller (no output capture) for interactive/foreground use. +// Returns the exit code, or -1 if fork or exec failed. +int run_process_foreground(const std::vector& argv); + +// Searches $PATH for an executable regular file named `name`, in PATH order. +// Returns its full path, or nullopt if not found. +std::optional find_in_path(std::string_view name); diff --git a/tests/run_test.py b/tests/run_test.py new file mode 100644 index 0000000..5c5c1bb --- /dev/null +++ b/tests/run_test.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Runs the mount -> umount -> cleanup cycle against the fixture image and checks +that every step succeeds, so `meson test` doesn't leak a layer on every run. +""" + +import re +import subprocess +import sys + + +def run(slocker_lite: str, *args: str) -> subprocess.CompletedProcess: + return subprocess.run([slocker_lite, *args], capture_output=True, text=True) + + +def main() -> int: + slocker_lite, fixture_tar = sys.argv[1], sys.argv[2] + + mount = run(slocker_lite, "-m", fixture_tar) + if mount.returncode != 0: + print(mount.stderr, file=sys.stderr) + return mount.returncode + + match = re.search(r"\(layer (\S+)\)", mount.stdout) + if not match: + print(f"could not find layer ID in mount output: {mount.stdout!r}", file=sys.stderr) + return 1 + layer_id = match.group(1) + + for args in (("-u", layer_id), ("-c", layer_id)): + result = run(slocker_lite, *args) + if result.returncode != 0: + print(result.stderr, file=sys.stderr) + return result.returncode + + return 0 + + +if __name__ == "__main__": + sys.exit(main())