Files
slocker-lite/CLAUDE.md
T
ceamac 247e61d9d8 Add --user/--group support to -e/--exec, matching -r/--run
Previously -e/--exec always ran its command as the host's own invoking
credentials, ignoring whatever uid/gid the session's own -r/--run resolved
it to. Now it defaults to whatever the session's sandboxed command is
already running as (read from /proc/<pid>/status), and --user/--group can
override that, resolved against the session's own /etc/passwd/group
(fetched via nsenter, since the mount namespace isn't otherwise reachable).
Either way it's applied by reusing the already bind-mounted
slocker-lite-priv-drop helper from the original -r/--run, not a second copy.

Refactored resolve_user_and_group() (user_spec.{h,cpp}) to take passwd/group
*content* instead of a filesystem path, so both callers -- run_container()
(local file read) and exec_in_session() (nsenter + cat) -- can share it.
Exported priv_drop::path/helper_name and find_priv_drop_helper() from
bwrap.h so exec_session.cpp can reuse the same helper.

Caught and fixed a second bug during testing on a rootless dev machine:
under -r/--run's own --unshare-user, "root inside the container" is a uid
mapping, not a real privilege drop, so /proc/<pid>/status's uid/gid (read
from outside that namespace) is the host-mapped id, not the container's
own view -- defaulting to a priv-drop there was wrong and failed outright.
Fixed by skipping the default-identity lookup whenever --exec is already
joining a differing user namespace, since nsenter --preserve-credentials
alone already reproduces the container's view via that same kernel mapping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-29 08:05:55 +00:00

