Files
slocker-lite/CLAUDE.md
T
ceamac be5a0b8202 Add a local YAML config file for persistent settings
Reads $XDG_CONFIG_HOME/slocker-lite/config.yaml (falling back to
$HOME/.config/slocker-lite/config.yaml), organized into sections. Only
the "global" section's log-level is supported for now -- other options
are one-shot flags, not standing preferences. An explicit --log-level
on the command line always overrides the config file, the same way
SPDLOG_LEVEL already does.

Uses libyaml directly (yaml_dep was already declared in meson.build but
unused). A missing config file isn't an error; unknown sections/keys
are ignored for forward-compatibility; malformed YAML syntax is a hard
error.

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

11 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()). 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.
  • 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 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_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 (see main.cpp above) — ExposedPorts/Env/Volumes are 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() 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. 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.
  • 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. run_container() (main.cpp) calls this 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.
  • config_file.{h,cpp}load_config_file() reads and parses (via libyaml's document API, <yaml.h>) the global section 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). Only global.log-level is supported today — 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 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.

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. 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, -n/--no-nsenter, --user, --group, -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).