diff --git a/CLAUDE.md b/CLAUDE.md index 56c8b4d..698ea39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,42 +15,83 @@ kernel actually supports. See `README.md` for the human-facing overview (build/u status); this file stays the dense, file-by-file reference. Still early-stage. Source layout (all under `src/`): -- `main.cpp` — CLI entry point, dependency checks, orchestration (`mount_image()`, - `run_container()`, `cleanup_image()`, `unmount_image()`, `list_images_command()`, - `inspect_image_command()`, `create_volume_command()`, `list_volumes_command()`, - `delete_volume_command()`, `list_processes_command()`, `clean_processes_command()`). - `list_processes_command()` implements `--list-processes` (long-option only): - calls `list_sessions()` (`pid_file.{h,cpp}`, see below) and prints one - tab-aligned `pid`, `container name`, `running`/`exited` row per entry (same - two-column tab-alignment scheme as `list_images_command()`/ +- `main.cpp` — the CLI entry point only, and deliberately tiny (~30 lines): + loads the config file, applies its `global.log-level` (`apply_log_level()`, + `cli_args.h`) before CLI parsing so an explicit `--log-level` can still + override it afterward, calls `parse_args()` (`cli_args.h`) and returns its + exit code immediately if it gives one (covers `-h`/`-V` and every parse + error), otherwise calls `dispatch_command()` (`commands.h`) and returns its + result. All of the actual option-parsing and command logic that used to live + here moved out into `cli_args.{h,cpp}`/`commands.{h,cpp}`/`self_test.{h,cpp}` + (see below) specifically to keep this file from re-growing into a dumping + ground as more commands (docker-compose support, etc.) get added. +- `cli_args.{h,cpp}` — command-line parsing only, nothing else. `Mode` (the + enum of every CLI action) and `ParsedArgs` (everything `parse_args()` + extracts from `argv`) live in the header since `commands.h`'s + `dispatch_command()` consumes them; `print_usage()`/`print_version()`, the + `options` namespace of getopt long-option codes, and the `long_options` + array itself are `.cpp`-local. `parse_args(argc, argv, out)` runs the + `getopt_long` loop plus all of the post-loop validation that used to live at + the top of `main()`: mode/`--volume` interaction (`-v` alone vs. combined + with `-r`, see below), `--group` requires `--user`, no leftover positional + args outside `-r`/`-e`, and (moved here from what used to be inline in the + `Mode::exec` dispatch arm) `-e/--exec `'s own pid parsing/validation + (`ParsedArgs::exec_pid`, a positive integer or a hard error) and its + trailing-command requirement (`ParsedArgs::command`, required non-empty). + Returns an exit code `main()` should return immediately (`0` for `-h`/`-V`, + `1` for any parse error) when set; `nullopt` means `out` is ready for + `dispatch_command()`. `-D/--daemonize` (has a short form; `'D'` was free) is + a plain boolean flag (`ParsedArgs::daemonize_flag`, set in its own `case + 'D':`, same pattern as `-n/--no-nsenter`). `--hostname `/`--env + VAR=VALUE`/`--env-file ` (all long-option only, `--env`/`--env-file` + both repeatable) are collected here into `ParsedArgs::hostname_flag`/ + `env_specs` — `--env` pushes `{false, optarg}`, `--env-file` pushes `{true, + optarg}` into the *same* ordered `std::vector` (not two separate + lists), preserving their exact relative command-line order across both + flags, since `resolve_env_specs()` (`env_spec.{h,cpp}`, see below) needs + that order to let a later one override an earlier one for the same variable + name — actually resolving them happens later, in `commands.cpp`'s + `run_container()`. `-v/--volume` is dual-purpose: used alone it's a + standalone `Mode::volume` request; combined with `-r/--run` it's repeatable + and requests a volume mount instead (resolved later by + `resolve_volume_mount()`, `volume_mount.{h,cpp}`, see below). Since `-v` + must be repeatable with `-r` but each occurrence still takes two + space-separated tokens, the getopt loop doesn't let `'v'` set `Mode` + directly: it accumulates `(spec, path)` pairs into `ParsedArgs::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::volume` call or, together with `-r`, + passes `volume_specs` through unresolved for `dispatch_command()`/ + `run_container()` to handle. +- `commands.{h,cpp}` — every command's implementation, plus the dispatcher. + `dispatch_command(args, config_path, config)` (the only externally-linked + function; everything else in this file is `.cpp`-local) is a `switch + (args.mode)` with **one explicit `case` per `Mode` enumerator and no + `default:`**, so `-Wswitch` (this project builds at `warning_level=3`) + forces a compile warning/error if a future `Mode` value is ever added + without a matching dispatch case, instead of silently falling through to + the wrong command — confirmed by testing (temporarily adding an unhandled + enumerator triggered exactly the expected `-Wswitch` warning). `Mode::mount` + has its own explicit case (`mount_command()`) for the same reason: it used + to be handled only by *falling off the end* of a long `if`/`else` chain in + `main()` with no explicit check at all — the very kind of implicit, + easy-to-silently-break behavior this dispatcher redesign exists to close + off, especially with more `Mode` values (docker-compose support) expected + soon. `list_processes_command()` implements `--list-processes` (long-option + only): calls `list_sessions()` (`pid_file.{h,cpp}`, see below) and prints + one tab-aligned `pid`, `container name`, `running`/`exited` row per entry + (same two-column tab-alignment scheme as `list_images_command()`/ `list_volumes_command()`, extended to a third column), no header row, silent success on an empty list. `clean_processes_command()` implements `--clean-processes` (also long-option only): calls `clean_stale_sessions()` (`pid_file.{h,cpp}`) and prints one `removed stale pid file for '' (pid )` line per file actually removed — nothing is printed for sessions still running, and an empty result (nothing stale) is silent success, same - convention as the rest of this file's list/delete commands. `-e/--exec ` - (has a short form, unlike the rest of the process-tracking flags) dispatches - straight to `exec_in_session()` (`exec_session.{h,cpp}`, see below): `pid` - lands in `mode_arg` (parsed as a positive integer, erroring out otherwise) the - same way `-r`'s image path does, and the trailing command - (`argv[optind:]`, required — errors out if empty) is collected the same way - `-r`'s own command is, sharing that mode's exemption from the "no leftover - positional args" check. - `-D/--daemonize` (has a short form; `'D'` was free) is a plain boolean flag - (`daemonize_flag`, set in its own `case 'D':`, same pattern as - `-n/--no-nsenter`) threaded through to `run_container()`, which computes - `container_name` *before* `mount_image()` now (moved up from right before - the `run_bwrap()` call — it only ever depended on `image_tar`, a parameter - available from the start, so this is a pure reordering) and, if daemonizing, - calls `daemonize(container_name)` (`daemonize.{h,cpp}`, see below) - immediately after: a returned value means this is the original (parent) - process (or a hard daemonize failure) — print it and `return` right away; - `nullopt` means this is the now-detached child, which falls through into the - rest of `run_container()`'s existing body completely unchanged, including - the unmount/cleanup that already runs after `run_bwrap()` returns (no - separate watcher/reaper — the daemonized child *is* what runs the whole - session, start to finish). + convention as the rest of this file's list/delete commands. `Mode::exec`'s + dispatch case is a one-line call to `exec_in_session(*args.exec_pid, + args.command)` (`exec_session.{h,cpp}`, see below) — the pid/command + parsing and validation now happens in `cli_args.cpp`'s `parse_args()` + instead (see above). `inspect_image_command()` implements `-i/--inspect `: prints every `OciImageConfig` field (user/group, exposed ports, env, volumes, default command) without mounting or running the image — extend it @@ -67,38 +108,45 @@ 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`). `-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()`. `--hostname ` - (long-option only, no short form) is likewise threaded straight through - `run_container()` into `run_bwrap()`/`build_bwrap_args()` (`bwrap.{h,cpp}`) — - see there for how/when it actually takes effect. `run_container()` also derives - a `container_name` for the session-tracking pid file (see `pid_file.{h,cpp}` - below): `read_image_ref()` (`oci_image.{h,cpp}`) applied to the single image - tar being run, formatted as `name:tag`, falling back to the tar's own filename - stem if `read_image_ref()` can't determine one — passed through to - `run_bwrap()` alongside everything else. `--env VAR=VALUE`/`--env-file ` - (both long-option only, both repeatable) accumulate into a single ordered - `std::vector` — `--env` pushes `{false, optarg}`, `--env-file` pushes - `{true, optarg}` — preserving their exact relative command-line order across - *both* flags (not two separate lists), since `resolve_env_specs()` - (`env_spec.{h,cpp}`, see below) needs that order to let a later one override - an earlier one for the same variable name. `run_container()` calls it once - (same `ok = false`-on-failure pattern as volume/user resolution) and passes - the resolved list to `run_bwrap()` as `extra_env`. + distinct concept from `OciImageConfig::volumes`). `dispatch_command()`'s + `Mode::volume`/`Mode::delete_volume`/`Mode::delete_volume_full` cases call these. + `run_container()` (the `Mode::run` dispatch case) resolves each `-v` 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()`. `--hostname ` is likewise threaded + straight through `run_container()` into `run_bwrap()`/`build_bwrap_args()` + (`bwrap.{h,cpp}`) — see there for how/when it actually takes effect. + `run_container()` also derives a `container_name` for the session-tracking + pid file (see `pid_file.{h,cpp}` below): `read_image_ref()` + (`oci_image.{h,cpp}`) applied to the single image tar being run, formatted + as `name:tag`, falling back to the tar's own filename stem if + `read_image_ref()` can't determine one — passed through to `run_bwrap()` + alongside everything else. `--env`/`--env-file`'s already-ordered + `env_specs` (see `cli_args.{h,cpp}` above) are resolved here, once, via + `resolve_env_specs()` (`env_spec.{h,cpp}`, see below) — same `ok = + false`-on-failure pattern as volume/user resolution — and the resolved list + is passed to `run_bwrap()` as `extra_env`. `-D/--daemonize`'s + `daemonize_flag` is also consumed here: `run_container()` computes + `container_name` *before* `mount_image()` (needed so `daemonize()` below can + use the real container name for the log file from its very first line, not + just after a later rename) and, if daemonizing, calls + `daemonize(container_name)` (`daemonize.{h,cpp}`, see below) immediately + after: a returned value means this is the original (parent) process (or a + hard daemonize failure) — print it and `return` right away; `nullopt` means + this is the now-detached child, which falls through into the rest of + `run_container()`'s existing body completely unchanged, including the + unmount/cleanup that already runs after `run_bwrap()` returns (no separate + watcher/reaper — the daemonized child *is* what runs the whole session, + start to finish). +- `self_test.{h,cpp}` — `run_self_tests()` implements `-t/--test`, this + project's own built-in self-test mode (distinct from the Meson-driven + fixture smoke test under `tests/`, described in "Build & test commands" + below). Currently just reports `detect_bwrap_unshare_args()`'s output + (`bwrap.{h,cpp}`); deliberately its own small file since real tests are + expected here soon. - `env_spec.{h,cpp}` — `resolve_env_specs()` turns an ordered list of - `EnvSpec {is_file, value}` (see `main.cpp` above) into a flat, ordered list of + `EnvSpec {is_file, value}` (see `cli_args.{h,cpp}` above) into a flat, ordered list of `(key, value)` pairs. A literal (`--env`) is split at its *first* `=` (the value may itself contain `=`; the key must be non-empty). A file (`--env-file`) is read line by line: blank/whitespace-only lines and lines whose first @@ -120,14 +168,14 @@ Source layout (all under `src/`): annotations (`io.containerd.image.name` preferred, else `org.opencontainers.image.ref.name`), falling back to the archive's filename and `"latest"` respectively. `read_image_ref()` is public (not just an internal - helper of `list_oci_images()`) precisely so `run_container()` (`main.cpp`) can + helper of `list_oci_images()`) precisely so `run_container()` (`commands.cpp`) can reuse the exact same logic to name a *single* image tar's session pid file (see `pid_file.{h,cpp}` below) instead of duplicating it. `read_oci_image_config()` reads the image config blob referenced by the manifest and extracts `User` (split on `:` into `OciImageConfig::user`/`group`), `ExposedPorts`, `Env`, `Volumes`, and the effective default command (`Entrypoint ++ Cmd`). `user`/`group` and the default command are consumed by - `-r/--run`, and every field is displayed by `-i/--inspect` (see `main.cpp` above) + `-r/--run`, and every field is displayed by `-i/--inspect` (see `commands.{h,cpp}` above) — `ExposedPorts`/`Env`/`Volumes` are otherwise still just captured for when networking/volumes are implemented. - `containers_storage.{h,cpp}` — wraps the `containers-storage` CLI (`import-layer`, @@ -235,7 +283,7 @@ Source layout (all under `src/`): `"/root"` for uid 0 or `"/"` otherwise when there's no matching row. `build_sandbox_env()` (`bwrap.cpp`) sets the sandboxed process's `HOME` from this — `"/root"` only when no user override applies at all (no `--user`, no - image-declared `config.User`). `run_container()` (`main.cpp`) calls `resolve_user_and_group()` + image-declared `config.User`). `run_container()` (`commands.cpp`) calls `resolve_user_and_group()` with either the explicit `--user`/`--group` flags, or, when `--user` wasn't given, the image's own declared `config.User` (`OciImageConfig::user`/`group`) — so a container defaults to running as whatever user the image itself declares, not @@ -288,7 +336,7 @@ Source layout (all under `src/`): failure path here (can't create the directory/file, can't lock, can't remove) is a `spdlog::warn`, never fatal — session tracking is best-effort and must never block or fail `-r/--run` itself. `list_sessions()` implements - `--list-processes` (`main.cpp`'s `list_processes_command()`): scans the same + `--list-processes` (`commands.cpp`'s `list_processes_command()`): scans the same `run/` directory and reports one `SessionInfo {pid, container_name, running}` per readable pid file. `pid` is read from the file's own contents, not parsed from the filename (ambiguous for names that themselves contain `-`); @@ -336,7 +384,7 @@ Source layout (all under `src/`): `--daemonize` was asked to capture would defeat the point of the flag. The child reports `"LOG \n"` over the pipe immediately (so the parent can show a useful location even on failure) and returns `nullopt` to its caller - (`run_container()`, `main.cpp`), which then falls through into the rest of + (`run_container()`, `commands.cpp`), which then falls through into the rest of that function's existing body completely unchanged — **the daemonized child is what runs the whole rest of `run_container()`, including the unmount/ cleanup that already existed after `run_bwrap()` returns; no separate @@ -410,7 +458,7 @@ Source layout (all under `src/`): overwrites it afterward — same precedence pattern already used for `SPDLOG_LEVEL`. `write_config_file()` writes the whole file back out (via libyaml's document-building/emitter API, symmetric to the read side) — used by - `-v/--volume` (`create_volume_command()`, `main.cpp`) to persist a new + `-v/--volume` (`create_volume_command()`, `commands.cpp`) to persist a new `VolumeEntry {name, directory}` into the `volumes` section, preserving `global` untouched. **`VolumeEntry`/the `volumes` section is a distinct concept from `OciImageConfig::volumes`**: this is a user-defined `name -> host directory` @@ -506,7 +554,7 @@ Build directory is `buildDir/` (already configured). the enum's own name (e.g. `Mode::run`, `OciPortProtocol::tcp`), so plain snake_case enumerators are enough on their own. Free-standing constants also use plain snake_case; when several are conceptually related, group them under a named `namespace` instead of - relying on a shared prefix to imply the grouping (e.g. `main.cpp`'s `getopt_long` long-option + relying on a shared prefix to imply the grouping (e.g. `cli_args.cpp`'s `getopt_long` long-option codes live in `namespace options { constexpr int log_level = ...; }`, and `bwrap.cpp`'s priv-drop-helper path/binary-name pair live in `namespace priv_drop { ... }`) — nest the named namespace inside the file's existing anonymous namespace where one is already present, so diff --git a/meson.build b/meson.build index 52ede57..cac892f 100644 --- a/meson.build +++ b/meson.build @@ -17,7 +17,8 @@ conf_data.set10('ENABLE_TESTS', get_option('enable_tests')) 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/main.cpp', 'src/cli_args.cpp', 'src/commands.cpp', 'src/self_test.cpp', + 'src/process.cpp', 'src/oci_image.cpp', 'src/containers_storage.cpp', 'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp', 'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp', 'src/daemonize.cpp'], include_directories : include_directories('.'), diff --git a/src/cli_args.cpp b/src/cli_args.cpp new file mode 100644 index 0000000..aeb0c05 --- /dev/null +++ b/src/cli_args.cpp @@ -0,0 +1,386 @@ +// 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 "cli_args.h" + +#include +#include +#include + +#include +#include + +#include "config.h" + +namespace { + +// --log-level/--user/--group/--list-volumes/--delete-volume[-full]/--hostname/ +// --list-processes/--clean-processes/--env/--env-file have no short form +// (--log-level's was freed up so -l could become --list-images; -u is already +// --umount; the rest have no natural free letter left, or don't need one), so +// they need long-option vals outside the printable-char range short options use. +namespace options { +constexpr int log_level = 256; +constexpr int user = 257; +constexpr int group = 258; +constexpr int list_volumes = 259; +constexpr int delete_volume = 260; +constexpr int delete_volume_full = 261; +constexpr int hostname = 262; +constexpr int list_processes = 263; +constexpr int clean_processes = 264; +constexpr int env = 265; +constexpr int env_file = 266; +} // namespace options + +constexpr std::array long_options = {{ + {"help", no_argument, nullptr, 'h'}, + {"version", no_argument, nullptr, 'V'}, + {"test", no_argument, nullptr, 't'}, + {"log-level", required_argument, nullptr, options::log_level}, + {"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'}, + {"daemonize", no_argument, nullptr, 'D'}, + {"list-images", required_argument, nullptr, 'l'}, + {"user", required_argument, nullptr, options::user}, + {"group", required_argument, nullptr, options::group}, + {"volume", required_argument, nullptr, 'v'}, + {"list-volumes", no_argument, nullptr, options::list_volumes}, + {"delete-volume", required_argument, nullptr, options::delete_volume}, + {"delete-volume-full", required_argument, nullptr, options::delete_volume_full}, + {"inspect", required_argument, nullptr, 'i'}, + {"exec", required_argument, nullptr, 'e'}, + {"hostname", required_argument, nullptr, options::hostname}, + {"list-processes", no_argument, nullptr, options::list_processes}, + {"clean-processes", no_argument, nullptr, options::clean_processes}, + {"env", required_argument, nullptr, options::env}, + {"env-file", required_argument, nullptr, options::env_file}, + {nullptr, 0, nullptr, 0}, +}}; + +void print_usage(const char* prog) { + fmt::print( + "usage: {0} -m|--mount \n" + " {0} -r|--run [-v ]... [-- [args...]]\n" + " {0} -u|--umount \n" + " {0} -c|--cleanup \n" + " {0} -l|--list-images \n" + " {0} -i|--inspect \n" + " {0} -e|--exec [-- [args...]]\n" + " {0} -v|--volume \n" + " {0} --list-volumes\n" + " {0} --delete-volume \n" + " {0} --delete-volume-full \n" + " {0} --list-processes\n" + " {0} --clean-processes\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: the image's own Entrypoint/Cmd if set,\n" + " else /bin/sh; pass -- [args...] to\n" + " override), then unmount and clean up when it\n" + " exits\n" + " -u, --umount unmount a previously mounted image layer (the\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" + " -D, --daemonize with --run, fork into the background: detaches\n" + " from the controlling terminal (setsid()) and\n" + " ignores SIGHUP, redirecting stdin from /dev/null\n" + " and stdout/stderr to a log file under\n" + " $XDG_STATE_HOME/slocker-lite/logs/. Prints the\n" + " session's pid and log path, then returns\n" + " immediately -- the same pid --list-processes/\n" + " -e/--exec use\n" + " --user with --run, run the command as this user (name\n" + " or numeric uid) instead of the image's own\n" + " declared user (or root, if it declares none),\n" + " resolved against the image's own /etc/passwd;\n" + " only takes effect when --run executes as root\n" + " (no user namespace involved)\n" + " --group with --user, use this group (name or numeric\n" + " gid) instead of the user's own primary group\n" + " --hostname with --run, set the sandbox's hostname (only\n" + " takes effect if the running kernel supports\n" + " --unshare-uts; otherwise ignored with a\n" + " warning)\n" + " --env VAR=VALUE with --run, set an environment variable in the\n" + " sandbox (overrides the default PATH/HOME/PWD/\n" + " TERM if given the same name). May be repeated;\n" + " combined with --env-file in command-line order,\n" + " each later one winning over an earlier one for\n" + " the same name\n" + " --env-file with --run, load environment variables from\n" + " (one VAR=VALUE per line; blank lines and\n" + " #-comments are skipped). May be repeated\n" + " -l, --list-images list OCI Image Layout tars (*.tar, *.tar.*) found\n" + " directly in , with their name:tag\n" + " -i, --inspect print an image's declared user, exposed ports,\n" + " env, volumes, and default command, without\n" + " mounting or running it\n" + " -e, --exec join a running --run session (pid must be one\n" + " shown by --list-processes as \"running\") and run\n" + " a command inside its container; pass\n" + " -- [args...] to specify 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. 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" + " remove a named volume from the config (the host\n" + " directory is left untouched)\n" + " --delete-volume-full \n" + " like --delete-volume, but also recursively\n" + " deletes the volume's host directory\n" + " --list-processes list running --run sessions found by their pid\n" + " files under $XDG_STATE_HOME/slocker-lite/run/,\n" + " with their pid, container name, and status\n" + " (running or exited)\n" + " --clean-processes remove stale pid files (see --list-processes)\n" + " left behind by sessions that are no longer\n" + " running\n" + " -t, --test run the test suite\n" + " --log-level set log verbosity (trace, debug, info, warn,\n" + " error, critical, off)\n" + " -h, --help print this help and exit\n" + " -V, --version print version information and exit\n", + prog); +} + +void print_version() { + fmt::print( + "{} {}\n" + "Licensed under GNU GPL version 2 or later \n" + "This is free software: you are free to change and redistribute it.\n" + "There is NO WARRANTY, to the extent permitted by law.\n", + PACKAGE, VERSION); +} + +} // namespace + +bool apply_log_level(std::string_view name) { + constexpr std::array valid_levels = { + "trace", "debug", "info", "warn", "error", "critical", "off"}; + for (auto level : valid_levels) { + if (level == name) { + spdlog::set_level(spdlog::level::from_str(std::string(name))); + return true; + } + } + spdlog::error("invalid log level: {}", name); + return false; +} + +std::optional parse_args(int argc, char* argv[], ParsedArgs& out) { + opterr = 0; + int opt; + while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:e:D", long_options.data(), nullptr)) != -1) { + switch (opt) { + case 'h': + print_usage(argv[0]); + return 0; + case 'V': + print_version(); + return 0; + case 't': + case 'm': + case 'u': + case 'r': + case 'c': + case 'l': + case 'i': + case 'e': + case options::list_volumes: + case options::delete_volume: + case options::delete_volume_full: + case options::list_processes: + case options::clean_processes: { + Mode requested; + switch (opt) { + case 't': + requested = Mode::test; + break; + case 'm': + requested = Mode::mount; + break; + case 'u': + requested = Mode::unmount; + break; + case 'r': + requested = Mode::run; + break; + case 'c': + requested = Mode::cleanup; + break; + case 'l': + requested = Mode::list_images; + break; + case 'i': + requested = Mode::inspect; + break; + case 'e': + requested = Mode::exec; + break; + case options::list_volumes: + requested = Mode::list_volumes; + break; + case options::delete_volume: + requested = Mode::delete_volume; + break; + case options::delete_volume_full: + requested = Mode::delete_volume_full; + break; + case options::list_processes: + requested = Mode::list_processes; + break; + default: + requested = Mode::clean_processes; + break; + } + if (out.mode != Mode::none && out.mode != requested) { + spdlog::error("multiple actions specified"); + print_usage(argv[0]); + return 1; + } + out.mode = requested; + if (optarg) { + out.mode_arg = optarg; + } + 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 below 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; + } + out.volume_specs.emplace_back(optarg, argv[optind]); + ++optind; + break; + } + case 'n': + out.disable_nsenter = true; + break; + case 'D': + out.daemonize_flag = true; + break; + case options::log_level: + if (!apply_log_level(optarg)) { + return 1; + } + break; + case options::user: + out.user_flag = optarg; + break; + case options::group: + out.group_flag = optarg; + break; + case options::hostname: + out.hostname_flag = optarg; + break; + case options::env: + out.env_specs.push_back({false, optarg}); + break; + case options::env_file: + out.env_specs.push_back({true, optarg}); + break; + case ':': + spdlog::error("option requires an argument: -{}", static_cast(optopt)); + print_usage(argv[0]); + return 1; + case '?': + default: + spdlog::error("unrecognized option"); + print_usage(argv[0]); + return 1; + } + } + + if (!out.volume_specs.empty() && out.mode != Mode::run) { + if (out.mode != Mode::none) { + spdlog::error("--volume can only be used standalone or together with --run"); + print_usage(argv[0]); + return 1; + } + if (out.volume_specs.size() > 1) { + spdlog::error("--volume can only be used once outside of --run"); + print_usage(argv[0]); + return 1; + } + out.mode = Mode::volume; + } + + if (out.mode == Mode::none) { + print_usage(argv[0]); + return 1; + } + if (out.mode != Mode::run && out.mode != Mode::exec && optind != argc) { + print_usage(argv[0]); + return 1; + } + if (out.group_flag && !out.user_flag) { + spdlog::error("--group requires --user"); + print_usage(argv[0]); + return 1; + } + + if (out.mode == Mode::run || out.mode == Mode::exec) { + out.command.assign(argv + optind, argv + argc); + } + + if (out.mode == Mode::exec) { + if (out.command.empty()) { + spdlog::error("--exec requires a command to run"); + print_usage(argv[0]); + return 1; + } + char* end = nullptr; + long parsed = std::strtol(out.mode_arg.c_str(), &end, 10); + if (out.mode_arg.empty() || !end || *end != '\0' || parsed <= 0) { + spdlog::error("invalid pid: {}", out.mode_arg); + return 1; + } + out.exec_pid = static_cast(parsed); + } + + return std::nullopt; +} diff --git a/src/cli_args.h b/src/cli_args.h new file mode 100644 index 0000000..d02ea9f --- /dev/null +++ b/src/cli_args.h @@ -0,0 +1,80 @@ +// 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 +#include + +#include + +#include "env_spec.h" + +enum class Mode { + none, + mount, + unmount, + test, + run, + cleanup, + list_images, + volume, + list_volumes, + delete_volume, + delete_volume_full, + inspect, + list_processes, + clean_processes, + exec +}; + +// Everything parse_args() extracts from argv, ready to hand to +// dispatch_command() (commands.h). +struct ParsedArgs { + Mode mode = Mode::none; + std::string mode_arg; + bool disable_nsenter = false; + bool daemonize_flag = false; + std::optional user_flag; + std::optional group_flag; + std::optional hostname_flag; + std::vector> volume_specs; + std::vector env_specs; + // Trailing argv (after getopt_long stops), populated only for + // Mode::run/Mode::exec -- the command to run, or to run under -e/--exec. + std::vector command; + // Parsed and range-validated from mode_arg when mode == Mode::exec. + std::optional exec_pid; +}; + +// Validates and applies a log-level name (trace/debug/info/warn/error/critical/off) +// via spdlog::set_level(). Returns false (logging an error) if `name` isn't +// recognized. Exposed (not parse_args()-internal) because main() also applies it +// directly for the config file's global.log-level, before parse_args() runs, so +// an explicit --log-level on the command line can still override it afterward. +bool apply_log_level(std::string_view name); + +// Parses argv via getopt_long() into `out`, including all of the post-loop +// validation (mode/--volume interaction, --group requires --user, leftover +// positional args outside --run/--exec, --exec's own command/pid validation). +// Returns an exit code main() should return immediately (0 for -h/--help or +// -V/--version, 1 for any parse error) -- print_usage()/print_version() are +// already called internally in those cases. nullopt means parsing succeeded +// and `out` is ready for dispatch_command() (commands.h). +std::optional parse_args(int argc, char* argv[], ParsedArgs& out); diff --git a/src/commands.cpp b/src/commands.cpp new file mode 100644 index 0000000..3bd6d36 --- /dev/null +++ b/src/commands.cpp @@ -0,0 +1,526 @@ +// 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 "commands.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "bwrap.h" +#include "containers_storage.h" +#include "daemonize.h" +#include "env_spec.h" +#include "exec_session.h" +#include "oci_image.h" +#include "pid_file.h" +#include "process.h" +#include "self_test.h" +#include "user_spec.h" +#include "volume_mount.h" + +namespace { + +constexpr std::array required_tools = {"containers-storage", "bwrap"}; + +// Shared by list_images_command()/list_volumes_command()/list_processes_command(): +// pad each entry with tabs (not spaces) so columns line up on an 8-column tab +// stop past the longest entry in that column, however long that is. +constexpr size_t tab_width = 8; + +bool check_required_dependencies() { + bool all_found = true; + for (auto name : required_tools) { + if (!find_in_path(name)) { + spdlog::error("required dependency not found in PATH: {}", name); + all_found = false; + } + } + return all_found; +} + +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; + } + g_mount_program = 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 mount_command(const std::filesystem::path& image_tar) { + auto mounted = mount_image(image_tar); + if (!mounted) { + return 1; + } + fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id); + return 0; +} + +int unmount_image(const std::string& layer_id) { + if (!check_required_dependencies()) { + return 1; + } + if (!unmount_layer(layer_id)) { + spdlog::error("failed to unmount layer {}", layer_id); + return 1; + } + fmt::print("unmounted layer {}\n", layer_id); + return 0; +} + +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 list_images_command(const std::filesystem::path& dir) { + auto images = list_oci_images(dir); + if (!images) { + return 1; + } + + std::vector refs; + refs.reserve(images->size()); + size_t max_len = 0; + for (const auto& ref : *images) { + refs.push_back(fmt::format("{}:{}", ref.name, ref.tag)); + max_len = std::max(max_len, refs.back().size()); + } + + size_t target_tabs = max_len / tab_width + 1; + + for (size_t i = 0; i < images->size(); ++i) { + size_t tabs_used = refs[i].size() / tab_width; + size_t tabs_needed = target_tabs > tabs_used ? target_tabs - tabs_used : 1; + fmt::print("{}{}{}\n", refs[i], std::string(tabs_needed, '\t'), + (*images)[i].path.filename().string()); + } + return 0; +} + +// Prints every OciImageConfig field for a human to read, without mounting or +// running the image. Extend this whenever OciImageConfig gains a new field. +int inspect_image_command(const std::filesystem::path& image_tar) { + auto config = read_oci_image_config(image_tar); + if (!config) { + return 1; + } + + fmt::print("Image: {}\n", image_tar.string()); + + if (config->user.empty()) { + fmt::print("User: (not set)\n"); + } else if (config->group.empty()) { + fmt::print("User: {}\n", config->user); + } else { + fmt::print("User: {}:{}\n", config->user, config->group); + } + + fmt::print("Command: {}\n", + config->command.empty() ? "(not set)" : fmt::to_string(fmt::join(config->command, " "))); + + if (config->exposed_ports.empty()) { + fmt::print("Exposed ports: (none)\n"); + } else { + fmt::print("Exposed ports:\n"); + for (const auto& port : config->exposed_ports) { + fmt::print(" {}/{}\n", port.port, port.protocol == OciPortProtocol::tcp ? "tcp" : "udp"); + } + } + + if (config->env.empty()) { + fmt::print("Env: (none)\n"); + } else { + fmt::print("Env:\n"); + for (const auto& e : config->env) { + fmt::print(" {}\n", e); + } + } + + if (config->volumes.empty()) { + fmt::print("Volumes: (none)\n"); + } else { + fmt::print("Volumes:\n"); + for (const auto& v : config->volumes) { + fmt::print(" {}\n", v); + } + } + + return 0; +} + +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) { + if (volume.name == name) { + spdlog::error("a volume named '{}' already exists (directory: {})", name, volume.directory); + return 1; + } + if (volume.directory == resolved.string()) { + spdlog::error("directory {} is already used by volume '{}'", resolved.string(), volume.name); + return 1; + } + } + + std::error_code ec; + bool created = std::filesystem::create_directories(resolved, ec); + if (ec) { + spdlog::error("failed to create directory {}: {}", resolved.string(), ec.message()); + return 1; + } + if (!created) { + spdlog::warn("directory {} already exists", resolved.string()); + std::error_code empty_ec; + bool empty = std::filesystem::is_empty(resolved, empty_ec); + if (!empty_ec && !empty) { + spdlog::warn("directory {} is not empty", resolved.string()); + } + } + + config.volumes.push_back({name, resolved.string()}); + if (!write_config_file(config_path, config)) { + return 1; + } + + fmt::print("created volume '{}' -> {}\n", name, resolved.string()); + return 0; +} + +int list_volumes_command(const AppConfig& config) { + size_t max_len = 0; + for (const auto& volume : config.volumes) { + max_len = std::max(max_len, volume.name.size()); + } + + size_t target_tabs = max_len / tab_width + 1; + + for (const auto& volume : config.volumes) { + size_t tabs_used = volume.name.size() / tab_width; + size_t tabs_needed = target_tabs > tabs_used ? target_tabs - tabs_used : 1; + fmt::print("{}{}{}\n", volume.name, std::string(tabs_needed, '\t'), volume.directory); + } + return 0; +} + +int list_processes_command() { + auto sessions = list_sessions(); + + std::vector pids; + std::vector names; + pids.reserve(sessions.size()); + names.reserve(sessions.size()); + size_t max_pid_len = 0; + size_t max_name_len = 0; + for (const auto& session : sessions) { + pids.push_back(fmt::format("{}", session.pid)); + names.push_back(session.container_name); + max_pid_len = std::max(max_pid_len, pids.back().size()); + max_name_len = std::max(max_name_len, names.back().size()); + } + + // Applied independently to each of the two variable-width columns. + size_t pid_target_tabs = max_pid_len / tab_width + 1; + size_t name_target_tabs = max_name_len / tab_width + 1; + + for (size_t i = 0; i < sessions.size(); ++i) { + size_t pid_tabs_used = pids[i].size() / tab_width; + size_t pid_tabs_needed = pid_target_tabs > pid_tabs_used ? pid_target_tabs - pid_tabs_used : 1; + size_t name_tabs_used = names[i].size() / tab_width; + size_t name_tabs_needed = name_target_tabs > name_tabs_used ? name_target_tabs - name_tabs_used : 1; + fmt::print("{}{}{}{}{}\n", pids[i], std::string(pid_tabs_needed, '\t'), names[i], + std::string(name_tabs_needed, '\t'), sessions[i].running ? "running" : "exited"); + } + return 0; +} + +int clean_processes_command() { + for (const auto& session : clean_stale_sessions()) { + fmt::print("removed stale pid file for '{}' (pid {})\n", session.container_name, session.pid); + } + return 0; +} + +int delete_volume_command(const std::string& name, const std::filesystem::path& config_path, + AppConfig& config, bool delete_directory) { + auto it = std::find_if(config.volumes.begin(), config.volumes.end(), + [&](const VolumeEntry& volume) { return volume.name == name; }); + if (it == config.volumes.end()) { + spdlog::error("no volume named '{}' exists", name); + return 1; + } + + if (delete_directory) { + if (!std::filesystem::exists(it->directory)) { + spdlog::warn("directory {} does not exist, nothing to delete", it->directory); + } + std::error_code ec; + std::filesystem::remove_all(it->directory, ec); + if (ec) { + spdlog::error("failed to delete directory {}: {}", it->directory, ec.message()); + return 1; + } + } + + config.volumes.erase(it); + if (!write_config_file(config_path, config)) { + return 1; + } + + fmt::print("deleted volume '{}'\n", name); + return 0; +} + +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& hostname, + const std::vector>& volume_specs, + const std::vector& env_specs, bool daemonize_flag, const AppConfig& app_config) { + // Only depends on image_tar, so this can run before mount_image() -- moved + // up here (rather than right before the run_bwrap() call, as before) so + // daemonize() below can use the real container name for the log file from + // its very first line, not just after a later rename. + auto image_ref = read_image_ref(image_tar); + std::string container_name = + image_ref ? fmt::format("{}:{}", image_ref->name, image_ref->tag) : image_tar.stem().string(); + + if (daemonize_flag) { + auto result = daemonize(container_name); + if (result) { + // Parent (or a hard daemonize failure before ever forking) -- report + // and return immediately. The child falls through below instead. + if (result->pid) { + fmt::print("started in background: pid {}, log: {}\n", *result->pid, result->log_path); + return 0; + } + spdlog::error("failed to start in background{}", result->log_path.empty() + ? "" + : fmt::format(" (see log: {})", result->log_path)); + return 1; + } + } + + auto mounted = mount_image(image_tar); + if (!mounted) { + return 1; + } + fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id); + + 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)); + } + + auto resolved_env = resolve_env_specs(env_specs); + if (!resolved_env) { + ok = false; + } + + // 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; + std::optional effective_group = group; + if (!effective_user && config && !config->user.empty()) { + effective_user = config->user; + effective_group = config->group.empty() ? std::nullopt : std::optional(config->group); + } + + std::optional resolved_user; + if (effective_user) { + resolved_user = resolve_user_and_group(*effective_user, effective_group, mounted->merged_path); + if (!resolved_user) { + ok = false; + } + } + + std::vector command = requested_command; + if (command.empty()) { + command = (config && !config->command.empty()) ? config->command + : std::vector{"/bin/sh"}; + } + + std::function on_bwrap_pid_known; + if (daemonize_flag) { + on_bwrap_pid_known = [&](pid_t pid) { report_daemon_started(container_name, pid); }; + } + + int exit_code = -1; + if (ok) { + exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, volume_mounts, resolved_user, hostname, + container_name, *resolved_env, on_bwrap_pid_known); + 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 (!ok || exit_code < 0) ? 1 : exit_code; +} + +} // namespace + +int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config_path, AppConfig& config) { + // Exhaustive over every Mode enumerator, deliberately with no default: + // -Wswitch (this project builds at warning_level=3) then forces a + // warning/error if a future Mode value is ever added without a matching + // case here, instead of silently falling through to the wrong command. + switch (args.mode) { + case Mode::none: + // Unreachable: parse_args() already rejects Mode::none before + // dispatch_command() is ever called. + spdlog::error("internal error: no mode selected"); + return 1; + case Mode::mount: + return mount_command(args.mode_arg); + case Mode::unmount: + return unmount_image(args.mode_arg); + case Mode::cleanup: + return cleanup_image(args.mode_arg); + case Mode::list_images: + return list_images_command(args.mode_arg); + case Mode::inspect: + return inspect_image_command(args.mode_arg); + case Mode::volume: + return create_volume_command(args.volume_specs.front().first, args.volume_specs.front().second, + config_path, config); + case Mode::list_volumes: + return list_volumes_command(config); + case Mode::delete_volume: + return delete_volume_command(args.mode_arg, config_path, config, false); + case Mode::delete_volume_full: + return delete_volume_command(args.mode_arg, config_path, config, true); + case Mode::list_processes: + return list_processes_command(); + case Mode::clean_processes: + return clean_processes_command(); + case Mode::test: + return run_self_tests(); + case Mode::exec: + return exec_in_session(*args.exec_pid, args.command); + case Mode::run: { + // 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 = !args.disable_nsenter && geteuid() != 0; + if (geteuid() == 0 && !args.disable_nsenter) { + spdlog::debug("running as root; skipping nsenter (the mount is already directly visible)"); + } + return run_container(args.mode_arg, args.command, use_nsenter, args.user_flag, args.group_flag, + args.hostname_flag, args.volume_specs, args.env_specs, args.daemonize_flag, + config); + } + } + return 1; // unreachable +} diff --git a/src/commands.h b/src/commands.h new file mode 100644 index 0000000..531d095 --- /dev/null +++ b/src/commands.h @@ -0,0 +1,27 @@ +// 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 "cli_args.h" +#include "config_file.h" + +// Runs whichever command args.mode selects (an exhaustive switch over Mode -- +// see commands.cpp -- so a Mode value ever added without a matching case +// triggers a -Wswitch warning/error rather than silently falling through). +int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config_path, AppConfig& config); diff --git a/src/main.cpp b/src/main.cpp index 00f70ab..3207985 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,655 +14,14 @@ // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -#include -#include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include #include -#include "bwrap.h" -#include "config.h" +#include "cli_args.h" +#include "commands.h" #include "config_file.h" -#include "containers_storage.h" -#include "daemonize.h" -#include "env_spec.h" -#include "exec_session.h" -#include "oci_image.h" -#include "pid_file.h" -#include "process.h" -#include "user_spec.h" -#include "volume_mount.h" - -namespace { - -constexpr std::array required_tools = {"containers-storage", "bwrap"}; - -// Shared by list_images_command()/list_volumes_command()/list_processes_command(): -// pad each entry with tabs (not spaces) so columns line up on an 8-column tab -// stop past the longest entry in that column, however long that is. -constexpr size_t tab_width = 8; - -enum class Mode { - none, - mount, - unmount, - test, - run, - cleanup, - list_images, - volume, - list_volumes, - delete_volume, - delete_volume_full, - inspect, - list_processes, - clean_processes, - exec -}; - -// --log-level/--user/--group/--list-volumes/--delete-volume[-full]/--hostname/ -// --list-processes/--clean-processes/--env/--env-file have no short form -// (--log-level's was freed up so -l could become --list-images; -u is already -// --umount; the rest have no natural free letter left, or don't need one), so -// they need long-option vals outside the printable-char range short options use. -namespace options { -constexpr int log_level = 256; -constexpr int user = 257; -constexpr int group = 258; -constexpr int list_volumes = 259; -constexpr int delete_volume = 260; -constexpr int delete_volume_full = 261; -constexpr int hostname = 262; -constexpr int list_processes = 263; -constexpr int clean_processes = 264; -constexpr int env = 265; -constexpr int env_file = 266; -} // namespace options - -constexpr std::array long_options = {{ - {"help", no_argument, nullptr, 'h'}, - {"version", no_argument, nullptr, 'V'}, - {"test", no_argument, nullptr, 't'}, - {"log-level", required_argument, nullptr, options::log_level}, - {"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'}, - {"daemonize", no_argument, nullptr, 'D'}, - {"list-images", required_argument, nullptr, 'l'}, - {"user", required_argument, nullptr, options::user}, - {"group", required_argument, nullptr, options::group}, - {"volume", required_argument, nullptr, 'v'}, - {"list-volumes", no_argument, nullptr, options::list_volumes}, - {"delete-volume", required_argument, nullptr, options::delete_volume}, - {"delete-volume-full", required_argument, nullptr, options::delete_volume_full}, - {"inspect", required_argument, nullptr, 'i'}, - {"exec", required_argument, nullptr, 'e'}, - {"hostname", required_argument, nullptr, options::hostname}, - {"list-processes", no_argument, nullptr, options::list_processes}, - {"clean-processes", no_argument, nullptr, options::clean_processes}, - {"env", required_argument, nullptr, options::env}, - {"env-file", required_argument, nullptr, options::env_file}, - {nullptr, 0, nullptr, 0}, -}}; - -void print_usage(const char* prog) { - fmt::print( - "usage: {0} -m|--mount \n" - " {0} -r|--run [-v ]... [-- [args...]]\n" - " {0} -u|--umount \n" - " {0} -c|--cleanup \n" - " {0} -l|--list-images \n" - " {0} -i|--inspect \n" - " {0} -e|--exec [-- [args...]]\n" - " {0} -v|--volume \n" - " {0} --list-volumes\n" - " {0} --delete-volume \n" - " {0} --delete-volume-full \n" - " {0} --list-processes\n" - " {0} --clean-processes\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: the image's own Entrypoint/Cmd if set,\n" - " else /bin/sh; pass -- [args...] to\n" - " override), then unmount and clean up when it\n" - " exits\n" - " -u, --umount unmount a previously mounted image layer (the\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" - " -D, --daemonize with --run, fork into the background: detaches\n" - " from the controlling terminal (setsid()) and\n" - " ignores SIGHUP, redirecting stdin from /dev/null\n" - " and stdout/stderr to a log file under\n" - " $XDG_STATE_HOME/slocker-lite/logs/. Prints the\n" - " session's pid and log path, then returns\n" - " immediately -- the same pid --list-processes/\n" - " -e/--exec use\n" - " --user with --run, run the command as this user (name\n" - " or numeric uid) instead of the image's own\n" - " declared user (or root, if it declares none),\n" - " resolved against the image's own /etc/passwd;\n" - " only takes effect when --run executes as root\n" - " (no user namespace involved)\n" - " --group with --user, use this group (name or numeric\n" - " gid) instead of the user's own primary group\n" - " --hostname with --run, set the sandbox's hostname (only\n" - " takes effect if the running kernel supports\n" - " --unshare-uts; otherwise ignored with a\n" - " warning)\n" - " --env VAR=VALUE with --run, set an environment variable in the\n" - " sandbox (overrides the default PATH/HOME/PWD/\n" - " TERM if given the same name). May be repeated;\n" - " combined with --env-file in command-line order,\n" - " each later one winning over an earlier one for\n" - " the same name\n" - " --env-file with --run, load environment variables from\n" - " (one VAR=VALUE per line; blank lines and\n" - " #-comments are skipped). May be repeated\n" - " -l, --list-images list OCI Image Layout tars (*.tar, *.tar.*) found\n" - " directly in , with their name:tag\n" - " -i, --inspect print an image's declared user, exposed ports,\n" - " env, volumes, and default command, without\n" - " mounting or running it\n" - " -e, --exec join a running --run session (pid must be one\n" - " shown by --list-processes as \"running\") and run\n" - " a command inside its container; pass\n" - " -- [args...] to specify 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. 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" - " remove a named volume from the config (the host\n" - " directory is left untouched)\n" - " --delete-volume-full \n" - " like --delete-volume, but also recursively\n" - " deletes the volume's host directory\n" - " --list-processes list running --run sessions found by their pid\n" - " files under $XDG_STATE_HOME/slocker-lite/run/,\n" - " with their pid, container name, and status\n" - " (running or exited)\n" - " --clean-processes remove stale pid files (see --list-processes)\n" - " left behind by sessions that are no longer\n" - " running\n" - " -t, --test run the test suite\n" - " --log-level set log verbosity (trace, debug, info, warn,\n" - " error, critical, off)\n" - " -h, --help print this help and exit\n" - " -V, --version print version information and exit\n", - prog); -} - -void print_version() { - fmt::print( - "{} {}\n" - "Licensed under GNU GPL version 2 or later \n" - "This is free software: you are free to change and redistribute it.\n" - "There is NO WARRANTY, to the extent permitted by law.\n", - PACKAGE, VERSION); -} - -bool apply_log_level(std::string_view name) { - constexpr std::array valid_levels = { - "trace", "debug", "info", "warn", "error", "critical", "off"}; - for (auto level : valid_levels) { - if (level == name) { - spdlog::set_level(spdlog::level::from_str(std::string(name))); - return true; - } - } - spdlog::error("invalid log level: {}", name); - return false; -} - -int run_tests() { - for (const auto& arg : detect_bwrap_unshare_args()) { - fmt::print("{}\n", arg); - } - return 0; -} - -} // namespace - -bool check_required_dependencies() { - bool all_found = true; - for (auto name : required_tools) { - if (!find_in_path(name)) { - spdlog::error("required dependency not found in PATH: {}", name); - all_found = false; - } - } - return all_found; -} - -int unmount_image(const std::string& layer_id) { - if (!check_required_dependencies()) { - return 1; - } - if (!unmount_layer(layer_id)) { - spdlog::error("failed to unmount layer {}", layer_id); - return 1; - } - fmt::print("unmounted layer {}\n", 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; - } - g_mount_program = 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 list_images_command(const std::filesystem::path& dir) { - auto images = list_oci_images(dir); - if (!images) { - return 1; - } - - std::vector refs; - refs.reserve(images->size()); - size_t max_len = 0; - for (const auto& ref : *images) { - refs.push_back(fmt::format("{}:{}", ref.name, ref.tag)); - max_len = std::max(max_len, refs.back().size()); - } - - size_t target_tabs = max_len / tab_width + 1; - - for (size_t i = 0; i < images->size(); ++i) { - size_t tabs_used = refs[i].size() / tab_width; - size_t tabs_needed = target_tabs > tabs_used ? target_tabs - tabs_used : 1; - fmt::print("{}{}{}\n", refs[i], std::string(tabs_needed, '\t'), - (*images)[i].path.filename().string()); - } - return 0; -} - -// Prints every OciImageConfig field for a human to read, without mounting or -// running the image. Extend this whenever OciImageConfig gains a new field. -int inspect_image_command(const std::filesystem::path& image_tar) { - auto config = read_oci_image_config(image_tar); - if (!config) { - return 1; - } - - fmt::print("Image: {}\n", image_tar.string()); - - if (config->user.empty()) { - fmt::print("User: (not set)\n"); - } else if (config->group.empty()) { - fmt::print("User: {}\n", config->user); - } else { - fmt::print("User: {}:{}\n", config->user, config->group); - } - - fmt::print("Command: {}\n", - config->command.empty() ? "(not set)" : fmt::to_string(fmt::join(config->command, " "))); - - if (config->exposed_ports.empty()) { - fmt::print("Exposed ports: (none)\n"); - } else { - fmt::print("Exposed ports:\n"); - for (const auto& port : config->exposed_ports) { - fmt::print(" {}/{}\n", port.port, port.protocol == OciPortProtocol::tcp ? "tcp" : "udp"); - } - } - - if (config->env.empty()) { - fmt::print("Env: (none)\n"); - } else { - fmt::print("Env:\n"); - for (const auto& e : config->env) { - fmt::print(" {}\n", e); - } - } - - if (config->volumes.empty()) { - fmt::print("Volumes: (none)\n"); - } else { - fmt::print("Volumes:\n"); - for (const auto& v : config->volumes) { - fmt::print(" {}\n", v); - } - } - - return 0; -} - -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) { - if (volume.name == name) { - spdlog::error("a volume named '{}' already exists (directory: {})", name, volume.directory); - return 1; - } - if (volume.directory == resolved.string()) { - spdlog::error("directory {} is already used by volume '{}'", resolved.string(), volume.name); - return 1; - } - } - - std::error_code ec; - bool created = std::filesystem::create_directories(resolved, ec); - if (ec) { - spdlog::error("failed to create directory {}: {}", resolved.string(), ec.message()); - return 1; - } - if (!created) { - spdlog::warn("directory {} already exists", resolved.string()); - std::error_code empty_ec; - bool empty = std::filesystem::is_empty(resolved, empty_ec); - if (!empty_ec && !empty) { - spdlog::warn("directory {} is not empty", resolved.string()); - } - } - - config.volumes.push_back({name, resolved.string()}); - if (!write_config_file(config_path, config)) { - return 1; - } - - fmt::print("created volume '{}' -> {}\n", name, resolved.string()); - return 0; -} - -int list_volumes_command(const AppConfig& config) { - size_t max_len = 0; - for (const auto& volume : config.volumes) { - max_len = std::max(max_len, volume.name.size()); - } - - size_t target_tabs = max_len / tab_width + 1; - - for (const auto& volume : config.volumes) { - size_t tabs_used = volume.name.size() / tab_width; - size_t tabs_needed = target_tabs > tabs_used ? target_tabs - tabs_used : 1; - fmt::print("{}{}{}\n", volume.name, std::string(tabs_needed, '\t'), volume.directory); - } - return 0; -} - -int list_processes_command() { - auto sessions = list_sessions(); - - std::vector pids; - std::vector names; - pids.reserve(sessions.size()); - names.reserve(sessions.size()); - size_t max_pid_len = 0; - size_t max_name_len = 0; - for (const auto& session : sessions) { - pids.push_back(fmt::format("{}", session.pid)); - names.push_back(session.container_name); - max_pid_len = std::max(max_pid_len, pids.back().size()); - max_name_len = std::max(max_name_len, names.back().size()); - } - - // Applied independently to each of the two variable-width columns. - size_t pid_target_tabs = max_pid_len / tab_width + 1; - size_t name_target_tabs = max_name_len / tab_width + 1; - - for (size_t i = 0; i < sessions.size(); ++i) { - size_t pid_tabs_used = pids[i].size() / tab_width; - size_t pid_tabs_needed = pid_target_tabs > pid_tabs_used ? pid_target_tabs - pid_tabs_used : 1; - size_t name_tabs_used = names[i].size() / tab_width; - size_t name_tabs_needed = name_target_tabs > name_tabs_used ? name_target_tabs - name_tabs_used : 1; - fmt::print("{}{}{}{}{}\n", pids[i], std::string(pid_tabs_needed, '\t'), names[i], - std::string(name_tabs_needed, '\t'), sessions[i].running ? "running" : "exited"); - } - return 0; -} - -int clean_processes_command() { - for (const auto& session : clean_stale_sessions()) { - fmt::print("removed stale pid file for '{}' (pid {})\n", session.container_name, session.pid); - } - return 0; -} - -int delete_volume_command(const std::string& name, const std::filesystem::path& config_path, - AppConfig& config, bool delete_directory) { - auto it = std::find_if(config.volumes.begin(), config.volumes.end(), - [&](const VolumeEntry& volume) { return volume.name == name; }); - if (it == config.volumes.end()) { - spdlog::error("no volume named '{}' exists", name); - return 1; - } - - if (delete_directory) { - if (!std::filesystem::exists(it->directory)) { - spdlog::warn("directory {} does not exist, nothing to delete", it->directory); - } - std::error_code ec; - std::filesystem::remove_all(it->directory, ec); - if (ec) { - spdlog::error("failed to delete directory {}: {}", it->directory, ec.message()); - return 1; - } - } - - config.volumes.erase(it); - if (!write_config_file(config_path, config)) { - return 1; - } - - fmt::print("deleted volume '{}'\n", name); - return 0; -} - -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& hostname, - const std::vector>& volume_specs, - const std::vector& env_specs, bool daemonize_flag, const AppConfig& app_config) { - // Only depends on image_tar, so this can run before mount_image() -- moved - // up here (rather than right before the run_bwrap() call, as before) so - // daemonize() below can use the real container name for the log file from - // its very first line, not just after a later rename. - auto image_ref = read_image_ref(image_tar); - std::string container_name = - image_ref ? fmt::format("{}:{}", image_ref->name, image_ref->tag) : image_tar.stem().string(); - - if (daemonize_flag) { - auto result = daemonize(container_name); - if (result) { - // Parent (or a hard daemonize failure before ever forking) -- report - // and return immediately. The child falls through below instead. - if (result->pid) { - fmt::print("started in background: pid {}, log: {}\n", *result->pid, result->log_path); - return 0; - } - spdlog::error("failed to start in background{}", result->log_path.empty() - ? "" - : fmt::format(" (see log: {})", result->log_path)); - return 1; - } - } - - auto mounted = mount_image(image_tar); - if (!mounted) { - return 1; - } - fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id); - - 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)); - } - - auto resolved_env = resolve_env_specs(env_specs); - if (!resolved_env) { - ok = false; - } - - // 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; - std::optional effective_group = group; - if (!effective_user && config && !config->user.empty()) { - effective_user = config->user; - effective_group = config->group.empty() ? std::nullopt : std::optional(config->group); - } - - std::optional resolved_user; - if (effective_user) { - resolved_user = resolve_user_and_group(*effective_user, effective_group, mounted->merged_path); - if (!resolved_user) { - ok = false; - } - } - - std::vector command = requested_command; - if (command.empty()) { - command = (config && !config->command.empty()) ? config->command - : std::vector{"/bin/sh"}; - } - - std::function on_bwrap_pid_known; - if (daemonize_flag) { - on_bwrap_pid_known = [&](pid_t pid) { report_daemon_started(container_name, pid); }; - } - - int exit_code = -1; - if (ok) { - exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, volume_mounts, resolved_user, hostname, - container_name, *resolved_env, on_bwrap_pid_known); - 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 (!ok || exit_code < 0) ? 1 : exit_code; -} int main(int argc, char* argv[]) { spdlog::cfg::load_env_levels(); @@ -676,237 +35,10 @@ int main(int argc, char* argv[]) { apply_log_level(*config->log_level); } - Mode mode = Mode::none; - std::string mode_arg; - bool disable_nsenter = false; - bool daemonize_flag = false; - std::optional user_flag; - std::optional group_flag; - std::optional hostname_flag; - std::vector> volume_specs; - std::vector env_specs; - - opterr = 0; - int opt; - while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:e:D", long_options.data(), nullptr)) != -1) { - switch (opt) { - case 'h': - print_usage(argv[0]); - return 0; - case 'V': - print_version(); - return 0; - case 't': - case 'm': - case 'u': - case 'r': - case 'c': - case 'l': - case 'i': - case 'e': - case options::list_volumes: - case options::delete_volume: - case options::delete_volume_full: - case options::list_processes: - case options::clean_processes: { - Mode requested; - switch (opt) { - case 't': - requested = Mode::test; - break; - case 'm': - requested = Mode::mount; - break; - case 'u': - requested = Mode::unmount; - break; - case 'r': - requested = Mode::run; - break; - case 'c': - requested = Mode::cleanup; - break; - case 'l': - requested = Mode::list_images; - break; - case 'i': - requested = Mode::inspect; - break; - case 'e': - requested = Mode::exec; - break; - case options::list_volumes: - requested = Mode::list_volumes; - break; - case options::delete_volume: - requested = Mode::delete_volume; - break; - case options::delete_volume_full: - requested = Mode::delete_volume_full; - break; - case options::list_processes: - requested = Mode::list_processes; - break; - default: - requested = Mode::clean_processes; - break; - } - if (mode != Mode::none && mode != requested) { - spdlog::error("multiple actions specified"); - print_usage(argv[0]); - return 1; - } - mode = requested; - if (optarg) { - mode_arg = optarg; - } - 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; - case 'D': - daemonize_flag = true; - break; - case options::log_level: - if (!apply_log_level(optarg)) { - return 1; - } - break; - case options::user: - user_flag = optarg; - break; - case options::group: - group_flag = optarg; - break; - case options::hostname: - hostname_flag = optarg; - break; - case options::env: - env_specs.push_back({false, optarg}); - break; - case options::env_file: - env_specs.push_back({true, optarg}); - break; - case ':': - spdlog::error("option requires an argument: -{}", static_cast(optopt)); - print_usage(argv[0]); - return 1; - case '?': - default: - spdlog::error("unrecognized option"); - print_usage(argv[0]); - return 1; - } + ParsedArgs args; + if (auto exit_code = parse_args(argc, argv, args)) { + return *exit_code; } - if (!volume_specs.empty() && mode != Mode::run) { - if (mode != Mode::none) { - 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::volume; - } - - if (mode == Mode::none) { - print_usage(argv[0]); - return 1; - } - if (mode != Mode::run && mode != Mode::exec && optind != argc) { - print_usage(argv[0]); - return 1; - } - if (group_flag && !user_flag) { - spdlog::error("--group requires --user"); - print_usage(argv[0]); - return 1; - } - - if (mode == Mode::test) { - return run_tests(); - } - if (mode == Mode::unmount) { - return unmount_image(mode_arg); - } - if (mode == Mode::cleanup) { - return cleanup_image(mode_arg); - } - if (mode == Mode::list_images) { - return list_images_command(mode_arg); - } - if (mode == Mode::inspect) { - return inspect_image_command(mode_arg); - } - if (mode == Mode::volume) { - return create_volume_command(volume_specs.front().first, volume_specs.front().second, config_path, - *config); - } - if (mode == Mode::list_volumes) { - return list_volumes_command(*config); - } - if (mode == Mode::delete_volume || mode == Mode::delete_volume_full) { - return delete_volume_command(mode_arg, config_path, *config, mode == Mode::delete_volume_full); - } - if (mode == Mode::list_processes) { - return list_processes_command(); - } - if (mode == Mode::clean_processes) { - return clean_processes_command(); - } - if (mode == Mode::exec) { - std::vector command(argv + optind, argv + argc); - if (command.empty()) { - spdlog::error("--exec requires a command to run"); - print_usage(argv[0]); - return 1; - } - char* end = nullptr; - long parsed = std::strtol(mode_arg.c_str(), &end, 10); - if (mode_arg.empty() || !end || *end != '\0' || parsed <= 0) { - spdlog::error("invalid pid: {}", mode_arg); - return 1; - } - return exec_in_session(static_cast(parsed), command); - } - if (mode == Mode::run) { - std::vector command(argv + optind, argv + argc); - // 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)"); - } - return run_container(mode_arg, command, use_nsenter, user_flag, group_flag, hostname_flag, volume_specs, - env_specs, daemonize_flag, *config); - } - - auto mounted = mount_image(mode_arg); - if (!mounted) { - return 1; - } - fmt::print("mounted image at: {} (layer {})\n", mounted->merged_path, mounted->top_layer_id); - return 0; + return dispatch_command(args, config_path, *config); } diff --git a/src/self_test.cpp b/src/self_test.cpp new file mode 100644 index 0000000..f440fa1 --- /dev/null +++ b/src/self_test.cpp @@ -0,0 +1,28 @@ +// 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 "self_test.h" + +#include + +#include "bwrap.h" + +int run_self_tests() { + for (const auto& arg : detect_bwrap_unshare_args()) { + fmt::print("{}\n", arg); + } + return 0; +} diff --git a/src/self_test.h b/src/self_test.h new file mode 100644 index 0000000..27f414f --- /dev/null +++ b/src/self_test.h @@ -0,0 +1,23 @@ +// 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 + +// Implements -t/--test, this project's own built-in self-test mode (distinct +// from the Meson-driven fixture smoke test under tests/). Currently just +// reports which bwrap --unshare-xxx flags the running kernel supports (see +// detect_bwrap_unshare_args(), bwrap.h) -- a placeholder for real tests. +int run_self_tests();