# 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 `: 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 `; `list_volumes_command()` implements `--list-volumes` (same tab-alignment scheme as `list_images_command()`, reused as-is); `delete_volume_command()` implements both `--delete-volume ` (config entry only) and `--delete-volume-full ` (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 ` (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. - `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`, 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` (see `volume_mount.h` below) and appends one writable `--bind ` 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 `: -- `. 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 : -- [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. - `config_file.{h,cpp}` — `load_config_file()` reads and parses (via libyaml's document API, ``) 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 ` (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).