Files
slocker-lite/CLAUDE.md
T
ceamac 00cb04ff41 Document global-state audit and fork-per-container decision
Ahead of docker-compose multi-container support, audited the three pieces
of mutable global state (g_mount_program, g_foreground_child_pid,
g_report_fd/g_log_path) for concurrency safety. Decided each container's
session will run in its own forked OS process, the same model
-D/--daemonize already uses, rather than one process managing multiple
containers without forking. Under that model none of the globals need to
change, since "one OS process" already equals "one running container" --
recorded as a load-bearing constraint for the compose orchestrator.

No code changes; documentation only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
2026-08-24 13:17:40 +00:00

39 KiB

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. -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). 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. --env VAR=VALUE/--env-file <file> (both long-option only, both repeatable) accumulate into a single ordered std::vector<EnvSpec>--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.
  • 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 (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() (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). 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() (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_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() 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 (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 SessionInfos 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(), main.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 — 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.

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. main.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).