00482b6b42
Validates the given pid against the same tracked-session liveness check --list-processes/--clean-processes already use, then joins its namespaces via nsenter and runs a command there in the foreground. Two things discovered only by testing against a live session, not assumed up front: - The tracked pid is bwrap's own outer process. It sets up the mount/user namespaces itself, then clone()s the actual sandboxed command into fresh pid/uts/ipc/cgroup namespaces -- clone()'s namespace flags only ever affect the new child, never the caller, so the outer process itself never enters those namespaces at all. exec_in_session() resolves that real child via /proc/<pid>/task/<pid>/children and joins its namespaces instead, falling back to the outer pid if that can't be read. - Rather than nsenter -a (which would hit a known "Invalid argument" failure re-entering an identical namespace -- this project already worked around exactly that once, for the containers-storage mount path), each namespace type is only joined if /proc/<pid>/ns/<type> actually differs from this process's own. nsenter also needs --preserve-credentials, or it tries to setuid/setgid/setgroups to the target's identity, which fails outright against the setgroups-denied unprivileged user namespace bwrap creates whenever -r/--run isn't root. Verified end-to-end: joined shell gets the container's own hostname, process tree (ps shows only container processes), and root filesystem; untracked/stale pids error out cleanly without touching nsenter; Ctrl-C during the joined command doesn't disturb the original session; no leftover mounts after either exits. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
367 lines
26 KiB
Markdown
367 lines
26 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## Project state
|
|
|
|
`slocker-lite` (C++20, built with Meson) mounts an OCI Image Layout tar (`oci-layout` +
|
|
`index.json` + `blobs/sha256/*`, as produced by `skopeo`/`podman save --format
|
|
oci-archive`/modern `docker save`) using `containers-storage` and `fuse-overlayfs`, then
|
|
(via `-r/--run`) runs a sandboxed command against it with `bwrap`. The real deployment
|
|
target is Android with a stock kernel, where `podman`/`docker` don't run (missing
|
|
namespace support) and there's no kernel overlayfs (hence `fuse-overlayfs`); `bwrap` is
|
|
invoked in "degraded mode" using only whichever `--unshare-xxx` namespaces the running
|
|
kernel actually supports. See `README.md` for the human-facing overview (build/usage/
|
|
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()`/
|
|
`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 '<name>' (pid
|
|
<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 <pid>`
|
|
(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.
|
|
`inspect_image_command()` implements `-i/--inspect
|
|
<image.tar>`: prints every `OciImageConfig` field (user/group, exposed ports, env,
|
|
volumes, default command) without mounting or running the image — extend it
|
|
whenever `OciImageConfig` gains a new field (see `oci_image.{h,cpp}` below).
|
|
`run_container()` unconditionally calls `read_oci_image_config()` and reuses the
|
|
result for two independent defaults: the command to run (`Entrypoint ++ Cmd`) when
|
|
none is given on the command line, and, when `--user` wasn't given, the sandboxed
|
|
process's user/group (`config.User`, split into `OciImageConfig::user`/`group`) —
|
|
an explicit `--user`/`--group` on the command line always takes precedence.
|
|
`create_volume_command()` implements `-v/--volume <name> <directory>`;
|
|
`list_volumes_command()` implements `--list-volumes` (same tab-alignment scheme as
|
|
`list_images_command()`, reused as-is); `delete_volume_command()` implements both
|
|
`--delete-volume <name>` (config entry only) and `--delete-volume-full <name>`
|
|
(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 <name>`
|
|
(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.
|
|
- `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,
|
|
derives an image name/tag via `read_image_ref()` from its `index.json` manifest
|
|
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
|
|
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)
|
|
— `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`,
|
|
`mount`, `unmount`, `layer --json`, `delete-layer`), forcing `fuse-overlayfs` as the
|
|
overlay `mount_program`. `cleanup_layer_chain()` walks a layer's parent chain
|
|
(children before parents) deleting each one.
|
|
- `bwrap.{h,cpp}` — `detect_bwrap_unshare_args()` probes the kernel (via a forked
|
|
`unshare(2)` per namespace type) for which `--unshare-xxx` flags `bwrap` can actually
|
|
use; `build_bwrap_args()`/`run_bwrap()` assemble and run the sandboxed command.
|
|
`build_bwrap_args()` also takes a `std::vector<ResolvedVolumeMount>` (see
|
|
`volume_mount.h` below) and appends one writable `--bind <host_directory>
|
|
<container_path>` 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
|
|
once network isolation is implemented; `detect_bwrap_unshare_args()` itself still
|
|
probes/reports it (e.g. via `-t/--test`), since that's kernel capability, not policy.
|
|
`build_bwrap_args()`/`run_bwrap()` also take an optional `hostname` (from
|
|
`--hostname`, long-option only): passed through as bwrap's own `--hostname` only
|
|
when `--unshare-uts` is actually among the flags `bwrap` is being given (bwrap
|
|
itself refuses `--hostname` without it) — otherwise logs a warning and leaves the
|
|
sandbox's hostname alone, since a stock Android kernel in degraded mode may not
|
|
support a UTS namespace at all.
|
|
Never requests `--unshare-user` when running as root: root doesn't need a fresh
|
|
user namespace for privilege, and bwrap's own single-mapping uid/gid setup for one
|
|
triggers the kernel's unprivileged-userns setgroups() restriction, which showed up
|
|
as every other supplementary group collapsing to the overflow gid ("nobody") in
|
|
`id`, and `su` inside the sandbox failing with "can't set groups: Operation not
|
|
permitted". Because of that, bwrap's own `--uid`/`--gid` (which require
|
|
`--unshare-user`) aren't usable when running as root either — `--user`/`--group`
|
|
work around this: when set, `build_bwrap_args()`/`run_bwrap()` bind-mount the
|
|
separate `slocker-lite-priv-drop` helper (see below) into the sandbox at a fixed
|
|
hidden path and route the real command through it as
|
|
`<uid>:<gid> -- <command...>`. This only actually works without a user namespace
|
|
(i.e. running as root) — under `--unshare-user`, the sandbox's uid map has only
|
|
one valid entry, so the helper's own `setuid()` fails cleanly there instead of
|
|
silently doing nothing. `run_bwrap()` fails fast (returns -1) if the helper can't
|
|
be found next to this binary when `--user` was requested, rather than silently
|
|
running the command as root. `run_bwrap()` also takes a `container_name` and
|
|
tracks the running session with it: it passes a lambda as
|
|
`run_process_foreground()`'s new `on_start` callback (see `process.{h,cpp}`
|
|
below) that calls `create_session_lock(container_name, pid)` (`pid_file.{h,cpp}`,
|
|
see below) the instant the real `bwrap` pid is known, then calls
|
|
`release_session_lock()` once `run_process_foreground()` returns (covering
|
|
every exit path — normal, nonzero, or a forwarded-signal exit — since that call
|
|
always blocks until the child has actually exited).
|
|
- `priv_drop_helper.cpp` → the separate `slocker-lite-priv-drop` binary (its own
|
|
`executable()` target in `meson.build`, **built with `-static`**). Deliberately
|
|
has zero dependencies on the rest of this project (no fmt/spdlog/etc.) and is
|
|
fully statically linked: it gets bind-mounted *into the container image's own
|
|
filesystem*, which won't have `slocker-lite`'s own shared library dependencies —
|
|
a dynamically linked binary bind-mounted that way fails outright ("error while
|
|
loading shared libraries"), which is exactly what happened before this was split
|
|
out (the original approach bind-mounted `slocker-lite`'s own — dynamically linked
|
|
— binary via `/proc/self/exe` and reexeced it; kept only as a lesson, not as
|
|
working code). Usage: `slocker-lite-priv-drop <uid>:<gid> -- <command> [args...]`;
|
|
does `setgroups(0,…)` → `setgid()` → `setuid()` → `execvp()`, in that order
|
|
(dropping the group needs `CAP_SETGID`, which is lost once `setuid()` drops root).
|
|
`find_priv_drop_helper()` (`src/bwrap.cpp`) locates it next to `slocker-lite`'s own
|
|
binary (via `/proc/self/exe`'s directory), which holds both when run straight from
|
|
`buildDir/` and after a real `meson install`.
|
|
- `user_spec.{h,cpp}` — `resolve_user_and_group()` resolves a user/group spec (each
|
|
a name or numeric id) against the *mounted image's own* `/etc/passwd`/`/etc/group`
|
|
(not the host's), since names like `git` only mean anything inside that image's own
|
|
user database. A numeric user with no group and no matching `/etc/passwd` entry
|
|
defaults gid to the same numeric value as the uid. `ResolvedUser` (`bwrap.h`) also
|
|
carries `home`, looked up by the final resolved uid's `/etc/passwd` entry (field 5)
|
|
regardless of whether `user` was given as a name or a number; falls back to
|
|
`"/root"` for uid 0 or `"/"` otherwise when there's no matching row.
|
|
`build_bwrap_args()` (`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()`
|
|
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
|
|
root, unless the image declares none.
|
|
- `process.{h,cpp}` — argv-based subprocess helpers (fork/execvp, no shell):
|
|
`run_process()` captures stdout (used for `containers-storage` calls),
|
|
`run_process_foreground()` inherits all of stdio (used for the interactive `bwrap`
|
|
run). Also `find_in_path()`, a shared `$PATH` lookup. `run_process_foreground()`
|
|
installs a SIGINT/SIGTERM handler around its `waitpid()` that forwards the signal
|
|
to the running child and keeps waiting instead of letting the default disposition
|
|
kill `slocker-lite` itself — without this, Ctrl-C (or `kill`) during `-r`'s `bwrap`
|
|
run would skip `run_container()`'s unmount/cleanup entirely, leaving the layer
|
|
imported and/or mounted. `run_process_foreground()` also takes an optional
|
|
`on_start` callback, invoked with the child's real pid right after `fork()`
|
|
succeeds (before the signal handlers go up and it blocks in `waitpid()`) — the
|
|
only point where that pid is knowable, and still accurate even when `argv`
|
|
itself execs into something else first (e.g. `nsenter` handing off to the final
|
|
command via its own in-place `execvp()` — a pid never changes across `exec()`).
|
|
`run_bwrap()` (`bwrap.cpp`) is the one caller that uses it, for session pid-file
|
|
tracking (see `pid_file.{h,cpp}` below).
|
|
- `pid_file.{h,cpp}` — tracks one running `-r/--run` session (a live `bwrap`
|
|
process) as a locked pid file, so an outside process (or a later
|
|
`slocker-lite` invocation) can tell whether it's still running.
|
|
`session_pid_file_path()` resolves
|
|
`$XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>` (falling back to
|
|
`$HOME/.local/state/...` when `XDG_STATE_HOME` is unset/empty — same
|
|
resolution pattern as `config_file_path()` below, for state instead of
|
|
config), sanitizing `container_name` first (anything outside `[A-Za-z0-9._-]`
|
|
→ `_`, since an image name/tag can contain `/` or `:`). `create_session_lock()`
|
|
creates the file (`O_CREAT|O_WRONLY|O_TRUNC|O_CLOEXEC`, mode 0644 — `O_CLOEXEC`
|
|
matters: this fd must never leak into the sandboxed command's own fd table),
|
|
writes the pid as text, and takes an exclusive, non-blocking `flock()` on it —
|
|
held only by that fd, so its lifetime tracks `slocker-lite`'s own process
|
|
lifetime (released automatically on any exit, including a crash), which lines
|
|
up with `bwrap` itself being invoked with `--die-with-parent`. Any external
|
|
tool can check liveness the same way: attempt the same exclusive non-blocking
|
|
`flock()` on the file — success means nothing holds it anymore (stale, safe to
|
|
remove), `EWOULDBLOCK` means a live process still does. `release_session_lock()`
|
|
closes the fd (releasing the flock immediately) and removes the file. Every
|
|
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
|
|
`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 `-`);
|
|
`container_name` is then recovered by stripping that exact `-<pid>` suffix
|
|
back off the filename. `running` reuses the same liveness check any external
|
|
tool would do — a non-blocking exclusive `flock()` that succeeds means the
|
|
file is actually stale, so `running` is false in that case; the lock is always
|
|
released again immediately either way, never left held by the check itself. A
|
|
file that can't be opened or doesn't parse as a pid (e.g. removed mid-scan) is
|
|
silently skipped, not reported as an error — scanning a live directory is
|
|
inherently racy. Both `list_sessions()` and `clean_stale_sessions()` (the
|
|
latter implements `--clean-processes`) share a private `open_session_file()`
|
|
helper for the open/read-pid/recover-name step. `clean_stale_sessions()`
|
|
doesn't just remove whatever a separate `list_sessions()` call reported as not
|
|
running — it re-takes the same non-blocking `flock()` used to test liveness
|
|
and holds it across the `remove()` call itself, per file, so the stale check
|
|
and the removal stay atomic against a new session starting in the gap between
|
|
a check and a later removal. Only files it actually removes are reported back
|
|
(as `SessionInfo`s with `running=false`); still-locked (running) files are
|
|
left untouched and not reported.
|
|
- `exec_session.{h,cpp}` — implements `-e/--exec <pid>`: joins an already-running
|
|
`-r/--run` session's namespaces via `nsenter` and runs a command inside it in
|
|
the foreground. `exec_in_session()` first confirms `pid` is a tracked, running
|
|
session via `list_sessions()` (`pid_file.h`) — same liveness check
|
|
`--list-processes`/`--clean-processes` already use, no new logic needed there.
|
|
**Key discovery, confirmed by direct testing, not assumed**: `pid` (the one
|
|
`run_process_foreground()` captured and pid-file-tracked when `-r` launched
|
|
`bwrap`) is bwrap's own *outer* process — it sets up the mount and user
|
|
namespaces itself, then `clone()`s the actual sandboxed command into fresh
|
|
pid/uts/ipc/cgroup namespaces, and `clone()`'s namespace-creation flags only
|
|
ever affect the newly created child, never the caller. So the outer process
|
|
itself never actually enters those namespaces — comparing
|
|
`/proc/<outer_pid>/ns/{pid,uts,ipc,cgroup}` against this process's own showed
|
|
them identical, while only `mnt`/`user` differed. `resolve_namespace_pid()`
|
|
reads `/proc/<pid>/task/<pid>/children` (the direct-children list `procfs`
|
|
exposes) to find that real inner process and joins *its* namespaces instead —
|
|
falls back to `pid` itself (best-effort, not fatal) if that file can't be
|
|
read. For each of `{mnt→--mount, uts→--uts, ipc→--ipc, pid→--pid,
|
|
cgroup→--cgroup, user→--user}` (`net` deliberately excluded — this project
|
|
never isolates networking, see `bwrap.cpp` below), `readlink()`s both
|
|
`/proc/<ns_pid>/ns/<type>` and `/proc/self/ns/<type>` and only passes
|
|
nsenter's corresponding `--type=/proc/<ns_pid>/ns/<type>` flag when they
|
|
differ — an identical-namespace re-entry attempt can fail outright
|
|
(`setns()`'s own `EINVAL` restriction on re-entering a namespace you're
|
|
already in), so skipping is deliberate, not just an optimization. `mnt` is the
|
|
one type where a *read* failure (permission denied, or the process vanished)
|
|
is treated as fatal, since without it "joining the container" is meaningless;
|
|
every other type just degrades to a skip. Always appends
|
|
`--preserve-credentials`: without it, `nsenter --user` also tries to
|
|
`setuid()`/`setgid()`/`setgroups()` to the target's identity within the new
|
|
user namespace, which fails outright (`setgroups failed: Operation not
|
|
permitted`) against the `setgroups`-denied unprivileged user namespace bwrap
|
|
creates whenever `-r/--run` isn't root — confirmed by hitting this exact
|
|
failure during manual testing before adding the flag. Runs the final
|
|
`nsenter ... -- <command>` via the existing `run_process_foreground()`
|
|
(`process.h`) — same inherited stdio and SIGINT/SIGTERM forwarding as every
|
|
other foreground external command, no new process-running logic needed.
|
|
- `config_file.{h,cpp}` — `load_config_file()` reads and parses (via libyaml's
|
|
document API, `<yaml.h>`) the `global` and `volumes` sections of the local YAML
|
|
config file located by `config_file_path()` (`$XDG_CONFIG_HOME/slocker-lite/config.yaml`,
|
|
falling back to `$HOME/.config/slocker-lite/config.yaml`). `global.log-level` is the
|
|
only supported `global` key — other long options are one-shot flags, not settings,
|
|
so they don't belong in a persistent config file. A missing file returns a
|
|
default-constructed (empty) `AppConfig`, not an error; unknown sections/keys (and
|
|
malformed individual volume entries) are ignored for forward-compatibility;
|
|
malformed YAML syntax is a hard error. `main()` applies `config->log_level` (via
|
|
the existing `apply_log_level()`) right after `spdlog::cfg::load_env_levels()` and
|
|
before parsing CLI options, so an explicit `--log-level` on the command line always
|
|
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
|
|
`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`
|
|
mapping created via `-v/--volume`, not an image's own declared mount points (still
|
|
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
|
|
`SPDLOG_LEVEL=debug`, since spdlog's default level is `info` — and a failed external
|
|
command additionally logs a `spdlog::warn`, which is visible by default (no env var
|
|
needed). The final "mounted image at: ..." success line is direct stdout program
|
|
output, not a log.
|
|
|
|
Because `containers-storage mount` runs rootless, it reexecs itself into a private
|
|
user+mount namespace to gain the privilege it needs for the overlay mount — which
|
|
leaves the result invisible to a plain shell or child process outside that namespace.
|
|
Confirmed `containers-storage unshare` does **not** rejoin an already-running mount's
|
|
namespace; only `nsenter` targeting the live `fuse-overlayfs` daemon's PID does.
|
|
`-r/--run` handles this automatically by locating that PID and running `bwrap` via
|
|
`nsenter` into its namespaces (`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.
|
|
`-r/--run` detects `geteuid() == 0` and skips `nsenter` automatically in that case;
|
|
`-n/--no-nsenter` forces it off manually for any other situation where the mount turns
|
|
out to already be directly visible.
|
|
|
|
## Build & test commands
|
|
|
|
Build directory is `buildDir/` (already configured).
|
|
|
|
- Configure (only needed if `buildDir/` is missing or deleted): `meson setup buildDir`
|
|
- Build: `meson compile -C buildDir` (or `ninja -C buildDir`) — also builds
|
|
`buildDir/slocker-lite-priv-drop`, the statically-linked helper `-r --user` needs
|
|
(see `priv_drop_helper.cpp` in "Project state")
|
|
- Run the executable: `./buildDir/slocker-lite -m <image.tar>` (see `--help` for the
|
|
full flag list: `-m/--mount`, `-r/--run`, `-u/--umount`, `-c/--cleanup`,
|
|
`-l/--list-images`, `-i/--inspect`, `-e/--exec`, `-n/--no-nsenter`, `--user`, `--group`,
|
|
`--hostname`, `-v/--volume`, `--list-volumes`, `--delete-volume`,
|
|
`--delete-volume-full`, `--list-processes`, `--clean-processes`, `-t/--test`, `--log-level`,
|
|
`-h/--help`, `-V/--version`)
|
|
- Run tests: `meson test -C buildDir`
|
|
|
|
## Code style
|
|
|
|
- Null-pointer checks: prefer `if (!ptr)` / `if (ptr)` over `if (ptr == nullptr)` / `if (ptr != nullptr)`.
|
|
|
|
## Licensing
|
|
|
|
- Every `.c`/`.cpp`/`.h` file under `src/` must start with the GPLv2-or-later copyright
|
|
header (see any existing file under `src/` for the exact text).
|
|
- After adding a new source file under `src/`, run `./add-license.sh` from the repo
|
|
root to prepend the header (it reads `copyright-header` and inserts it via `sed`,
|
|
skipping files that already have it, so it's safe to re-run at any time).
|
|
|
|
## Build configuration notes
|
|
|
|
- `meson.build` sets `warning_level=3` and `cpp_std=c++20` — keep new code warning-clean under `-Wall -Wextra -Wpedantic`-equivalent settings.
|
|
- The single Meson `test()` target runs `slocker-lite` against a fixture OCI image tar generated at build time by `tests/gen_fixture.py` (a `custom_target`) and checks its exit code (no test framework is wired in yet).
|