Document the new Catch2 test suite

README.md: new "Testing" section -- the 4 category/tag-expression table,
what meson test covers vs. what stays manual, and tests/setup-tests.py.

CLAUDE.md: rewrote the self_test.{h,cpp} entry to describe its new role
(pure Catch2 Session::run() plumbing, ENABLE_TESTS-guarded) instead of the
hand-rolled tests it used to contain directly, and added a full
per-file breakdown of the new tests/unit, tests/integration, and
tests/support infrastructure -- including the real bugs found building it
(the two parse_args()/getopt_long state-reset bugs, the ScratchXdgDirs
mixed-iterator UB, the Catch2-inherited-SIGTERM-handler artifact, the
missing /sys mount and spdlog-writes-to-stdout findings), all in the same
narrative depth this file already uses throughout. Also updated the
"Build & test commands" flag list and meson test description.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-09-04 09:58:35 +00:00
parent 865862762d
commit b602de8e7b
2 changed files with 184 additions and 60 deletions
+151 -60
View File
@@ -301,64 +301,151 @@ Source layout (all under `src/`):
right after the `stop_tap_relay()` loop) for `--clean-processes`'s own right after the `stop_tap_relay()` loop) for `--clean-processes`'s own
crash-orphan sweep (`network_tap_relay.h`'s `clean_stale_tap_relays()`, crash-orphan sweep (`network_tap_relay.h`'s `clean_stale_tap_relays()`,
see below). see below).
- `self_test.{h,cpp}``run_self_tests()` implements `-t/--test`, this - `self_test.{h,cpp}``run_self_tests(args)` implements `-t/--test`
project's own built-in self-test mode (distinct from the Meson-driven (`args` is `ParsedArgs::test_args`, `cli_args.h` — everything on the
fixture smoke test under `tests/`, described in "Build & test commands" command line after `-t`, captured the same trailing-argv way `-r`/`-x`
below; previously reported `detect_bwrap_unshare_args()`'s output — capture their own command). Purely plumbing: builds a synthetic argv
`bwrap.{h,cpp}` — unplugged since that's kernel-capability diagnostics, not (`{"slocker-lite -t"} + args`) and hands it straight to Catch2's
a test). Currently exercises `persistent_netns.{h,cpp}`'s (see below) `Catch::Session().run(argc, argv)` — no test logic of its own lives here
create/verify/remove cycle: skipped with a message (not a failure) when not at all, that's all under `tests/` (see below); this file is `#if
root, since `create_persistent_netns()` requires it for the bind mount. ENABLE_TESTS`-guarded (`config.h`, from Meson's `enable_tests` option,
Confirms the namespace is missing before creation, exists right after default on — the same macro that already gated whether `catch2_dep` gets
(checked from this process, *after* the forked child that actually did the linked at all) so a `-Denable_tests=false` build prints a clear "not
`unshare()`/bind-mount has already exited — the actual claim being tested: compiled into this build" message and returns nonzero instead of
the namespace outlives its creating process), then gone again after failing to link. `-t`'s own leftover-args capture requires a literal
removal. Also exercises `network_tap_relay.{h,cpp}`'s (see below) `--` before any Catch2 option that looks like one of slocker-lite's own
create/attach/teardown cycle, same root-only skip: a throwaway bridge (`-r/--reporter` collides with `-r/--run`, `-c/--section` with
(`ip link add ... type bridge` inside a throwaway persistent namespace `-c/--cleanup`) — a bare tag expression like `-t -- "[unit]"` needs it
of its own — `create_tap_relay()` now always enters a network's too by convention, though `getopt_long`'s own permutation happens to let
persistent namespace first, both kinds, see `network_bridge.{h,cpp}`'s a `-t "[unit]"` without `--` work anyway, since `"[unit]"` doesn't start
own "Resolved: `extern` had no connectivity" entry, so this test needs with `-`. Distinct from the Meson-driven fixture smoke test under
one too even though `network_bridge.h`'s own bridge *provisioning* logic `tests/` (`tests/gen_fixture.py`/`tests/run_test.py`, described in
still isn't otherwise exercised here — a tap device only cares that "Build & test commands" below), which stays a separate, always-on,
*some* bridge exists to attach to) stands in for a real network's bridge, Python-driven mount/unmount/cleanup check.
and a throwaway network namespace (a forked
child that `unshare(CLONE_NEWNET)`s then blocks in `pause()` until **Test organization under `tests/`** (all in-process — every category
signaled) stands in for a real `-r/--run` session's isolated one. **Real calls this project's own already-header-exposed functions directly, no
race caught by testing, not assumed**: `fork()` returning to the parent subprocess-spawning, no refactoring of `commands.cpp`'s file-local
doesn't mean the child has actually reached its own `unshare(CLONE_NEWNET)` functions needed; see `README.md`'s own "Testing" section for the
call yet — the same race `network_join.cpp`'s own user-facing category table/tag-expression cheat sheet):
`wait_for_isolated_net_namespace()` already guards against for a real - `tests/unit/*.cpp` (`[unit]`) — one file per source area
session; an earlier version of this test used the child's pid immediately (`test_port_forward.cpp`, `test_env_spec.cpp`, `test_network_subnet.cpp`,
and the container-side tap device silently ended up created in the *host's* `test_cli_args.cpp`) exercising pure/isolated functions with no side
namespace instead (confirmed: it wasn't visible via `nsenter` into the effects: `parse_port_forward_spec()`, `resolve_env_specs()`,
child's namespace at all). Fixed the same way — polling (bounded, 1s, `network_subnet.h`'s CIDR validation/overlap/allocation/address
20ms interval) `namespace_isolated()` (`sandbox_process.h`) until the arithmetic, and `parse_args()` itself against synthetic argv's. **Two
child's own net namespace actually differs from this process's before real bugs found running `parse_args()` repeatedly in one process** (never
trusting its pid. Confirms the host-side tap gets created and attached to possible before this suite existed — a real invocation only ever calls
the bridge (`ip link show` output contains `master <bridge>`), the it once, from `main()`): `getopt_long`'s own scanning position
container-side tap gets created with the requested name inside the target (`optind`) is process-global and was never reset between calls, so a
namespace (checked via a plain, single-shot second `parse_args()` call would silently resume wherever the first
`nsenter --net=/proc/<pid>/ns/net -- ip link show`; an earlier version left off; fixing that alone (`optind = 1`) wasn't enough either, since
wrapped this in a bounded retry, added when tap devices were still created `-h`/`-V` return out of the `getopt_long` loop early (their own
via bare `ioctl(TUNSETIFF)` and intermittently weren't immediately `return 0` case), before a call ever completes its scan and lets
visible — see `network_tap_relay.{h,cpp}`'s own entry below for why that `getopt_long` null out its own private `nextchar` pointer — the *next*
retry, and the whole class of symptom it was compensating for, is gone call then resumed scanning through that stale pointer into the
now that devices are created persistently instead), and — **updated once *previous* call's already-destroyed argv strings. Fixed with
the original "both devices disappear on their own once `stop_tap_relay()` `optind = 0` (not `1`) at the top of `parse_args()` itself — glibc
stops the relay, no explicit `ip link del` needed" assumption turned out documents that value specifically as "fully reinitialize private state
to be wrong on the real target device** (`network_tap_relay.{h,cpp}`'s own before rescanning a new argv"; confirmed clean across repeated runs in
entry below has the full story) — now confirms the *host*-side device is both random and deterministic (`--order lex`) Catch2 ordering.
explicitly gone after `stop_tap_relay()` (the new `ip link del` step) - `tests/integration/test_config_bwrap_chain.cpp` (`[integration]`, no
while the *container*-side device deliberately still exists (correctly net/root) — chains `config_file.h`'s read/write with `bwrap.h`'s argv
persistent — only the relay stopped, not the container's own network assembly: write a config file, load it back, resolve a
namespace); the container-side device's actual disappearance, once that `NamespaceConfig` the same way `run_container()` does, confirm
namespace itself is torn down, isn't separately re-checked (`nsenter` has `build_bwrap_args()`'s resulting argv actually reflects it (disabled
nothing left to target once the namespace's only holding process has `unshare-net`/`unshare-uts` never requested; an all-default config
already exited) — it's destroyed moments later anyway, at the very end of matches the live host's own `detect_bwrap_unshare_args()` probe
this test, when its throwaway namespace-holder process is killed. exactly). `build_bwrap_args()` is pure argv assembly given a `root`
Deliberately its own small file since more real tests are expected here as that's just a string here, never accessed — no mounting, no privilege.
more of the networking feature lands. - `tests/integration/test_rootless_run.cpp` (`[integration][net]`,
rootless) — runs a real busybox image through `dispatch_command()`
(`commands.h`) itself, the exact real `-r/--run` path, in-process.
Confirms bwrap's *default* sandboxing (no `-n`/`-p` at all) is
genuinely isolating: a fresh net namespace with nothing but loopback,
and pid/uts/ipc namespaces differing from the test process's own.
**Real findings, not assumed**: bwrap's sandbox mounts `--proc /proc`
and `--dev /dev` but *not* `/sys` at all (`ls /sys/class/net` inside
the sandbox: "No such file or directory", reproduced via the real CLI
too, not just this test) — the loopback-only check instead reads
`/proc/net/dev` (two header lines + one `<iface>: ...` line per
interface). Also: spdlog's default sink writes to stdout, not stderr,
same as the plain `"mounted image at: ..."` success line — so a naive
stdout capture (`tests/support/fixtures.h`'s `CapturedStdout`, below)
mixes slocker-lite's own status/log output in with the sandboxed
command's real output; fixed by having the sandboxed command bracket
its own output between two unique markers and extracting only what's
strictly between them.
- `tests/integration/test_root_networking.cpp` (`[integration][root][net]`)
— the persistent-netns/tap-relay/dns-resolver tests that originally
lived directly in this file, ported to tagged `TEST_CASE`s (every
assertion uses `CHECK`, not `REQUIRE`, so a failure partway through
still reaches the same unconditional cleanup at the end — these
manage real host-side namespaces/bridges/tap devices that must not
leak just because an earlier assertion failed; simple `if`/pid guards
skip meaningless dependent steps instead). All of the real-bug
narrative originally written here — the `fork()`-vs-`unshare()` race
caught by `wait_for_isolated_net_namespace()`-style polling, the
persistent-tap-device redesign, the `ip link del` teardown fix — is
unchanged in substance, just now describing that file instead of this
one. **One further real bug found porting this to Catch2**: the
tap-relay test's own `create_tap_relay()` call forks a relay child
that relies on `SIGTERM`'s *default* disposition to terminate cleanly
once `stop_tap_relay()` signals it (`network_tap_relay.cpp`'s own
relay loop deliberately installs no handler) — but Catch2 installs its
own fatal-signal handler around a running `TEST_CASE`, which that
forked child inherits, so its ordinary shutdown signal got caught by
the *inherited* handler *in the child* instead, producing a spurious
"FAILED ... due to a fatal error condition: SIGTERM" report
interleaved into the real output (confirmed cosmetic only — exit code
and assertion count were correct either way). Fixed by resetting
`SIGTERM` to `SIG_DFL` just around the `create_tap_relay()` call and
restoring it right after — only the disposition *at fork time* is
inherited, so nothing about how long the relay then keeps running
matters. No production code changed for this; it's purely an artifact
of forking network primitives from within a Catch2-instrumented
process.
- `tests/support/fixtures.{h,cpp}``find_busybox_fixture()` (searches
`images/busybox.tar` relative to cwd, this project's own established
manual-testing convention; `nullopt` if absent, so `[net]` tests
`SKIP()` rather than fail — see `tests/setup-tests.py`, below),
`ScratchXdgDirs` (RAII: points `XDG_CONFIG_HOME`/`XDG_STATE_HOME` at a
fresh `mkdtemp()` directory for its lifetime, restoring the previous
environment and removing the directory on destruction, so integration
tests never touch the real developer's own config/state), and
`CapturedStdout` (RAII: redirects this process's own fd 1 — and
anything a forked/exec'd child inherits from it — to a throwaway temp
file for its lifetime). **Real bug found via ~10-30 repeated combined
`[unit]`+`[integration]` runs, not assumed**: `ScratchXdgDirs`'s
constructor originally built its `mkdtemp()` template vector from two
*separate* temporary `std::string` objects (`.begin()` off one,
`.end()` off the other) — mixing iterators from different containers
is undefined behavior, here manifesting as an intermittent,
heap-address-dependent `std::length_error: cannot create std::vector
larger than max_size()` inside whichever test happened to run adjacent
to it. Fixed by using a single named string instance for both ends of
the range.
- `tests/setup-tests.py` — idempotent fixture fetcher for
`images/busybox.tar`: does nothing if it already exists, otherwise
tries `skopeo``podman``docker` in that order (`skopeo`/`podman`
both reliably produce a genuine OCI Image Layout tar; a plain `docker
save` only does if the containerd image store happens to be enabled,
so the result is verified — `oci-layout`/`index.json` actually present
at the tar root — regardless of which tool produced it, falling
through to the next option otherwise), clear instructions + nonzero
exit if none are available and no fixture already exists.
`meson.build` only compiles any of `tests/unit/`/`tests/integration/`/
`tests/support/` into the `slocker-lite` binary at all when
`enable_tests` is on (mirroring `config.h`'s own `ENABLE_TESTS` guard —
`TEST_CASE`s need `catch2_dep` actually linked, which itself is
conditional on the same option), and registers two more `test()` entries
(`unit-tests`: `-t -- "[unit]"`; `integration-tests`:
`-t -- "[integration]~[net]"`) alongside the original fixture smoke test
— only the categories safe to run unprivileged with no network setup;
`[net]`/`[root]` stay manual-only, run by a developer on a real machine,
matching how this project's self-tests were never part of `meson test`
either.
- `env_spec.{h,cpp}``resolve_env_specs()` turns an ordered list of - `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 `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 `(key, value)` pairs. A literal (`--env`) is split at its *first* `=` (the
@@ -2083,8 +2170,12 @@ Build directory is `buildDir/` (already configured).
`--user`, `--group`, `--hostname`, `--env`, `--env-file`, `-v/--volume`, `--list-volumes`, `--delete-volume`, `--user`, `--group`, `--hostname`, `--env`, `--env-file`, `-v/--volume`, `--list-volumes`, `--delete-volume`,
`--delete-volume-full`, `-n/--network`, `--extern`, `--intern`, `--subnet`, `--no-ipv6`, `--subnet6`, `--delete-volume-full`, `-n/--network`, `--extern`, `--intern`, `--subnet`, `--no-ipv6`, `--subnet6`,
`--no-veth`, `--list-networks`, `--delete-network`, `-p/--port-forward`, `--no-dns`, `--list-processes`, `--clean-processes`, `--no-veth`, `--list-networks`, `--delete-network`, `-p/--port-forward`, `--no-dns`, `--list-processes`, `--clean-processes`,
`-w/--write-config`, `-t/--test`, `--log-level`, `-h/--help`, `-V/--version`) `-w/--write-config`, `-t/--test [-- <catch-command-line-options>]`, `--log-level`,
- Run tests: `meson test -C buildDir` `-h/--help`, `-V/--version`)
- Run tests: `meson test -C buildDir` (the `[unit]` + safe `[integration]` categories
only — see `self_test.{h,cpp}`'s own entry above and `README.md`'s "Testing" section
for the full `-t/--test` category/tag breakdown, including the `[net]`/`[root]`
categories that stay manual-only)
## Code style ## Code style
+33
View File
@@ -49,6 +49,39 @@ meson test -C buildDir
This also builds `buildDir/slocker-lite-priv-drop`, a small statically-linked helper This also builds `buildDir/slocker-lite-priv-drop`, a small statically-linked helper
that `-r --user`/`--group` needs at runtime (see "How it works"). that `-r --user`/`--group` needs at runtime (see "How it works").
## Testing
`meson test -C buildDir` above runs three checks: the original fixture mount/unmount
smoke test, plus two of the four test categories `-t/--test` provides (see below) --
`meson test` only ever runs what's safe unprivileged with no network setup; the other
two categories are for a developer to run by hand on a real machine.
`-t`/`--test` runs this project's own built-in [Catch2](https://github.com/catchorg/Catch2)
test suite directly: `slocker-lite -t [-- <catch-command-line-options>]`. A bare `-t`
runs everything Catch2 finds; select a category with a tag expression (note the `--`
before it, needed since Catch2's own `-r/--reporter` and `-c/--section` would otherwise
collide with slocker-lite's own `-r/--run` and `-c/--cleanup`):
| Category | What it needs | Invocation |
|---|---|---|
| 1. Unit | Nothing | `slocker-lite -t -- "[unit]"` |
| 2. Integration | Nothing (no network, no root) | `slocker-lite -t -- "[integration]~[net]"` |
| 3. Integration, networked | A real image (see below), no root | `slocker-lite -t -- "[integration][net]~[root]"` |
| 4. Integration, root | Root | `slocker-lite -t -- "[integration][root]"` |
Append `~[slow]` to any of these to skip notably slow cases. A `[root]`-tagged test
`SKIP()`s cleanly (not a failure) when not actually run as root, and a
`[net]`-tagged one does the same when it needs a real container image it can't find
(see next) -- so a bare `-t` degrades gracefully rather than failing outright on a
plain rootless dev machine with no fixture set up.
Categories 3 and 4 need a real, runnable OCI image (something with an actual
`/bin/sh`, unlike the minimal single-file fixture the plain `meson test` smoke test
uses) at `images/busybox.tar`. Run `tests/setup-tests.py` to fetch one automatically
(via `skopeo`, `podman`, or `docker`, whichever is available -- does nothing if
`images/busybox.tar` already exists, e.g. your own build); or just drop your own
busybox-like image there directly.
## Usage ## Usage
``` ```