665 lines
49 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` — 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 <pid>`'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 <name>`/`--env
VAR=VALUE`/`--env-file <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<EnvSpec>` (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 '<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. `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
<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`). `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 <name>` 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 `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
non-whitespace character is `#` are skipped (comments), with a trailing `\r`
stripped first for CRLF files; every other line is parsed the same way as a
literal. Logs a specific error and returns `nullopt` on the first hard failure
(malformed line, empty key, or an unreadable file) — deliberately stops at the
first line, not "skip and warn", since an env file with a typo should fail
loudly rather than silently omit a variable a container might depend on.
`build_sandbox_env()` (`bwrap.cpp`, see below) appends the resolved list after
its own built-in `PATH`/`HOME`/`PWD`/`TERM` — no deduplication needed there,
since `run_process_foreground()`'s own `setenv(..., 1)` loop already lets the
later occurrence in iteration order win for a repeated key, so an explicit
`--env PATH=...` still overrides the default.
- `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()` (`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 `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`,
`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).
`build_bwrap_args()` no longer passes `--clearenv`/`--setenv` to `bwrap` itself;
instead, `build_sandbox_env()` builds the sandboxed command's exact environment
(`PATH`, `HOME`, `PWD` — hardcoded to `"/"`, matching `--chdir`'s own value; note
per bwrap's own man page `--clearenv` never actually unset `PWD` in the first
place, so this isn't a straight port of a prior `--setenv` — and `TERM`, only
if the host process has one, followed by `extra_env` — the resolved
`--env`/`--env-file` list from `resolve_env_specs()` (`env_spec.h`), appended
last so it can override the built-in defaults for the same key) and
`run_bwrap()` passes it straight to `run_process_foreground()`'s own `env`
override (see `process.{h,cpp}` below). This works because `bwrap` (and
`nsenter`, when interposed via `wrap_for_root_namespace()`) doesn't alter its
own inherited environment unless told to, and neither does
`slocker-lite-priv-drop` (just `setgroups()`/`setgid()`/`setuid()`/`execvp()`,
no env manipulation) — so controlling it once, at the outermost exec, is
sufficient for it to reach the final sandboxed command unchanged. **Because
that outermost exec now uses this same explicitly-built environment**,
`build_bwrap_args()`/`wrap_for_root_namespace()` resolve `bwrap`'s and
`nsenter`'s own argv[0] to an absolute path via `find_in_path()` (called from
this process's own, unmodified environment, before `fork()`) instead of
leaving them as bare names — confirmed by direct testing: `--env PATH=...`
used to break `execvp()`'s ability to even *locate* `bwrap`/`nsenter`
(bare-name lookup happens in the child, using the already-overridden PATH),
not just what the sandboxed command itself sees. With the fix, only the
*sandboxed command's own* lookup is affected by a `--env PATH=...` override
(as expected — same as overriding `PATH` in any real shell before running a
bare command name), and `bwrap`/`nsenter` are always found regardless.
`run_bwrap()` also takes an optional `on_bwrap_pid_known` callback, invoked
alongside (not instead of) the session-lock-creation lambda, at the exact
same `on_start` timing — `-D/--daemonize` (`daemonize.{h,cpp}`, see below)
hooks in here via `report_daemon_started()` to learn the real pid at the
same instant everything else that needs it does, rather than needing its own
separate pid-discovery mechanism.
- `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()` and the `priv_drop::path`/`priv_drop::helper_name`
constants live in `bwrap.h` (not just internal to `bwrap.cpp`) specifically so
`exec_in_session()` (`exec_session.cpp`, see below) can reuse the exact same
already-bind-mounted helper for `-e/--exec`'s own `--user`/`--group` support,
instead of a second copy needing to be bind-mounted for it (which wouldn't even
be possible — `-e/--exec` joins an *already-running* session's mount namespace,
it doesn't get to add bind mounts to it). `find_priv_drop_helper()` itself only
checks this binary's own host-side existence; it says nothing about whether a
given session actually has it bind-mounted (only true when that session's
`-r/--run` resolved a user in the first place).
- `user_spec.{h,cpp}``resolve_user_and_group()` resolves a user/group spec (each
a name or numeric id) against the *container's own* `/etc/passwd`/`/etc/group`
**content** (not the host's, and not a path — callers own reading it, since the
two current callers get that content two different ways: `run_container()`
reads it directly off the merged mount path, while `exec_in_session()` fetches
it over `nsenter`, since a running session's mount namespace isn't otherwise
reachable from this process — see below). `nullopt` content for either file
means "unreadable/absent"; a numeric user with no group still resolves fine
without it (defaults gid to the same numeric value as the uid) but a named one
doesn't. `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_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()` (`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
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). `run_process_foreground()` also takes
an optional `env` (list of key/value pairs): when set, the forked child
replaces its entire environment via `clearenv()`/`setenv()` (plain POSIX, not
the GNU-only `execvpe()` — the target platform includes musl) before `execvp()`,
instead of inheriting this process's own. `nullopt` (the default) leaves the
child's environment untouched. `run_bwrap()` is again the one caller that uses
this, via `build_sandbox_env()` (`bwrap.cpp`) — see there.
- `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 `:`). `session_log_file_path()`
is a sibling resolving to `$XDG_STATE_HOME/slocker-lite/logs/<container_name>-<pid>.log`
instead — same sanitization, same `$XDG_STATE_HOME`/`$HOME` fallback, just a
different subdirectory and a `.log` extension — used by `daemonize.{h,cpp}`
(see below) for `-D/--daemonize`'s log file. `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` (`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 `-`);
`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.
- `daemonize.{h,cpp}` — implements `-D/--daemonize`'s fork/detach mechanics.
`daemonize(container_name)` sets up a `pipe2(..., O_CLOEXEC)` pair (so it
never leaks into `bwrap`/the sandboxed command, same reasoning as the pid
file's own `O_CLOEXEC`) and `fork()`s. The **child** calls `setsid()`
deliberately here, not via re-adding bwrap's own `--new-session` (removed
earlier, see the `build_bwrap_args()` comment): `--new-session` only calls
`setsid()` for the deeply-nested sandboxed command *inside* bwrap's own
namespace setup, leaving the outer `bwrap`/`nsenter`/`slocker-lite` processes
still attached to the *original* session and still receiving its signals
(e.g. a `SIGHUP` when the controlling terminal closes) — not real
daemonization. Calling `setsid()` in our own forked child, *before* it execs
into `nsenter`/`bwrap`, detaches the entire chain at once, since `exec()`
never changes session membership — confirmed by direct testing
(`ps -o pid,sid,pgid,tty`): the daemon child becomes its own session leader
with no controlling tty, and `bwrap` (a later descendant) shares that same
session, also with no tty. The child also `sigaction()`s `SIGHUP` to
`SIG_IGN` (survives the later `exec()` into `nsenter`/`bwrap`, unlike a real
handler, which `exec()` resets to default — confirmed by sending `SIGHUP`
directly to a running daemonized `bwrap` pid and it staying alive), then
redirects stdin to `/dev/null` and stdout/stderr to a log file at
`session_log_file_path(container_name, getpid())` (`pid_file.h`) — named
after its *own* pid since the real session pid (`bwrap`'s) isn't known yet.
If the log directory/file can't be set up at all, that's a **hard** failure
here (`_exit(1)`), not best-effort — silently losing the very output
`--daemonize` was asked to capture would defeat the point of the flag. The
child reports `"LOG <path>\n"` over the pipe immediately (so the parent can
show a useful location even on failure) and returns `nullopt` to its caller
(`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
watcher/reaper process exists**. The **parent** blocks reading the pipe until
EOF, returning the accumulated `"LOG "`/`"PID "` lines as a `DaemonizeResult`
— the caller then prints it and exits immediately without running any
session logic itself. `report_daemon_started(container_name, pid)` (called
from `run_bwrap()`'s new `on_bwrap_pid_known` callback — see `bwrap.{h,cpp}`
below — the instant the real `bwrap` pid is known) renames the pid-named log
file to `<container_name>-<pid>.log`, re-reports the *updated* `"LOG "` line
(a real bug caught by testing: the parent's first `"LOG "` line names the
pre-rename, daemon-pid-named path — without a second one, the parent would
print a stale filename that doesn't match where the file actually ends up),
then `"PID <pid>\n"` and closes its own end of the pipe — must happen here,
explicitly, rather than waiting for the pipe to close naturally at the end of
the (potentially very long) daemon's lifetime, or the parent would block for
as long as the session runs instead of returning promptly. The pipe's write
fd and the current log path are tracked as private file-scope state in
`daemonize.cpp` (matching `process.cpp`'s own `g_foreground_child_pid`
pattern for "there's only ever one of these per process" runtime state),
since `report_daemon_started()` is called later, from a different function,
not threaded explicitly through every call in between.
- `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.
**Real bug found via testing on a real target device, not assumed**: that
file requires `CONFIG_CHECKPOINT_RESTORE`, which not every kernel enables —
confirmed absent (not just unreadable — the file doesn't exist at all) on a
real Android device, where `-e/--exec` then fell back to the outer `bwrap`
pid itself and failed outright (`nsenter: no namespace specified`, since
every namespace type either matched the outer process's own or couldn't be
read at all). Fixed by adding `find_child_by_scanning_proc()`, a portable
fallback used only when the children file is missing/empty: scans
`/proc/<n>/stat` for any process whose ppid field equals `pid` — the same
information `pstree` itself reads to build its tree, which is how the actual
sandboxed child was located and confirmed correct on the same device via a
manual `nsenter -t <child_pid> -a -- /bin/sh` before the fix was written.
Picks the lowest matching pid if more than one child exists, for a
deterministic result. 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.
`exec_in_session()` also takes optional `--user`/`--group` (mirroring `-r/--run`'s
own): given, they resolve against the session's own `/etc/passwd`/`/etc/group`
(fetched via `cat` run through the same `nsenter` join, since this process can't
otherwise see into that namespace, then handed to `resolve_user_and_group()`
`user_spec.h`, see below); if unset, defaults to whatever uid/gid the session's
own sandboxed command is *already* running as (read from `/proc/<ns_pid>/status`),
rather than root/the caller — fixing a real bug (reported after this project's
own `-e/--exec` and priv-drop features had both shipped separately): without this,
`-e/--exec` always ran as whatever the *host* invocation was, ignoring any
`--user`/`--group` the session itself was started with. Either way, the resolved
identity is applied by running `command` through the session's already
bind-mounted `slocker-lite-priv-drop` helper (`priv_drop::path`, `bwrap.h`) —
reused as-is, not bind-mounted again (`-e/--exec` can't add bind mounts to an
already-running session's namespace anyway). Skipped entirely when the resolved
uid *and* gid are both 0: a session that was never given a resolvable user at
`-r/--run` time never got the helper bind-mounted at all, and dropping to 0:0
would be a no-op regardless; a missing helper for a genuinely non-root
resolution instead surfaces as `nsenter`'s own "No such file or directory" once
it tries to exec `priv_drop::path`, diagnostic enough on its own. **Second real
bug, caught by direct testing on a rootless dev machine before this shipped**:
when the session's own `-r/--run` used `--unshare-user` (i.e. ran rootless —
see the root-vs-rootless paragraph below), "root inside the container" is
achieved purely through the kernel's own uid mapping for that namespace, not a
real privilege drop — so `/proc/<ns_pid>/status`'s uid/gid, read from *outside*
that namespace, shows the host-mapped id (e.g. `1000`), not the
container-relative one (`0`). Treating that as "needs a priv-drop to 1000" is
wrong two ways: the helper is typically never bind-mounted for a session with
no resolved `--user`, and even when it is, `setuid()` fails outright under the
single-entry uid map an unprivileged user namespace gets (confirmed directly:
`failed to drop privileges to 0:0: Operation not permitted`). Fixed by tracking
whether the `user` namespace type was actually one of the ones joined (it only
is when it differs from this process's own, i.e. exactly when `-r/--run` used
`--unshare-user`) and, when so, leaving the default identity unresolved (no
priv-drop) for that case — joining that same user namespace with
`--preserve-credentials` (already done regardless) already reproduces the
container's own view correctly via that same kernel mapping, with nothing
further needed. Verified end-to-end on this same rootless dev machine: a
daemonized `-r --run` busybox session with no declared user, `--exec`'d with no
`--user`, now correctly shows `uid=0(root)` (previously would have attempted,
and failed, a priv-drop to the host-mapped uid); an explicit `--exec --user 0`
against the same session correctly resolves to `0:0` and skips the priv-drop
step; `--exec --user portage` against it correctly resolves the name to its
real `250:250` via the fetched `/etc/passwd` and then fails clearly (helper not
bind-mounted, since the session itself had no declared user) rather than
silently running as the wrong identity.
- `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()`, `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`
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,
`initialize_volume_directory()` reconciles it against the image's own directory
at the given container path: if that image directory is non-empty, its contents
are copied in first; then, **whether or not there was content to copy**, the
host directory's own mode/ownership/timestamps (and xattrs/ACLs where
supported) are always set to match the image directory's own — **real bug
fixed by the user, not assumed**: an earlier version only ever copied when the
image directory was non-empty, so an image declaring an *empty* directory with
specific ownership/permissions (e.g. a data directory owned by a non-root
uid/gid) got a host directory with default `create_directories()` permissions
instead, and even the non-empty case never reconciled the directory's *own*
attributes (only each copied entry's). The existence check, content copy, and
attribute reconciliation all run as a single `sh -c` 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] --attributes-only -T` does
the attribute-reconciliation step; 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).
`-T`/`--no-target-directory` is required on that second `cp` — confirmed by
direct testing: without it, since the host directory already exists, plain
`cp SRC DST` copies `SRC` *into* `DST` as a nested `DST/basename(SRC)`
subdirectory instead of reconciling `DST`'s own attributes, which is exactly
the bug this fix closes. A nonzero `cp` exit is only ever a warning, never
fatal — often just an ownership-preservation shortfall when not running as
root. Verified end-to-end against `images/gitea.tar`'s real declared
`/etc/gitea`/`/var/lib/gitea` volumes under a real rootless mount: the
resulting host directories' mode/ownership matched the image's own declared
values in both cases, and no mounts/layers were left behind afterward.
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.
**Mutable global state and multi-container support:** an audit ahead of planned
docker-compose support (running multiple containers at once) found exactly three
pieces of mutable global/file-scope state in `src/`: `g_mount_program`
(`containers_storage.{h,cpp}`, the resolved `fuse-overlayfs` path — genuinely
process-wide, invariant across containers), `g_foreground_child_pid`
(`process.cpp`, plus `run_process_foreground()`'s process-wide `SIGINT`/`SIGTERM`
handler installation — tracks one foreground child at a time), and
`g_report_fd`/`g_log_path` (`daemonize.cpp`, one in-flight `-D/--daemonize`
handshake's report-pipe fd and log path). **Decision, confirmed by the user:**
multi-container/compose support will run each container's session in its own
forked OS process — the same model `-D/--daemonize` already uses — rather than
one process managing multiple containers concurrently without forking. Under
that model, "one OS process" and "one running container" stay the same thing
they already are today, so **none of these globals need to become per-container
state** — each forked child only ever tracks/signals one foreground child and
handles one daemonize handshake, exactly as today. This is a load-bearing
constraint for however the compose orchestrator ends up implemented: it must
fork (not thread, not run an in-process event loop over N containers) one child
per service, each child reusing `run_container()`'s existing single-container
code path unchanged.
## 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`, `-D/--daemonize`,
`--user`, `--group`, `--hostname`, `--env`, `--env-file`, `-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)`.
- Constants: no `k` Hungarian-notation prefix. `enum class` values are already qualified by
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. `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
internal linkage is unchanged. A `kXxx`-named identifier that turns out not to actually be
`const` (mutable global/static state) instead follows this codebase's existing `g_` prefix
convention (e.g. `containers_storage.cpp`'s `g_mount_program`, matching `process.cpp`'s
`g_foreground_child_pid` and `daemonize.cpp`'s `g_report_fd`/`g_log_path`).
## 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).