Files
slocker-lite/CLAUDE.md
T
ceamac c70abb56a9 Make bwrap's --unshare-xxx namespaces configurable via global.unshare-*
Adds six global.unshare-{user,ipc,pid,net,uts,cgroup} config-file keys
(1/on/yes/true or 0/off/no/false, case-insensitive, default on) that
gate whether -r/--run requests each bwrap --unshare-xxx flag when the
kernel also supports it. Replaces the previous hardcoded skip of
--unshare-net, which is now policy-driven like the other five types
and defaults to enabled -- preparation for real network namespace
isolation (slirp4netns) next, on this same branch.
2026-08-30 09:15:39 +00:00

62 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 — 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) -x/--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). --kill <pid> (ParsedArgs::kill_pid) shares that same positive-integer parsing/validation via a small extracted parse_pid_arg() helper (.cpp-local) rather than duplicating the strtol dance a second time — unlike -x/--exec, it takes no trailing command, so it's simply not added to the leftover-args exemption list (Mode::run/Mode::exec only). 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()/run_bwrap() also take a NamespaceConfig (bwrap.h) — one plain bool field per namespace_probes entry (user/ipc/pid/net/uts/ cgroup, default true), resolved by run_container() (commands.cpp) from AppConfig's six global.unshare-* keys (config_file.h, see above) once, up front — bwrap.{h,cpp} itself never touches AppConfig/YAML, only this already-resolved struct. For each flag detect_bwrap_unshare_args() finds the kernel supports, build_bwrap_args() additionally requires the matching NamespaceConfig field to be true (looked up via a .cpp-local namespace_policy_enabled() if-chain over namespace_probes' names) before actually passing it to bwrap — kernel support and policy are separate gates, both must allow a type. This replaced an earlier hardcoded special case that always dropped --unshare-net regardless of policy or kernel support (without any network setup, e.g. slirp4netns, unsharing it just left the sandbox with no network at all) — net now goes through the exact same policy path as every other type, defaulting to enabled like the rest. This is a deliberate, user-acknowledged transitional behavior change: as of this, a plain -r/--run with no config file override gets a real network namespace and thus no network access at all, until slirp4netns integration (the next task on this same branch) actually sets one up; global.unshare-net: off restores the prior no-isolation behavior in the meantime. detect_bwrap_unshare_args() itself is untouched by any of this — still an unfiltered kernel-capability probe, so -t/--test's diagnostic report continues to reflect raw kernel support, 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. That same on_start lambda also calls create_session_cgroup() (session_cgroup.h, see below), right alongside create_session_lock(), so --kill can later find every process the session ever starts via its dedicated cgroup; remove_session_cgroup() is called from the same post-run_process_foreground() spot release_session_lock() already is.

  • 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 -x/--exec's own --user/--group support, instead of a second copy needing to be bind-mounted for it (which wouldn't even be possible — -x/--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. sanitize_for_filename() (anything outside [A-Za-z0-9._-]_, falling back to "container" if that leaves nothing) is exported here (not just .cpp-local) specifically so session_cgroup.{h,cpp} (see below) can reuse the exact same <name>-<pid> naming rule for its own per-session cgroup directory without drifting from this file's own. 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 SessionInfos with running=false); still-locked (running) files are left untouched and not reported.

  • session_cgroup.{h,cpp} — gives --kill (kill_session.{h,cpp}, see below) a reliable way to find every process a session ever started, however deeply forked/daemonized/reparented, by putting it in a dedicated cgroup v2 group from the moment it starts. cgroup_v2_available() checks for /sys/fs/cgroup/cgroup.controllers (the same signal systemd's own unified-hierarchy detection uses) — only cgroup v2 is supported; v1 (which splits per-controller into separate hierarchies with no unified cgroup.procs at the top) is deliberately out of scope, since this project's real target (Android) has used the unified v2 hierarchy by default since Android 12. session_cgroup_path() is deterministic — /sys/fs/cgroup/slocker-lite/<name>-<pid>/, reusing pid_file.h's own sanitize_for_filename() — so no separate lookup state is needed anywhere. create_session_cgroup() is called from run_bwrap()'s on_start callback (bwrap.cpp, see below), the same spot create_session_lock() already fires from: create_directories()'s the leaf directory (also creating the slocker-lite/ parent the first time — a plain grouping cgroup, no resource controllers are ever enabled on it via cgroup.subtree_control, so the "no internal processes" restriction that comes with actually delegating controllers never applies here) and writes the bwrap pid into its cgroup.procs. From that point on, every process bwrap (or anything it execs into) forks inherits this cgroup automatically, permanently — including anything that later daemonizes/double-forks and gets reparented, unlike pid-namespace child membership (only the processes clone() itself creates) or process-group membership (many daemonizing services explicitly setpgid()/setsid() away from it on purpose). Best-effort, mirroring create_session_lock(): returns nullopt (logging a warning, never fatal) if cgroup v2 isn't available, or the directory can't be created/written (no delegated subtree when running rootless, or an SELinux policy blocking cgroupfs writes even for a root-euid process, are both real, confirmed-by- testing causes on the two environments this project actually runs on). remove_session_cgroup() (called from the same post-run_process_foreground() spot release_session_lock() already is) only succeeds once the cgroup is empty — a straggler process still alive at normal exit (e.g. a daemonized process that outlived the session's own main command, a pre-existing exposure independent of this feature) leaves it in place with a warning, not a fatal error. session_cgroup_pids() reads cgroup.procs — this is the actual answer to "gather every process running inside the container": unlike anything derived from /proc parent-pid chains or pid namespaces, cgroup membership reliably includes every process the session ever started. session_cgroup_supports_kill()/kill_session_cgroup() wrap the cgroup.kill knob (Linux 5.14+): writing "1" to it atomically SIGKILLs every process currently in the cgroup in one step.

  • sandbox_process.{h,cpp} — process-tree/namespace-resolution utilities shared by exec_session.{h,cpp} and kill_session.{h,cpp} (see both below); pulled into their own file (rather than staying private to exec_session.cpp, where resolve_namespace_pid() originally lived) once --kill needed the exact same "find the real sandboxed child" logic, to avoid a second, drifting copy. resolve_namespace_pid() is unchanged from its original exec_session.cpp form (see that entry for the full reasoning: bwrap's own outer/tracked pid never actually enters the pid/uts/ipc/cgroup namespaces it creates for its clone()'d child, only that child does). Two new utilities added alongside it for kill_session(): pid_namespace_isolated(outer_pid, ns_pid) compares /proc/<outer_pid>/ns/pid and /proc/<ns_pid>/ns/pid's own readlink() targets directly — true only when bwrap's clone() actually created a separate pid namespace for its child (--unshare-pid was requested and the kernel supported it), the precondition for the kernel's own guarantee that killing a pid namespace's pid 1 forcibly tears down every remaining process in it. collect_descendant_pids(root) generalizes resolve_namespace_pid()'s own /proc/<n>/stat ppid-scanning fallback to collect a whole transitive tree (root included) instead of just one child, sharing the actual stat-parsing loop between both via a private build_ppid_map() (one /proc pass, used by both the single-child lookup and the full-tree collection). Reliable specifically when root is a genuinely isolated pid namespace's own pid 1: anything that reparents within it (e.g. a daemonizing service) is guaranteed by the kernel to land back on root itself, unlike on a kernel without pid namespace support, where it escapes to the host's real pid 1 instead (see kill_session.{h,cpp} below for exactly this scenario, confirmed on a real target device).

  • 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 -x/--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() (sandbox_process.{h,cpp} — moved out of this file once --kill needed the exact same logic, see that entry) finds that real inner process so this can join its namespaces instead. 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 -x/--exec and priv-drop features had both shipped separately): without this, -x/--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 (-x/--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.

  • kill_session.{h,cpp} — implements --kill <pid>, stopping a tracked, running -r/--run session and everything it started. kill_session() validates pid the same way exec_in_session() does (via list_sessions(), pid_file.h). Real bug reported by the user against their own actual target device, confirmed via a captured session log: a plain kill <tracked_bwrap_pid> doesn't kill everything a container started — their /init script php-fpm --daemonizes (double-forks, detaches) then exec caddy ...s (replaces itself); after killing the tracked pid, both caddy and the php-fpm master+workers kept running as orphans. Root cause: on that device, bwrap's --unshare-pid isn't actually in effect at all — detect_bwrap_unshare_args() (bwrap.cpp) only requests --unshare-xxx flags the kernel actually supports, and that kernel doesn't support pid namespaces (independently confirmed elsewhere this session, see exec_session.{h,cpp}'s own CONFIG_CHECKPOINT_RESTORE bug above) — so php-fpm --daemonize reparents to the host's own pid 1, completely disconnected from the sandboxed session; the classic "kill a pid namespace's pid 1, the kernel guarantees the whole namespace collapses" trick simply doesn't apply there. Per the user's own explicit request (they want to choose the mechanism per host capability, and may need an even more basic one later for some hypothetical older device), kill_session() picks between three independently-named strategies, selected dynamically per session (not a single cached host-wide capability flag, since e.g. cgroup creation can fail for session-specific reasons like permissions even on a host that generally supports cgroups) — each runs its own complete SIGTERM → wait-up-to-grace_period_seconds (10s default, no CLI flag) → forced-SIGKILL escalation internally, with no cross-strategy fallback-after-failure chaining:

    1. kill_via_cgroup() — preferred whenever the session has a non-empty dedicated cgroup (session_cgroup_pids(), session_cgroup.h): SIGTERM to every pid currently in it, and, if forcing is needed, either the atomic cgroup.kill knob or a fresh re-read-and-SIGKILL sweep (fresh, not the original snapshot, since a process could have forked a new child after the graceful sweep but before dying). The only mechanism that reliably reaches every process regardless of pid namespace support. Critical correctness point, caught during design review before this shipped: the "is it stopped yet" poll must gate on the cgroup being empty, not list_sessions()'s running flag — that flag only reflects the pid file's flock, released the moment the tracked outer bwrap pid exits, and the SIGTERM sweep necessarily hits bwrap itself too (it's a cgroup member) — bwrap dies and gets reaped in well under a second, long before slower descendants (caddy shutting down gracefully, php-fpm finishing in-flight requests) actually exit. Gating on the pid file instead would make the poll resolve "done" almost immediately, the forced-kill step would never run, and the original bug would reproduce with unused machinery around it.
    2. kill_via_pid_namespace() — used when no cgroup exists for the session, but resolve_namespace_pid()/pid_namespace_isolated() (sandbox_process.h) confirm --unshare-pid was genuinely in effect for it. SIGTERMs collect_descendant_pids(ns_pid) (reliable here specifically because reparenting within a genuinely isolated pid namespace always lands back on that namespace's own pid 1); if forcing is needed, a single SIGKILL to ns_pid alone is guaranteed complete by the kernel itself, independent of whatever the graceful sweep missed. "Stopped" is simply kill(ns_pid, 0) failing with ESRCH. Verified end-to-end on this project's rootless dev machine (which does support pid namespaces, unlike the user's real target device): a daemonized busybox session running sh -c 'sleep 300 & exec sleep 300' (mirroring the daemonize-then-exec shape of the original bug) was fully cleaned up by --kill, including the backgrounded child, with no leftover processes, mounts, or layers; a second run using sh -c 'trap "" TERM; sleep 300' (ignoring SIGTERM entirely) confirmed the forced-SIGKILL escalation path too, taking the full 10s grace period before the pid namespace's own collapse-on-kill guarantee cleaned it up regardless.
    3. kill_via_tracked_pid() — fallback when neither of the above applies: signals the tracked bwrap pid directly, SIGTERM then SIGKILL, polling list_sessions() for "stopped" since that's the only signal available without a cgroup or an isolated pid namespace to check directly. Exactly today's manual-kill behavior — least complete, but always available, and strictly no worse than before this feature existed. This is the path the user's own real target device actually takes today (no cgroup delegation confirmed working there yet; no pid namespace support at all) — a future, even more basic strategy (for some hypothetical still-more-limited device) would slot in here the same way, per the user's own explicit request to keep this extensible.

    poll_until()/sleep_ms() (.cpp-local) use nanosleep() in an EINTR-retry loop — matching this project's existing direct-POSIX style (process.cpp already retries waitpid() the same way) — rather than <thread>/<chrono> (unused anywhere else in this project).

  • 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). Supported global keys: log-level, and six unshare-<type> keys (unshare-user/unshare-ipc/ unshare-pid/unshare-net/unshare-uts/unshare-cgroup, one per bwrap.cpp's own namespace_probes entry) controlling whether -r/--run requests each of bwrap's --unshare-xxx flags — every other long option is a one-shot flag, not a setting, so it doesn't belong in a persistent config file. Each unshare-* key accepts "1"/"on"/"yes"/"true" (enabled) or "0"/"off"/"no"/"false" (disabled), case-insensitively (.cpp-local parse_bool_flag()); an unset key defaults to enabled, and an unrecognized value logs a spdlog::warn and is treated as unset (default enabled) rather than failing the whole config load — consistent with this file's existing forward-compatible/ignore-malformed-entries policy (only malformed YAML syntax is a hard error). A missing file returns a default-constructed (empty) AppConfig, not an error; unknown sections/keys (and malformed individual volume entries) are likewise ignored for forward-compatibility. 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 (including any set unshare-* keys, re-serialized as canonical "true"/"false") 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. run_container() (commands.cpp) resolves the six unshare-* fields (each value_or(true)) into a NamespaceConfig (bwrap.h, see below) once, up front, and passes it to run_bwrap()bwrap.{h,cpp} itself has no dependency on this file or on YAML parsing at all, only on the already-resolved, defaults-applied struct.

  • 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, -x/--exec, --kill, -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).