Reuse -v/--volume to mount named/host volumes into -r/--run

Volume names now reject '/', which lets a -v spec used with -r be told
apart as either an existing named volume or a host directory path. -v
becomes repeatable with -r, each mounting a volume at an absolute
container path; if the host directory is empty and the image already
has content there, it's copied in first (preserving numeric
ownership/permissions/links/xattrs-ACLs, degrading gracefully with a
warning if the host filesystem doesn't support xattrs). The
existence-check and copy run through the same nsenter-wrapped
namespace bwrap itself needs, since a rootless containers-storage
mount's content isn't otherwise visible to this process at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-08-21 17:08:16 +00:00
parent d69b408d14
commit 545762d6de
8 changed files with 397 additions and 57 deletions
+33 -19
View File
@@ -178,6 +178,7 @@ std::vector<std::string> detect_bwrap_unshare_args() {
std::vector<std::string> build_bwrap_args(const std::string& root,
const std::vector<std::string>& command,
const std::vector<ResolvedVolumeMount>& volumes,
std::optional<ResolvedUser> user) {
// --new-session detaches from the controlling terminal, which breaks job
// control for an interactive foreground shell ("can't access tty"). Re-enable
@@ -232,6 +233,10 @@ std::vector<std::string> build_bwrap_args(const std::string& root,
};
args.insert(args.end(), filesystem_args.begin(), filesystem_args.end());
for (const auto& volume : volumes) {
args.insert(args.end(), {"--bind", volume.host_directory, volume.container_path});
}
if (const char* term = std::getenv("TERM")) {
args.insert(args.end(), {"--setenv", "TERM", term});
}
@@ -262,32 +267,41 @@ std::vector<std::string> build_bwrap_args(const std::string& root,
return args;
}
int run_bwrap(const std::string& root, const std::vector<std::string>& command, bool use_nsenter,
std::optional<ResolvedUser> user) {
std::vector<std::string> argv;
std::optional<std::vector<std::string>> wrap_for_root_namespace(const std::string& root, bool use_nsenter,
const std::vector<std::string>& argv) {
if (!use_nsenter) {
return argv;
}
auto pid = find_fuse_overlayfs_pid(root);
if (!pid) {
spdlog::error("could not find the fuse-overlayfs process serving {}", root);
return std::nullopt;
}
if (!find_in_path("nsenter")) {
spdlog::error("nsenter not found in PATH");
return std::nullopt;
}
std::vector<std::string> wrapped = {"nsenter", fmt::format("--user=/proc/{}/ns/user", *pid),
fmt::format("--mount=/proc/{}/ns/mnt", *pid), "--"};
wrapped.insert(wrapped.end(), argv.begin(), argv.end());
return wrapped;
}
int run_bwrap(const std::string& root, const std::vector<std::string>& command, bool use_nsenter,
const std::vector<ResolvedVolumeMount>& volumes, std::optional<ResolvedUser> user) {
if (user && !find_priv_drop_helper()) {
spdlog::error("could not find the {} helper next to this binary; --user/--group requires it",
kPrivDropHelperName);
return -1;
}
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, volumes, user);
auto argv = wrap_for_root_namespace(root, use_nsenter, bwrap_args);
if (!argv) {
return -1;
}
auto bwrap_args = build_bwrap_args(root, command, user);
argv.insert(argv.end(), bwrap_args.begin(), bwrap_args.end());
return run_process_foreground(argv);
return run_process_foreground(*argv);
}
+23 -5
View File
@@ -20,6 +20,8 @@
#include <string>
#include <vector>
#include "volume_mount.h"
// Probes the running kernel for which Linux namespace types can actually be
// unshared and returns the corresponding bwrap --unshare-xxx flags for the
// ones that are supported. Intended for kernels with partial namespace
@@ -27,6 +29,19 @@
// --unshare-xxx flag to bwrap would make it fail outright.
std::vector<std::string> detect_bwrap_unshare_args();
// Wraps `argv` so it runs inside the mount+user namespace that containers-storage's
// rootless fuse-overlayfs daemon created for `root` (the merged mount path from
// mount_layer()), via nsenter -- needed to reach anything under `root` at all from a
// plain process outside that namespace (containers-storage mount reexecs itself
// into a private namespace to get the privilege an unprivileged overlay mount
// needs; only nsenter targeting that live daemon's PID can rejoin it). If
// use_nsenter is false, returns argv unchanged (the mount is already directly
// visible, e.g. when running as root -- see run_container() in main.cpp). Returns
// nullopt (and logs) if the fuse-overlayfs process or nsenter itself can't be
// found.
std::optional<std::vector<std::string>> wrap_for_root_namespace(const std::string& root, bool use_nsenter,
const std::vector<std::string>& argv);
struct ResolvedUser {
int uid;
int gid;
@@ -34,12 +49,15 @@ struct ResolvedUser {
// 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()). If `user` is set,
// the command is wrapped so it drops to that uid/gid before running -- see
// run_bwrap() for how, since bwrap's own --uid/--gid require --unshare-user, which
// isn't requested when running as root (see detect_bwrap_unshare_args()).
// flags the kernel supports (see detect_bwrap_unshare_args()). Each entry in
// `volumes` is bound writably at its container_path (see resolve_volume_mount()
// in volume_mount.h). If `user` is set, the command is wrapped so it drops to that
// uid/gid before running -- see run_bwrap() for how, since bwrap's own --uid/--gid
// require --unshare-user, which isn't requested when running as root (see
// detect_bwrap_unshare_args()).
std::vector<std::string> build_bwrap_args(const std::string& root,
const std::vector<std::string>& command,
const std::vector<ResolvedVolumeMount>& volumes,
std::optional<ResolvedUser> user);
// Runs bwrap against `root` (the merged mount path from mount_layer()) in the
@@ -54,4 +72,4 @@ std::vector<std::string> build_bwrap_args(const std::string& root,
// used to drop privileges to that uid/gid before running `command` -- see
// build_bwrap_args(). Returns bwrap's exit code, or -1 on failure to launch.
int run_bwrap(const std::string& root, const std::vector<std::string>& command, bool use_nsenter,
std::optional<ResolvedUser> user);
const std::vector<ResolvedVolumeMount>& volumes, std::optional<ResolvedUser> user);
+82 -18
View File
@@ -37,6 +37,7 @@
#include "oci_image.h"
#include "process.h"
#include "user_spec.h"
#include "volume_mount.h"
namespace {
@@ -92,7 +93,7 @@ constexpr std::array<struct option, 18> kLongOptions = {{
void print_usage(const char* prog) {
fmt::print(
"usage: {0} -m|--mount <image.tar>\n"
" {0} -r|--run <image.tar> [-- <command> [args...]]\n"
" {0} -r|--run <image.tar> [-v <name-or-dir> <container-path>]... [-- <command> [args...]]\n"
" {0} -u|--umount <layer-id>\n"
" {0} -c|--cleanup <layer-id>\n"
" {0} -l|--list-images <directory>\n"
@@ -137,7 +138,14 @@ void print_usage(const char* prog) {
" mounting or running it\n"
" -v, --volume <name> <dir> create a named volume mapped to a host directory\n"
" (created if missing), recorded in the config\n"
" file's volumes section\n"
" file's volumes section. With --run, instead\n"
" mount a volume into the sandbox: <name> is a\n"
" named volume or, if it contains '/', a host\n"
" directory (created if missing); <dir> is the\n"
" absolute path inside the container to mount it\n"
" at. May be repeated with --run. If the host\n"
" directory is empty and the image already has\n"
" content there, it's copied in first\n"
" --list-volumes list all named volumes (see -v/--volume) with\n"
" their host directory\n"
" --delete-volume <name>\n"
@@ -351,6 +359,11 @@ int inspect_image_command(const std::filesystem::path& image_tar) {
int create_volume_command(const std::string& name, const std::string& directory,
const std::filesystem::path& config_path, AppConfig& config) {
if (!is_valid_volume_name(name)) {
spdlog::error("volume name '{}' must not contain '/'", name);
return 1;
}
std::filesystem::path resolved = std::filesystem::absolute(directory).lexically_normal();
for (const auto& volume : config.volumes) {
@@ -439,7 +452,9 @@ int delete_volume_command(const std::string& name, const std::filesystem::path&
int run_container(const std::filesystem::path& image_tar,
const std::vector<std::string>& requested_command, bool use_nsenter,
const std::optional<std::string>& user, const std::optional<std::string>& group) {
const std::optional<std::string>& user, const std::optional<std::string>& group,
const std::vector<std::pair<std::string, std::string>>& volume_specs,
const AppConfig& app_config) {
auto mounted = mount_image(image_tar);
if (!mounted) {
return 1;
@@ -448,6 +463,35 @@ int run_container(const std::filesystem::path& image_tar,
auto config = read_oci_image_config(image_tar);
bool ok = true;
std::vector<ResolvedVolumeMount> volume_mounts;
for (const auto& [spec, container_path] : volume_specs) {
if (container_path.empty() || container_path.front() != '/') {
spdlog::error("volume container path '{}' must be absolute", container_path);
ok = false;
continue;
}
bool duplicate = std::any_of(
volume_mounts.begin(), volume_mounts.end(),
[&](const ResolvedVolumeMount& mount) { return mount.container_path == container_path; });
if (duplicate) {
spdlog::error("volume container path '{}' is mounted more than once", container_path);
ok = false;
continue;
}
// use_nsenter also governs whether resolve_volume_mount() needs nsenter to see
// the image's own content when populating an empty volume -- the same
// rootless-mount visibility constraint run_bwrap() itself works around.
auto resolved =
resolve_volume_mount(spec, container_path, app_config, mounted->merged_path, use_nsenter);
if (!resolved) {
ok = false;
continue;
}
volume_mounts.push_back(std::move(*resolved));
}
// Falls back to the image's own declared user (config.User) when --user wasn't
// given on the command line, rather than always defaulting to root.
std::optional<std::string> effective_user = user;
@@ -457,7 +501,6 @@ int run_container(const std::filesystem::path& image_tar,
effective_group = config->group.empty() ? std::nullopt : std::optional<std::string>(config->group);
}
bool ok = true;
std::optional<ResolvedUser> resolved_user;
if (effective_user) {
resolved_user = resolve_user_and_group(*effective_user, effective_group, mounted->merged_path);
@@ -474,7 +517,7 @@ int run_container(const std::filesystem::path& image_tar,
int exit_code = -1;
if (ok) {
exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, resolved_user);
exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, volume_mounts, resolved_user);
if (exit_code < 0) {
spdlog::error("failed to run bwrap");
}
@@ -507,6 +550,7 @@ int main(int argc, char* argv[]) {
bool disable_nsenter = false;
std::optional<std::string> user_flag;
std::optional<std::string> group_flag;
std::vector<std::pair<std::string, std::string>> volume_specs;
opterr = 0;
int opt;
@@ -524,7 +568,6 @@ int main(int argc, char* argv[]) {
case 'r':
case 'c':
case 'l':
case 'v':
case 'i':
case kListVolumesOpt:
case kDeleteVolumeOpt:
@@ -549,9 +592,6 @@ int main(int argc, char* argv[]) {
case 'l':
requested = Mode::kListImages;
break;
case 'v':
requested = Mode::kVolume;
break;
case 'i':
requested = Mode::kInspect;
break;
@@ -576,6 +616,21 @@ int main(int argc, char* argv[]) {
}
break;
}
case 'v': {
// -v/--volume takes two tokens: optarg (the name or host path) plus
// the immediately following argv entry (the directory, or, with -r,
// the container path). Consumed manually (rather than via getopt's
// own required_argument) so -v can repeat with -r -- see main()'s
// post-loop handling for how the two uses are told apart.
if (optind >= argc || (argv[optind][0] == '-' && argv[optind][1] != '\0')) {
spdlog::error("--volume requires a name/path and a directory or container path");
print_usage(argv[0]);
return 1;
}
volume_specs.emplace_back(optarg, argv[optind]);
++optind;
break;
}
case 'n':
disable_nsenter = true;
break;
@@ -602,17 +657,25 @@ int main(int argc, char* argv[]) {
}
}
if (!volume_specs.empty() && mode != Mode::kRun) {
if (mode != Mode::kNone) {
spdlog::error("--volume can only be used standalone or together with --run");
print_usage(argv[0]);
return 1;
}
if (volume_specs.size() > 1) {
spdlog::error("--volume can only be used once outside of --run");
print_usage(argv[0]);
return 1;
}
mode = Mode::kVolume;
}
if (mode == Mode::kNone) {
print_usage(argv[0]);
return 1;
}
if (mode == Mode::kVolume) {
if (optind + 1 != argc) {
spdlog::error("--volume requires a name and a directory");
print_usage(argv[0]);
return 1;
}
} else if (mode != Mode::kRun && optind != argc) {
if (mode != Mode::kRun && optind != argc) {
print_usage(argv[0]);
return 1;
}
@@ -638,7 +701,8 @@ int main(int argc, char* argv[]) {
return inspect_image_command(mode_arg);
}
if (mode == Mode::kVolume) {
return create_volume_command(mode_arg, argv[optind], config_path, *config);
return create_volume_command(volume_specs.front().first, volume_specs.front().second, config_path,
*config);
}
if (mode == Mode::kListVolumes) {
return list_volumes_command(*config);
@@ -656,7 +720,7 @@ int main(int argc, char* argv[]) {
if (geteuid() == 0 && !disable_nsenter) {
spdlog::debug("running as root; skipping nsenter (the mount is already directly visible)");
}
return run_container(mode_arg, command, use_nsenter, user_flag, group_flag);
return run_container(mode_arg, command, use_nsenter, user_flag, group_flag, volume_specs, *config);
}
auto mounted = mount_image(mode_arg);
+145
View File
@@ -0,0 +1,145 @@
// 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 "volume_mount.h"
#include <sys/xattr.h>
#include <algorithm>
#include <filesystem>
#include <string>
#include <vector>
#include <fmt/core.h>
#include <spdlog/spdlog.h>
#include "bwrap.h"
#include "process.h"
namespace {
// Probes whether `dir`'s filesystem supports (user) extended attributes by
// setting and immediately removing a throwaway one. Linux POSIX ACLs are
// themselves stored as xattrs, so this one probe covers both.
bool xattr_supported(const std::filesystem::path& dir) {
constexpr const char* kProbeName = "user.slocker-lite.probe";
if (setxattr(dir.c_str(), kProbeName, "1", 1, 0) != 0) {
return false;
}
removexattr(dir.c_str(), kProbeName);
return true;
}
// If `image_side` (a path under the mounted image root) turns out to be a
// non-empty directory, recursively copies its contents into the already-existing
// `dst`, preserving mode/ownership/timestamps/links (and xattrs/ACLs where dst's
// filesystem supports them). Both the existence check and the copy run as a single
// `sh -c` invocation wrapped through wrap_for_root_namespace(): a rootless
// containers-storage mount lives in a private namespace this process can't see
// into directly (the same reason run_bwrap() itself needs nsenter), so a plain
// std::filesystem check on `image_side` from here would see nothing at all. Never
// treated as fatal: a nonzero exit is very often just an ownership-preservation
// shortfall when not running as root (chown() -> EPERM) rather than a real
// failure, and cp's own stderr is inherited straight to the user (see process.h),
// so -r/--run proceeds with whatever did copy.
void copy_if_present(const std::string& root, bool use_nsenter, const std::filesystem::path& image_side,
const std::filesystem::path& dst) {
if (!find_in_path("cp") || !find_in_path("sh")) {
spdlog::warn("cp/sh not found in PATH; cannot populate volume directory {} from the image",
dst.string());
return;
}
std::vector<std::string> preserve = {"mode", "ownership", "timestamps", "links"};
if (xattr_supported(dst)) {
preserve.push_back("xattr");
} else {
spdlog::warn("extended attributes/ACLs are not supported on {}; skipping their preservation",
dst.string());
}
std::string preserve_arg = "--preserve=";
for (size_t i = 0; i < preserve.size(); ++i) {
if (i > 0) {
preserve_arg += ',';
}
preserve_arg += preserve[i];
}
// $0=sh $1=image_side $2=preserve_arg $3=dst. Trailing "/." (not just $1) copies
// image_side's *contents* into the already-created dst instead of nesting a
// subdirectory inside it.
std::vector<std::string> script = {
"sh", "-c",
"if [ -d \"$1\" ] && [ -n \"$(ls -A \"$1\" 2>/dev/null)\" ]; then cp -a \"$2\" \"$1/.\" \"$3\"; fi",
"sh", image_side.string(), preserve_arg, dst.string()};
auto argv = wrap_for_root_namespace(root, use_nsenter, script);
if (!argv) {
spdlog::warn("could not reach the image's mount namespace; volume directory {} was not populated",
dst.string());
return;
}
auto result = run_process(*argv);
if (result.exit_code != 0) {
spdlog::warn("copying initial contents into {} reported errors (exit code {})", dst.string(),
result.exit_code);
}
}
} // namespace
bool is_valid_volume_name(std::string_view name) {
return !name.empty() && name.find('/') == std::string_view::npos;
}
std::optional<ResolvedVolumeMount> resolve_volume_mount(const std::string& spec,
const std::string& container_path,
const AppConfig& config,
const std::string& merged_path, bool use_nsenter) {
std::string host_directory;
if (is_valid_volume_name(spec)) {
auto it = std::find_if(config.volumes.begin(), config.volumes.end(),
[&](const VolumeEntry& volume) { return volume.name == spec; });
if (it == config.volumes.end()) {
spdlog::error("no volume named '{}' exists", spec);
return std::nullopt;
}
host_directory = it->directory;
} else {
std::filesystem::path resolved = std::filesystem::absolute(spec).lexically_normal();
std::error_code ec;
std::filesystem::create_directories(resolved, ec);
if (ec) {
spdlog::error("failed to create directory {}: {}", resolved.string(), ec.message());
return std::nullopt;
}
host_directory = resolved.string();
}
std::error_code empty_ec;
if (std::filesystem::is_empty(host_directory, empty_ec) && !empty_ec) {
// std::filesystem::path::operator/ discards the lhs entirely when the rhs is
// absolute, so relative_path() strips container_path's leading '/' first.
std::filesystem::path image_side =
std::filesystem::path(merged_path) / std::filesystem::path(container_path).relative_path();
copy_if_present(merged_path, use_nsenter, image_side, host_directory);
}
return ResolvedVolumeMount{host_directory, container_path};
}
+53
View File
@@ -0,0 +1,53 @@
// 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 <optional>
#include <string>
#include <string_view>
#include "config_file.h"
// True if `name` is non-empty and contains no '/'. Volume names must satisfy this
// so that -v/--volume's first parameter (used with -r/--run) can be told apart from
// a host directory path: a name never contains '/', a host path always does.
bool is_valid_volume_name(std::string_view name);
// A -v spec, resolved and ready to hand to build_bwrap_args()/run_bwrap().
struct ResolvedVolumeMount {
std::string host_directory; // absolute, already exists
std::string container_path; // as given on the command line
};
// Resolves one -v <spec> <container_path> used together with -r/--run. `spec` is
// either an existing named volume (no '/', looked up in config.volumes) or a host
// directory path (contains '/', created via create_directories() if missing). If
// the resulting host directory is empty and the image already has non-empty
// content at `container_path` (under `merged_path`, the mounted image root), that
// content is copied in first, preserving numeric ownership/permissions/links and,
// where the host filesystem supports it, extended attributes/ACLs (a single
// warning is logged and they're skipped otherwise). `use_nsenter` must match the
// value run_bwrap() will use for this same run: reading anything under
// `merged_path` requires it whenever the mount was made rootless (see
// wrap_for_root_namespace() in bwrap.h), exactly like the eventual bwrap run
// itself. Logs a specific error and returns nullopt on a hard failure (unknown
// volume name, can't create the host directory); a failed or degraded copy is only
// ever a warning, never fatal here.
std::optional<ResolvedVolumeMount> resolve_volume_mount(const std::string& spec,
const std::string& container_path,
const AppConfig& config,
const std::string& merged_path, bool use_nsenter);