Initial commit: OCI image mounting via containers-storage
slocker-lite validates an OCI Image Layout tar, imports its layers into containers-storage's layer store in order, and mounts the assembled image using fuse-overlayfs, printing the resulting merged path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
// 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 "containers_storage.h"
|
||||
|
||||
#include "process.h"
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(const std::string& s) {
|
||||
size_t begin = s.find_first_not_of(" \t\r\n");
|
||||
if (begin == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
size_t end = s.find_last_not_of(" \t\r\n");
|
||||
return s.substr(begin, end - begin + 1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string kMountProgram;
|
||||
|
||||
std::optional<std::string> import_layer(const std::filesystem::path& diff_file,
|
||||
const std::string& parent_id) {
|
||||
std::vector<std::string> argv = {
|
||||
"containers-storage", "import-layer", "--file", diff_file.string(),
|
||||
"--storage-opt", "overlay.mount_program=" + kMountProgram};
|
||||
if (!parent_id.empty()) {
|
||||
argv.push_back(parent_id);
|
||||
}
|
||||
|
||||
ProcessResult result = run_process(argv);
|
||||
std::string id = trim(result.stdout_output);
|
||||
if (result.exit_code != 0 || id.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
std::optional<std::string> mount_layer(const std::string& layer_id) {
|
||||
std::vector<std::string> argv = {
|
||||
"containers-storage", "mount", "--storage-opt",
|
||||
"overlay.mount_program=" + kMountProgram, layer_id};
|
||||
|
||||
ProcessResult result = run_process(argv);
|
||||
std::string path = trim(result.stdout_output);
|
||||
if (result.exit_code != 0 || path.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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>
|
||||
|
||||
// Absolute path to the fuse-overlayfs binary, passed to containers-storage as
|
||||
// overlay.mount_program so it performs the actual overlay mounting. Set once in main()
|
||||
// after find_in_path() resolves it.
|
||||
extern std::string kMountProgram;
|
||||
|
||||
// Runs `containers-storage import-layer --storage-opt overlay.mount_program=<path>
|
||||
// [--file diff_file] [parent_id]`. Returns the new layer's ID (trimmed stdout) or
|
||||
// nullopt on failure (containers-storage's own stderr is inherited/visible).
|
||||
std::optional<std::string> import_layer(const std::filesystem::path& diff_file,
|
||||
const std::string& parent_id);
|
||||
|
||||
// Runs `containers-storage mount --storage-opt overlay.mount_program=<path> <layer_id>`.
|
||||
// Returns the merged directory path (trimmed stdout) or nullopt on failure.
|
||||
std::optional<std::string> mount_layer(const std::string& layer_id);
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// 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 <array>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <fmt/core.h>
|
||||
#include <spdlog/cfg/env.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include "containers_storage.h"
|
||||
#include "oci_image.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::array<std::string_view, 2> kRequiredTools = {"containers-storage", "bwrap"};
|
||||
|
||||
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<std::filesystem::path> 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() {
|
||||
bool all_found = true;
|
||||
for (auto name : kRequiredTools) {
|
||||
if (!find_in_path(name)) {
|
||||
spdlog::error("required dependency not found in PATH: {}", name);
|
||||
all_found = false;
|
||||
}
|
||||
}
|
||||
return all_found;
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
spdlog::cfg::load_env_levels();
|
||||
|
||||
if (argc != 2) {
|
||||
spdlog::error("usage: {} <image.tar>", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
const std::filesystem::path image_tar = argv[1];
|
||||
|
||||
if (!check_required_dependencies()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
parent_id = *layer_id;
|
||||
}
|
||||
|
||||
auto merged = mount_layer(parent_id);
|
||||
if (!merged) {
|
||||
spdlog::error("failed to mount assembled image");
|
||||
return 1;
|
||||
}
|
||||
|
||||
fmt::print("mounted image at: {}\n", *merged);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// 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 "oci_image.h"
|
||||
|
||||
#include <archive.h>
|
||||
#include <archive_entry.h>
|
||||
#include <fmt/core.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
|
||||
namespace {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
std::string_view normalize_entry_path(std::string_view name) {
|
||||
if (name.substr(0, 2) == "./") {
|
||||
name.remove_prefix(2);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// Reads a single tar entry's content, calling `sink` with each chunk of data as it's
|
||||
// read. `sink` is only invoked for the entry whose normalized path equals `target_name`.
|
||||
// Returns true if the entry was found (regardless of whether it was empty).
|
||||
bool for_each_chunk_in_entry(const std::filesystem::path& tar_path, std::string_view target_name,
|
||||
const std::function<void(const char*, size_t)>& sink) {
|
||||
struct archive* a = archive_read_new();
|
||||
archive_read_support_filter_all(a);
|
||||
archive_read_support_format_tar(a);
|
||||
|
||||
if (archive_read_open_filename(a, tar_path.c_str(), 65536) != ARCHIVE_OK) {
|
||||
archive_read_free(a);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool found = false;
|
||||
struct archive_entry* entry;
|
||||
while (archive_read_next_header(a, &entry) == ARCHIVE_OK) {
|
||||
if (normalize_entry_path(archive_entry_pathname(entry)) != target_name) {
|
||||
archive_read_data_skip(a);
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
const void* buff;
|
||||
size_t size;
|
||||
int64_t offset;
|
||||
while (true) {
|
||||
int rc = archive_read_data_block(a, &buff, &size, &offset);
|
||||
if (rc == ARCHIVE_EOF) {
|
||||
break;
|
||||
}
|
||||
if (rc != ARCHIVE_OK) {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
sink(static_cast<const char*>(buff), size);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
archive_read_free(a);
|
||||
return found;
|
||||
}
|
||||
|
||||
std::optional<std::string> read_entry_to_string(const std::filesystem::path& tar_path,
|
||||
std::string_view target_name) {
|
||||
std::string content;
|
||||
bool found = for_each_chunk_in_entry(tar_path, target_name, [&](const char* data, size_t size) {
|
||||
content.append(data, size);
|
||||
});
|
||||
if (!found) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string oci_digest_hex(std::string_view digest) {
|
||||
constexpr std::string_view prefix = "sha256:";
|
||||
if (digest.substr(0, prefix.size()) == prefix) {
|
||||
digest.remove_prefix(prefix.size());
|
||||
}
|
||||
return std::string(digest);
|
||||
}
|
||||
|
||||
bool extract_blob_to_file(const std::filesystem::path& tar_path, std::string_view digest_hex,
|
||||
const std::filesystem::path& out_file) {
|
||||
std::string entry_name = fmt::format("blobs/sha256/{}", digest_hex);
|
||||
|
||||
std::ofstream out(out_file, std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
spdlog::error("failed to open {} for writing", out_file.string());
|
||||
return false;
|
||||
}
|
||||
|
||||
bool found = for_each_chunk_in_entry(tar_path, entry_name,
|
||||
[&](const char* data, size_t size) { out.write(data, static_cast<std::streamsize>(size)); });
|
||||
if (!found) {
|
||||
spdlog::error("blob {} not found in {}", entry_name, tar_path.string());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<std::vector<OciLayer>> read_oci_layers(const std::filesystem::path& tar_path) {
|
||||
auto layout = read_entry_to_string(tar_path, "oci-layout");
|
||||
if (!layout) {
|
||||
spdlog::error("not an OCI image tar: missing oci-layout");
|
||||
return std::nullopt;
|
||||
}
|
||||
try {
|
||||
[[maybe_unused]] json parsed_layout = json::parse(*layout);
|
||||
} catch (const json::parse_error& e) {
|
||||
spdlog::error("oci-layout is not valid JSON: {}", e.what());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
auto index_content = read_entry_to_string(tar_path, "index.json");
|
||||
if (!index_content) {
|
||||
spdlog::error("not an OCI image tar: missing index.json");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
json index;
|
||||
try {
|
||||
index = json::parse(*index_content);
|
||||
} catch (const json::parse_error& e) {
|
||||
spdlog::error("index.json is not valid JSON: {}", e.what());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string manifest_digest;
|
||||
for (const auto& m : index.value("manifests", json::array())) {
|
||||
if (m.value("mediaType", "") == "application/vnd.oci.image.manifest.v1+json") {
|
||||
manifest_digest = m.value("digest", "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (manifest_digest.empty()) {
|
||||
spdlog::error("index.json has no OCI image manifest entry");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string manifest_entry = fmt::format("blobs/sha256/{}", oci_digest_hex(manifest_digest));
|
||||
auto manifest_content = read_entry_to_string(tar_path, manifest_entry);
|
||||
if (!manifest_content) {
|
||||
spdlog::error("missing image manifest blob {}", manifest_digest);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
json manifest;
|
||||
try {
|
||||
manifest = json::parse(*manifest_content);
|
||||
} catch (const json::parse_error& e) {
|
||||
spdlog::error("image manifest {} is not valid JSON: {}", manifest_digest, e.what());
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<OciLayer> layers;
|
||||
for (const auto& l : manifest.value("layers", json::array())) {
|
||||
OciLayer layer;
|
||||
layer.digest = l.value("digest", "");
|
||||
layer.media_type = l.value("mediaType", "");
|
||||
if (layer.digest.empty()) {
|
||||
spdlog::error("image manifest {} has a layer with no digest", manifest_digest);
|
||||
return std::nullopt;
|
||||
}
|
||||
layers.push_back(std::move(layer));
|
||||
}
|
||||
|
||||
if (layers.empty()) {
|
||||
spdlog::error("image manifest {} has no layers", manifest_digest);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
struct OciLayer {
|
||||
std::string digest; // "sha256:<hex>", as it appears in the manifest
|
||||
std::string media_type;
|
||||
};
|
||||
|
||||
// Validates that `tar_path` is an OCI Image Layout tar (oci-layout + index.json +
|
||||
// a referenced blobs/sha256/<digest> image manifest) and returns its layers in
|
||||
// base-to-top order. Logs a specific error via fmt and returns nullopt if anything
|
||||
// required is missing or malformed.
|
||||
std::optional<std::vector<OciLayer>> read_oci_layers(const std::filesystem::path& tar_path);
|
||||
|
||||
// Extracts the raw bytes of blobs/sha256/<digest_hex> from `tar_path` into `out_file`.
|
||||
// Returns false (and logs) if the entry isn't found.
|
||||
bool extract_blob_to_file(const std::filesystem::path& tar_path,
|
||||
std::string_view digest_hex,
|
||||
const std::filesystem::path& out_file);
|
||||
|
||||
// Strips the "sha256:" algorithm prefix from a digest string, e.g.
|
||||
// "sha256:abcd" -> "abcd". Returns the input unchanged if there is no such prefix.
|
||||
std::string oci_digest_hex(std::string_view digest);
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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 "process.h"
|
||||
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include <fmt/ranges.h>
|
||||
#include <spdlog/spdlog.h>
|
||||
|
||||
ProcessResult run_process(const std::vector<std::string>& argv) {
|
||||
spdlog::debug("running external command: {}", fmt::join(argv, " "));
|
||||
|
||||
int stdout_pipe[2];
|
||||
if (pipe(stdout_pipe) != 0) {
|
||||
return {-1, ""};
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
close(stdout_pipe[0]);
|
||||
close(stdout_pipe[1]);
|
||||
return {-1, ""};
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
close(stdout_pipe[0]);
|
||||
dup2(stdout_pipe[1], STDOUT_FILENO);
|
||||
close(stdout_pipe[1]);
|
||||
|
||||
std::vector<char*> c_argv;
|
||||
c_argv.reserve(argv.size() + 1);
|
||||
for (const auto& arg : argv) {
|
||||
c_argv.push_back(const_cast<char*>(arg.c_str()));
|
||||
}
|
||||
c_argv.push_back(nullptr);
|
||||
|
||||
execvp(c_argv[0], c_argv.data());
|
||||
const char* msg = "run_process: execvp failed\n";
|
||||
write(STDERR_FILENO, msg, std::strlen(msg));
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
close(stdout_pipe[1]);
|
||||
|
||||
std::string output;
|
||||
char buffer[4096];
|
||||
ssize_t n;
|
||||
while ((n = read(stdout_pipe[0], buffer, sizeof(buffer))) > 0) {
|
||||
output.append(buffer, static_cast<size_t>(n));
|
||||
}
|
||||
close(stdout_pipe[0]);
|
||||
|
||||
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, output};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// 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 <string>
|
||||
#include <vector>
|
||||
|
||||
struct ProcessResult {
|
||||
int exit_code;
|
||||
std::string stdout_output;
|
||||
};
|
||||
|
||||
// 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<std::string>& argv);
|
||||
Reference in New Issue
Block a user