Files
slocker-lite/CLAUDE.md
T
ceamac 23f380e180 Track running -r/--run sessions with a locked PID file
Each bwrap session is now recorded under
$XDG_STATE_HOME/slocker-lite/run/<container-name>-<pid> (falling back
to $HOME/.local/state/...), holding an exclusive advisory flock() for
as long as it's running -- so any tool can tell a stale leftover file
apart from a live session by attempting the same non-blocking flock().
The file is removed once the run ends, on every exit path including a
forwarded Ctrl-C.

run_process_foreground() gained an optional on_start(pid) callback,
fired right after fork() succeeds -- the only point the real bwrap pid
is knowable, since exec() (including nsenter handing off to bwrap)
never changes it. run_bwrap() uses this to create/release the session
lock. The container name comes from read_image_ref(), promoted from a
list_oci_images()-only helper to public API in oci_image.h so
run_container() can reuse the same name/tag derivation for a single
image tar.

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

21 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()). inspect_image_command() implements -i/--inspect <image.tar>: prints every OciImageConfig field (user/group, exposed ports, env, volumes, default command) without mounting or running the image — extend it whenever OciImageConfig gains a new field (see oci_image.{h,cpp} below). run_container() unconditionally calls read_oci_image_config() and reuses the result for two independent defaults: the command to run (Entrypoint ++ Cmd) when none is given on the command line, and, when --user wasn't given, the sandboxed process's user/group (config.User, split into OciImageConfig::user/group) — an explicit --user/--group on the command line always takes precedence. create_volume_command() implements -v/--volume <name> <directory>; list_volumes_command() implements --list-volumes (same tab-alignment scheme as list_images_command(), reused as-is); delete_volume_command() implements both --delete-volume <name> (config entry only) and --delete-volume-full <name> (also std::filesystem::remove_all()s the host directory — errors out before touching the config if that fails, warns instead of failing if the directory was already gone) — see config_file.{h,cpp} below for what a "volume" means here (a distinct concept from OciImageConfig::volumes). -v/--volume is dual-purpose: used alone it's create_volume_command(); combined with -r/--run it instead requests a volume mount (repeatable) and is resolved by resolve_volume_mount() (see volume_mount.{h,cpp} below) instead. Since -v must be repeatable with -r but each occurrence still takes two space-separated tokens, main()'s getopt loop no longer lets 'v' set Mode itself: it accumulates (spec, path) pairs into volume_specs (consuming the second token manually, with a guard against swallowing the next flag if there isn't one), and only after the loop decides whether that means one standalone Mode::kVolume call or, together with -r, threads volume_specs through to run_container(). run_container() resolves each spec (erroring out, ok = false, same as a failed --user resolution — bwrap is skipped but unmount/cleanup still runs) into a ResolvedVolumeMount, rejecting a duplicate or non-absolute container path first, and passes the resolved list to run_bwrap(). --hostname <name> (long-option only, no short form) is likewise threaded straight through run_container() into run_bwrap()/build_bwrap_args() (bwrap.{h,cpp}) — see there for how/when it actually takes effect. run_container() also derives a container_name for the session-tracking pid file (see pid_file.{h,cpp} below): read_image_ref() (oci_image.{h,cpp}) applied to the single image tar being run, formatted as name:tag, falling back to the tar's own filename stem if read_image_ref() can't determine one — passed through to run_bwrap() alongside everything else.
  • oci_image.{h,cpp} — validates/parses the OCI Image Layout tar (libarchive + nlohmann_json) and extracts layer blobs. list_oci_images() scans a directory (non-recursively) for *.tar/*.tar.* files and, for each valid OCI archive, derives an image name/tag via read_image_ref() from its index.json manifest annotations (io.containerd.image.name preferred, else org.opencontainers.image.ref.name), falling back to the archive's filename and "latest" respectively. read_image_ref() is public (not just an internal helper of list_oci_images()) precisely so run_container() (main.cpp) can reuse the exact same logic to name a single image tar's session pid file (see pid_file.{h,cpp} below) instead of duplicating it. read_oci_image_config() reads the image config blob referenced by the manifest and extracts User (split on : into OciImageConfig::user/group), ExposedPorts, Env, Volumes, and the effective default command (Entrypoint ++ Cmd). user/group and the default command are consumed by -r/--run, and every field is displayed by -i/--inspect (see main.cpp above) — ExposedPorts/Env/Volumes are otherwise still just captured for when networking/volumes are implemented.
  • containers_storage.{h,cpp} — wraps the containers-storage CLI (import-layer, mount, unmount, layer --json, delete-layer), forcing fuse-overlayfs as the overlay mount_program. cleanup_layer_chain() walks a layer's parent chain (children before parents) deleting each one.
  • bwrap.{h,cpp}detect_bwrap_unshare_args() probes the kernel (via a forked unshare(2) per namespace type) for which --unshare-xxx flags bwrap can actually use; build_bwrap_args()/run_bwrap() assemble and run the sandboxed command. build_bwrap_args() also takes a std::vector<ResolvedVolumeMount> (see volume_mount.h below) and appends one writable --bind <host_directory> <container_path> per entry. wrap_for_root_namespace() is run_bwrap()'s own nsenter-wrapping logic pulled out into a reusable, exported function — it's also what volume_mount.cpp's copy-into-an-empty-volume step uses to reach the image's content when running rootless (see below); run_bwrap() itself now just calls it once on the assembled bwrap argv. build_bwrap_args() deliberately drops --unshare-net from what's actually passed to bwrap even when the kernel supports it — without any network setup (e.g. slirp4netns), unsharing it just leaves the sandbox with no network at all. Re-add once network isolation is implemented; detect_bwrap_unshare_args() itself still probes/reports it (e.g. via -t/--test), since that's kernel capability, not policy. build_bwrap_args()/run_bwrap() also take an optional hostname (from --hostname, long-option only): passed through as bwrap's own --hostname only when --unshare-uts is actually among the flags bwrap is being given (bwrap itself refuses --hostname without it) — otherwise logs a warning and leaves the sandbox's hostname alone, since a stock Android kernel in degraded mode may not support a UTS namespace at all. Never requests --unshare-user when running as root: root doesn't need a fresh user namespace for privilege, and bwrap's own single-mapping uid/gid setup for one triggers the kernel's unprivileged-userns setgroups() restriction, which showed up as every other supplementary group collapsing to the overflow gid ("nobody") in id, and su inside the sandbox failing with "can't set groups: Operation not permitted". Because of that, bwrap's own --uid/--gid (which require --unshare-user) aren't usable when running as root either — --user/--group work around this: when set, build_bwrap_args()/run_bwrap() bind-mount the separate slocker-lite-priv-drop helper (see below) into the sandbox at a fixed hidden path and route the real command through it as <uid>:<gid> -- <command...>. This only actually works without a user namespace (i.e. running as root) — under --unshare-user, the sandbox's uid map has only one valid entry, so the helper's own setuid() fails cleanly there instead of silently doing nothing. run_bwrap() fails fast (returns -1) if the helper can't be found next to this binary when --user was requested, rather than silently running the command as root. run_bwrap() also takes a container_name and tracks the running session with it: it passes a lambda as run_process_foreground()'s new on_start callback (see process.{h,cpp} below) that calls create_session_lock(container_name, pid) (pid_file.{h,cpp}, see below) the instant the real bwrap pid is known, then calls release_session_lock() once run_process_foreground() returns (covering every exit path — normal, nonzero, or a forwarded-signal exit — since that call always blocks until the child has actually exited).
  • priv_drop_helper.cpp → the separate slocker-lite-priv-drop binary (its own executable() target in meson.build, built with -static). Deliberately has zero dependencies on the rest of this project (no fmt/spdlog/etc.) and is fully statically linked: it gets bind-mounted into the container image's own filesystem, which won't have slocker-lite's own shared library dependencies — a dynamically linked binary bind-mounted that way fails outright ("error while loading shared libraries"), which is exactly what happened before this was split out (the original approach bind-mounted slocker-lite's own — dynamically linked — binary via /proc/self/exe and reexeced it; kept only as a lesson, not as working code). Usage: slocker-lite-priv-drop <uid>:<gid> -- <command> [args...]; does setgroups(0,…)setgid()setuid()execvp(), in that order (dropping the group needs CAP_SETGID, which is lost once setuid() drops root). find_priv_drop_helper() (src/bwrap.cpp) locates it next to slocker-lite's own binary (via /proc/self/exe's directory), which holds both when run straight from buildDir/ and after a real meson install.
  • user_spec.{h,cpp}resolve_user_and_group() resolves a user/group spec (each a name or numeric id) against the mounted image's own /etc/passwd//etc/group (not the host's), since names like git only mean anything inside that image's own user database. A numeric user with no group and no matching /etc/passwd entry defaults gid to the same numeric value as the uid. ResolvedUser (bwrap.h) also carries home, looked up by the final resolved uid's /etc/passwd entry (field 5) regardless of whether user was given as a name or a number; falls back to "/root" for uid 0 or "/" otherwise when there's no matching row. build_bwrap_args() (bwrap.cpp) sets the sandboxed process's HOME from this — "/root" only when no user override applies at all (no --user, no image-declared config.User). run_container() (main.cpp) calls resolve_user_and_group() with either the explicit --user/--group flags, or, when --user wasn't given, the image's own declared config.User (OciImageConfig::user/group) — so a container defaults to running as whatever user the image itself declares, not root, unless the image declares none.
  • process.{h,cpp} — argv-based subprocess helpers (fork/execvp, no shell): run_process() captures stdout (used for containers-storage calls), run_process_foreground() inherits all of stdio (used for the interactive bwrap run). Also find_in_path(), a shared $PATH lookup. run_process_foreground() installs a SIGINT/SIGTERM handler around its waitpid() that forwards the signal to the running child and keeps waiting instead of letting the default disposition kill slocker-lite itself — without this, Ctrl-C (or kill) during -r's bwrap run would skip run_container()'s unmount/cleanup entirely, leaving the layer imported and/or mounted. run_process_foreground() also takes an optional on_start callback, invoked with the child's real pid right after fork() succeeds (before the signal handlers go up and it blocks in waitpid()) — the only point where that pid is knowable, and still accurate even when argv itself execs into something else first (e.g. nsenter handing off to the final command via its own in-place execvp() — a pid never changes across exec()). run_bwrap() (bwrap.cpp) is the one caller that uses it, for session pid-file tracking (see pid_file.{h,cpp} below).
  • pid_file.{h,cpp} — tracks one running -r/--run session (a live bwrap process) as a locked pid file, so an outside process (or a later slocker-lite invocation) can tell whether it's still running. session_pid_file_path() resolves $XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid> (falling back to $HOME/.local/state/... when XDG_STATE_HOME is unset/empty — same resolution pattern as config_file_path() below, for state instead of config), sanitizing container_name first (anything outside [A-Za-z0-9._-]_, since an image name/tag can contain / or :). create_session_lock() creates the file (O_CREAT|O_WRONLY|O_TRUNC|O_CLOEXEC, mode 0644 — O_CLOEXEC matters: this fd must never leak into the sandboxed command's own fd table), writes the pid as text, and takes an exclusive, non-blocking flock() on it — held only by that fd, so its lifetime tracks slocker-lite's own process lifetime (released automatically on any exit, including a crash), which lines up with bwrap itself being invoked with --die-with-parent. Any external tool can check liveness the same way: attempt the same exclusive non-blocking flock() on the file — success means nothing holds it anymore (stale, safe to remove), EWOULDBLOCK means a live process still does. release_session_lock() closes the fd (releasing the flock immediately) and removes the file. Every failure path here (can't create the directory/file, can't lock, can't remove) is a spdlog::warn, never fatal — session tracking is best-effort and must never block or fail -r/--run itself.
  • config_file.{h,cpp}load_config_file() reads and parses (via libyaml's document API, <yaml.h>) the global and volumes sections of the local YAML config file located by config_file_path() ($XDG_CONFIG_HOME/slocker-lite/config.yaml, falling back to $HOME/.config/slocker-lite/config.yaml). global.log-level is the only supported global key — other long options are one-shot flags, not settings, so they don't belong in a persistent config file. A missing file returns a default-constructed (empty) AppConfig, not an error; unknown sections/keys (and malformed individual volume entries) are ignored for forward-compatibility; malformed YAML syntax is a hard error. main() applies config->log_level (via the existing apply_log_level()) right after spdlog::cfg::load_env_levels() and before parsing CLI options, so an explicit --log-level on the command line always overwrites it afterward — same precedence pattern already used for SPDLOG_LEVEL. write_config_file() writes the whole file back out (via libyaml's document-building/emitter API, symmetric to the read side) — used by -v/--volume (create_volume_command(), main.cpp) to persist a new VolumeEntry {name, directory} into the volumes section, preserving global untouched. VolumeEntry/the volumes section is a distinct concept from OciImageConfig::volumes: this is a user-defined name -> host directory mapping created via -v/--volume, not an image's own declared mount points (still unconsumed, see oci_image.{h,cpp} above). AppConfig/config.volumes is looked up by name in resolve_volume_mount() (volume_mount.{h,cpp}, see below), which is how -r/--run's own -v usage finds a named volume's host directory.
  • volume_mount.{h,cpp}is_valid_volume_name() (no /, checked by both create_volume_command() and to tell a -v spec's name/path apart) and resolve_volume_mount(), called once per -v occurrence from run_container() when running with -r. A spec with no / is looked up in config.volumes by name (error if unknown); one with / is treated as a host directory path and create_directories()'d if missing. If the resulting host directory is empty and the image already has non-empty content at the given container path, that content is copied in first. Both the existence check and the copy run as a single sh -c '[ -d ... ] && cp -a ...' invocation wrapped through wrap_for_root_namespace() (bwrap.h) — not a plain std::filesystem check — because a rootless containers-storage mount's content isn't visible to this process at all without nsenter, the same constraint run_bwrap() itself works around (see the root-vs-rootless paragraph below). cp -a --preserve=mode,ownership,timestamps,links[,xattr] does the copy; whether ,xattr is included is decided by a direct setxattr()/removexattr() probe on the host directory (no new library dependency — Linux POSIX ACLs are themselves stored as xattrs, so this one probe stands in for both, logging a single spdlog::warn if unsupported). A nonzero cp exit is only ever a warning, never fatal — often just an ownership-preservation shortfall when not running as root.

Errors are logged via spdlog::error; every external command is also traced at debug level in run_process()/run_process_foreground() (src/process.cpp) — visible via SPDLOG_LEVEL=debug, since spdlog's default level is info — and a failed external command additionally logs a spdlog::warn, which is visible by default (no env var needed). The final "mounted image at: ..." success line is direct stdout program output, not a log.

Because containers-storage mount runs rootless, it reexecs itself into a private user+mount namespace to gain the privilege it needs for the overlay mount — which leaves the result invisible to a plain shell or child process outside that namespace. Confirmed containers-storage unshare does not rejoin an already-running mount's namespace; only nsenter targeting the live fuse-overlayfs daemon's PID does. -r/--run handles this automatically by locating that PID and running bwrap via nsenter into its namespaces (wrap_for_root_namespace(), src/bwrap.h) — reused as-is by volume_mount.cpp's copy-into-an-empty-volume step, since that also needs to read image content that's otherwise invisible outside the same namespace. Running as root sidesteps all of this: no privilege reexec is needed, so the mount is already directly visible in the current namespace, and nsenter --user=... into it then fails ("reassociate to namespace 'ns/user' failed: Invalid argument") since the caller is already in that same user namespace. -r/--run detects geteuid() == 0 and skips nsenter automatically in that case; -n/--no-nsenter forces it off manually for any other situation where the mount turns out to already be directly visible.

Build & test commands

Build directory is buildDir/ (already configured).

  • Configure (only needed if buildDir/ is missing or deleted): meson setup buildDir
  • Build: meson compile -C buildDir (or ninja -C buildDir) — also builds buildDir/slocker-lite-priv-drop, the statically-linked helper -r --user needs (see priv_drop_helper.cpp in "Project state")
  • Run the executable: ./buildDir/slocker-lite -m <image.tar> (see --help for the full flag list: -m/--mount, -r/--run, -u/--umount, -c/--cleanup, -l/--list-images, -i/--inspect, -n/--no-nsenter, --user, --group, --hostname, -v/--volume, --list-volumes, --delete-volume, --delete-volume-full, -t/--test, --log-level, -h/--help, -V/--version)
  • Run tests: meson test -C buildDir

Code style

  • Null-pointer checks: prefer if (!ptr) / if (ptr) over if (ptr == nullptr) / if (ptr != nullptr).

Licensing

  • Every .c/.cpp/.h file under src/ must start with the GPLv2-or-later copyright header (see any existing file under src/ for the exact text).
  • After adding a new source file under src/, run ./add-license.sh from the repo root to prepend the header (it reads copyright-header and inserts it via sed, skipping files that already have it, so it's safe to re-run at any time).

Build configuration notes

  • meson.build sets warning_level=3 and cpp_std=c++20 — keep new code warning-clean under -Wall -Wextra -Wpedantic-equivalent settings.
  • The single Meson test() target runs slocker-lite against a fixture OCI image tar generated at build time by tests/gen_fixture.py (a custom_target) and checks its exit code (no test framework is wired in yet).