diff --git a/CLAUDE.md b/CLAUDE.md index 3bf7473..7ed6a15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,20 @@ Source layout (all under `src/`): (also `std::filesystem::remove_all()`s the host directory — errors out before touching the config if that fails, warns instead of failing if the directory was already gone) — see `config_file.{h,cpp}` below for what a "volume" means here (a - distinct concept from `OciImageConfig::volumes`). + distinct concept from `OciImageConfig::volumes`). `-v/--volume` is dual-purpose: + used alone it's `create_volume_command()`; combined with `-r/--run` it instead + requests a volume mount (repeatable) and is resolved by `resolve_volume_mount()` + (see `volume_mount.{h,cpp}` below) instead. Since `-v` must be repeatable with + `-r` but each occurrence still takes two space-separated tokens, `main()`'s + getopt loop no longer lets `'v'` set `Mode` itself: it accumulates + `(spec, path)` pairs into `volume_specs` (consuming the second token manually, + with a guard against swallowing the next flag if there isn't one), and only + *after* the loop decides whether that means one standalone `Mode::kVolume` call + or, together with `-r`, threads `volume_specs` through to `run_container()`. + `run_container()` resolves each spec (erroring out, `ok = false`, same as a + failed `--user` resolution — `bwrap` is skipped but unmount/cleanup still runs) + into a `ResolvedVolumeMount`, rejecting a duplicate or non-absolute container + path first, and passes the resolved list to `run_bwrap()`. - `oci_image.{h,cpp}` — validates/parses the OCI Image Layout tar (libarchive + nlohmann_json) and extracts layer blobs. `list_oci_images()` scans a directory (non-recursively) for `*.tar`/`*.tar.*` files and, for each valid OCI archive, @@ -55,6 +68,13 @@ Source layout (all under `src/`): - `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. + `build_bwrap_args()` also takes a `std::vector` (see + `volume_mount.h` below) and appends one writable `--bind + ` per entry. `wrap_for_root_namespace()` is `run_bwrap()`'s own + nsenter-wrapping logic pulled out into a reusable, exported function — it's also + what `volume_mount.cpp`'s copy-into-an-empty-volume step uses to reach the + image's content when running rootless (see below); `run_bwrap()` itself now just + calls it once on the assembled `bwrap` argv. `build_bwrap_args()` deliberately drops `--unshare-net` from what's actually passed to `bwrap` even when the kernel supports it — without any network setup (e.g. `slirp4netns`), unsharing it just leaves the sandbox with no network at all. Re-add @@ -128,9 +148,28 @@ Source layout (all under `src/`): untouched. **`VolumeEntry`/the `volumes` section is a distinct concept from `OciImageConfig::volumes`**: this is a user-defined `name -> host directory` mapping created via `-v/--volume`, not an image's own declared mount points (still - unconsumed, see `oci_image.{h,cpp}` above) — the two aren't connected yet, though a - future `-r/--run` volume-mounting feature would presumably look volumes up here by - name. + unconsumed, see `oci_image.{h,cpp}` above). `AppConfig`/`config.volumes` is looked + up by name in `resolve_volume_mount()` (`volume_mount.{h,cpp}`, see below), which + is how `-r/--run`'s own `-v` usage finds a named volume's host directory. +- `volume_mount.{h,cpp}` — `is_valid_volume_name()` (no `/`, checked by both + `create_volume_command()` and to tell a `-v` spec's name/path apart) and + `resolve_volume_mount()`, called once per `-v` occurrence from `run_container()` + when running with `-r`. A spec with no `/` is looked up in `config.volumes` by + name (error if unknown); one with `/` is treated as a host directory path and + `create_directories()`'d if missing. If the resulting host directory is empty and + the image already has non-empty content at the given container path, that content + is copied in first. Both the existence check and the copy run as a single + `sh -c '[ -d ... ] && cp -a ...'` invocation wrapped through + `wrap_for_root_namespace()` (`bwrap.h`) — **not** a plain `std::filesystem` check + — because a rootless `containers-storage mount`'s content isn't visible to this + process at all without `nsenter`, the same constraint `run_bwrap()` itself works + around (see the root-vs-rootless paragraph below). `cp -a + --preserve=mode,ownership,timestamps,links[,xattr]` does the copy; whether + `,xattr` is included is decided by a direct `setxattr()`/`removexattr()` probe on + the host directory (no new library dependency — Linux POSIX ACLs are themselves + stored as xattrs, so this one probe stands in for both, logging a single + `spdlog::warn` if unsupported). A nonzero `cp` exit is only ever a warning, never + fatal — often just an ownership-preservation shortfall when not running as root. Errors are logged via `spdlog::error`; every external command is also traced at debug level in `run_process()`/`run_process_foreground()` (`src/process.cpp`) — visible via @@ -145,7 +184,10 @@ leaves the result invisible to a plain shell or child process outside that names 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 +`nsenter` into its namespaces (`wrap_for_root_namespace()`, `src/bwrap.h`) — reused +as-is by `volume_mount.cpp`'s copy-into-an-empty-volume step, since that also needs +to read image content that's otherwise invisible outside the same namespace. +**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. diff --git a/README.md b/README.md index 8b8ed51..59ff4b0 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,10 @@ running kernel actually supports, instead of requiring the full set. Early-stage. Mounting, running, and dropping privileges to a specific user/group all work. Named volumes (`-v/--volume`) can be created and are persisted in the config -file, but aren't consumed by `-r/--run` yet. Image-declared networking -(`ExposedPorts`/`Env` from the image config, and the image's own separately-declared -`Volumes`) are parsed but not yet applied, and there's no background/daemonized run -mode yet. +file, and can be mounted into `-r/--run` (repeatably), along with ad hoc host +directories. Image-declared networking (`ExposedPorts`/`Env` from the image config, +and the image's own separately-declared `Volumes`) are parsed but not yet applied, +and there's no background/daemonized run mode yet. ## Requirements @@ -53,7 +53,7 @@ that `-r --user`/`--group` needs at runtime (see "How it works"). ``` slocker-lite -m|--mount -slocker-lite -r|--run [-- [args...]] +slocker-lite -r|--run [-v ]... [-- [args...]] slocker-lite -u|--umount slocker-lite -c|--cleanup slocker-lite -l|--list-images @@ -78,7 +78,7 @@ slocker-lite -V|--version | `--group ` | With `--user`, use this group (name or numeric gid) instead of the user's primary group. | | `-l, --list-images ` | List OCI Image Layout tars (`*.tar`, `*.tar.*`) found directly in ``, with their `name:tag`. | | `-i, --inspect ` | Print an image's declared user, exposed ports, env, volumes, and default command, without mounting or running it. | -| `-v, --volume ` | Create a named volume mapped to a host directory (created if missing), recorded in the config file's `volumes` section. Fails if the name or directory is already used by an existing volume. | +| `-v, --volume ` | Create a named volume mapped to a host directory (created if missing), recorded in the config file's `volumes` section. Fails if the name or directory is already used by an existing volume. Volume names can't contain `/`. With `--run`, instead mounts a volume into the sandbox (repeatable): `` is an existing named volume, or, if it contains `/`, a host directory path (created if missing); `` is the absolute path inside the container to mount it at. If the host directory is empty and the image already has content there, that content is copied in first, preserving numeric ownership/permissions/links and, where the host filesystem supports them, extended attributes/ACLs (skipped with a warning otherwise). | | `--list-volumes` | List all named volumes (see `-v/--volume`) with their host directory. | | `--delete-volume ` | Remove a named volume from the config. The host directory is left untouched. | | `--delete-volume-full ` | Like `--delete-volume`, but also recursively deletes the volume's host directory. | @@ -111,6 +111,9 @@ sudo ./buildDir/slocker-lite -r myimage.tar --user git # Create a named volume backed by a host directory ./buildDir/slocker-lite -v mydata ~/slocker-volumes/mydata +# Run, mounting that named volume plus an ad hoc host directory +./buildDir/slocker-lite -r myimage.tar -v mydata /data -v ~/scratch /scratch + # List all named volumes ./buildDir/slocker-lite --list-volumes @@ -138,9 +141,10 @@ volumes: `global.log-level` is the only standing preference supported today (one-shot commands like `--mount`/`--run`/`--user` don't belong in a config file). An explicit `--log-level` on the command line always overrides the config file. The `volumes` -section is managed by `-v/--volume` (see above) rather than hand-edited — it's not -consumed by `-r/--run` yet. A missing config file is fine either way (nothing is -overridden, and one gets created the first time `-v/--volume` is used). +section is managed by `-v/--volume` (see above) rather than hand-edited — it's what +`-r/--run`'s own `-v` usage looks named volumes up in. A missing config file is fine +either way (nothing is overridden, and one gets created the first time +`-v/--volume` is used). ## How it works diff --git a/meson.build b/meson.build index 42fc82b..442d297 100644 --- a/meson.build +++ b/meson.build @@ -18,7 +18,7 @@ configure_file(output : 'config.h', configuration : conf_data) slocker_lite = executable('slocker-lite', ['src/main.cpp', 'src/process.cpp', 'src/oci_image.cpp', 'src/containers_storage.cpp', - 'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp'], + 'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp'], include_directories : include_directories('.'), dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep], install : true) diff --git a/src/bwrap.cpp b/src/bwrap.cpp index 63d9b98..6829084 100644 --- a/src/bwrap.cpp +++ b/src/bwrap.cpp @@ -178,6 +178,7 @@ std::vector detect_bwrap_unshare_args() { std::vector build_bwrap_args(const std::string& root, const std::vector& command, + const std::vector& volumes, std::optional 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 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 build_bwrap_args(const std::string& root, return args; } -int run_bwrap(const std::string& root, const std::vector& command, bool use_nsenter, - std::optional user) { - std::vector argv; +std::optional> wrap_for_root_namespace(const std::string& root, bool use_nsenter, + const std::vector& 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 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& command, bool use_nsenter, + const std::vector& volumes, std::optional 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); } diff --git a/src/bwrap.h b/src/bwrap.h index 7e41c79..844f021 100644 --- a/src/bwrap.h +++ b/src/bwrap.h @@ -20,6 +20,8 @@ #include #include +#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 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> wrap_for_root_namespace(const std::string& root, bool use_nsenter, + const std::vector& 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 build_bwrap_args(const std::string& root, const std::vector& command, + const std::vector& volumes, std::optional user); // Runs bwrap against `root` (the merged mount path from mount_layer()) in the @@ -54,4 +72,4 @@ std::vector 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& command, bool use_nsenter, - std::optional user); + const std::vector& volumes, std::optional user); diff --git a/src/main.cpp b/src/main.cpp index be71627..3ccd369 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 kLongOptions = {{ void print_usage(const char* prog) { fmt::print( "usage: {0} -m|--mount \n" - " {0} -r|--run [-- [args...]]\n" + " {0} -r|--run [-v ]... [-- [args...]]\n" " {0} -u|--umount \n" " {0} -c|--cleanup \n" " {0} -l|--list-images \n" @@ -137,7 +138,14 @@ void print_usage(const char* prog) { " mounting or running it\n" " -v, --volume 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: is a\n" + " named volume or, if it contains '/', a host\n" + " directory (created if missing); 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 \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& requested_command, bool use_nsenter, - const std::optional& user, const std::optional& group) { + const std::optional& user, const std::optional& group, + const std::vector>& 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 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 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(config->group); } - bool ok = true; std::optional 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 user_flag; std::optional group_flag; + std::vector> 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); diff --git a/src/volume_mount.cpp b/src/volume_mount.cpp new file mode 100644 index 0000000..cacfd7e --- /dev/null +++ b/src/volume_mount.cpp @@ -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 + +#include +#include +#include +#include + +#include +#include + +#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 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 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 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}; +} diff --git a/src/volume_mount.h b/src/volume_mount.h new file mode 100644 index 0000000..7163f26 --- /dev/null +++ b/src/volume_mount.h @@ -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 +#include +#include + +#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 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 resolve_volume_mount(const std::string& spec, + const std::string& container_path, + const AppConfig& config, + const std::string& merged_path, bool use_nsenter);