Covers all five -u/--up steps (compose_orchestrator.{h,cpp}), the
run_container()/run_mounted_container() split, and the newly-exported
commands.cpp functions the orchestrator reuses.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
compose_state_file_path()/record_compose_services() write one line per
started service ("<service_name> <container_name> <pid>") to
$XDG_STATE_HOME/slocker-lite/compose/<sanitized-absolute-compose-path>,
keyed by the compose file's own resolved path -- the same state-file
naming pattern port_forward.h's/network_tap_relay.h's own crash-orphan
records already use, so a future -d/--down implementation can find exactly
which sessions a given -u/--up started. Overwrites any previous run's own
record for the same file, a known limitation until -d/--down itself exists
to keep the two in sync.
Verified manually end to end: -u against a 2-service, depends_on-linked
compose file mounts, provisions, starts both daemonized in order, and
writes a correct state file; --kill against the recorded pids leaves no
processes behind.
This completes the -u/--up sequence requested (collect images, mount all
before starting any, provision networks/volumes, start in dependency
order backgrounded, record pids). Not yet implemented: -d/--down itself
(still a stub), port-forwarding/--user/--group for compose services, and
re-verification of network provisioning specifically against real root
(the volume/single-network-free path was verified rootless; network
provisioning reuses already-proven create_network_command()/
ensure_network_provisioned() as-is).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
start_compose_services() topologically orders services by depends_on
(Kahn's algorithm -- load_compose_file() already guarantees the graph is
acyclic) and starts each one daemonized (as if -D/--daemonize had been
given), against its own already-mounted image, provisioned networks
(project-prefixed names resolved via the mapping from step 3), and
provisioned named volumes. A service whose dependency failed to start (or
was itself skipped) is skipped too, logged clearly, and never started
against a dependency that isn't actually running.
A service's session identity (pid-file/log/cgroup naming) is its explicit
container_name: if given, else "<project>_<service>"; its DNS hostname
(what sibling services resolve it by) is instead its explicit
container_name: or bare service name, never project-prefixed -- matching
real Compose's own service-name resolution. Port-forwarding and explicit
--user/--group aren't wired up for compose services yet.
Verified manually (rootless): a 2-service compose file with a depends_on
edge starts both daemonized, in order, with correct --hostname each;
--list-processes/`ps` confirm both genuinely running; --kill cleanly stops
both with no leftover processes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
run_container() (-r/--run) still decides container_name, handles the
-D/--daemonize fork (which must happen before mounting so the log file can
be named from its first line), and calls mount_image() itself. Everything
after that -- volume/env/user resolution, namespace policy, network/
port-forward/DNS setup, running bwrap, and unmount/cleanup -- moves into a
new, exported run_mounted_container(), taking an already-mounted image
instead of mounting its own. No behavior change for -r/--run itself; this
is prep for the compose orchestrator's own dependency-ordered starting
(next commit), which mounts every service's image up front and needs to
run each one against its own already-mounted image rather than a second
copy of ~200 lines of this logic.
Verified: meson test clean, [integration][net]~[root] clean (exercises
this exact path via test_rootless_run.cpp), and a direct manual `-r
images/busybox.tar -- echo` smoke test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Exports create_volume_command()/create_network_command() (pure refactor)
so the orchestrator can reuse -v/--volume's and -n/--network's own
provisioning logic directly. New provision_compose_networks_and_volumes()
creates every managed (non-external) network/volume the compose file
declares, project-name-prefixed (compose_project_name(), derived the same
way real Docker Compose derives its own default project name -- the
compose file's parent directory basename) so two different compose
projects can't collide in slocker-lite's single, flat networks/volumes
namespace. Finding one that already exists is deliberately not an error --
reused as-is, per the user's own explicit direction (e.g. left over from an
interrupted previous -u/--up run). external: true networks map to
themselves, unprefixed, since they're real pre-existing networks by
definition.
Verified manually (rootless): volume provisioning creates the
project-prefixed directory/config entry, and a second -u run against the
same compose file correctly reuses it without error. Network provisioning
reuses ensure_network_provisioned()/create_network_command() as-is (no new
logic there) but wasn't separately re-verified live, since it needs root.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
mount_compose_images() mounts every resolved image up front, deliberately
before starting any service -- mounting can be slow on some devices, so
this avoids leaving earlier services already running while a later one is
still mounting. On any failure partway through, unmounts+cleans up every
image already mounted in the same call before returning, so a failed
-u/--up never leaves a partial mount set behind.
Verified manually: single and multi-service mounts each produce distinct
layer IDs; the successful path leaves images mounted deliberately, since
the next step's dependency-ordered starting still needs them mounted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Exports MountedImage/mount_image() from commands.cpp's own anonymous
namespace (pure refactor, no behavior change) so the new orchestrator can
reuse them. New src/compose_orchestrator.{h,cpp} adds
resolve_compose_images(), matching each service's image: reference against
list_oci_images() -- the same function -l/--list-images already uses --
before anything is mounted or started, wired into -u/--up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Both take a required OCI images directory plus an optional compose file
name (defaulting to "compose.yaml", resolved relative to the cwd) via the
same manual two-token consumption -v/--volume already uses, just with the
second token optional. The stub commands aren't no-ops: they resolve the
images directory, load and validate the compose file through the existing
load_compose_file()/validate_compose_external_state(), and print a summary
-- confirming the CLI wiring and parser work end to end -- before logging
that actual orchestration isn't implemented yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Separate from load_compose_file() (which stays pure YAML validation with no
host-state dependency): checks every env_file exists as a readable regular
file, and every network marked external: true already exists in the real
persistent.yaml. Fail-fast, same convention as load_compose_file()'s own
cross-validation. Bind-mount host directories are deliberately not checked
here, since resolve_volume_mount() already auto-creates a missing one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
- depends_on cycles (not just direct self-reference): a three-color DFS
over the dependency graph reports the actual cycle path.
- Duplicate host-port/protocol across (or within) services: two services
both publishing the same (host_port, protocol) would only ever leave one
reachable, even though both DNAT rules would get added later.
- container_name colliding with another service's own implicit name, not
just two explicit container_names matching each other.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
load_compose_file() (src/compose_file.{h,cpp}) parses and validates
services/networks/volumes -- unrecognized keys are silently ignored, but a
malformed value for a supported key is a hard parse error, since a Compose
file describes an actual deployment rather than being a version-spanning
settings file. Confirmed no dedicated C++ library for this exists, so it's
hand-written against the already-present libyaml dependency rather than
pulling in the official JSON Schema plus a validator library.
scalar_value()/find_in_mapping() move out of config_file.cpp's own
.cpp-local pair into a new shared src/yaml_util.{h,cpp} (plus a new
sequence_items(), for Compose's list-valued keys) so both files share one
YAML-traversal implementation instead of drifting copies.
tests/unit/test_compose_file.cpp covers every supported field/form and
validation error against small hand-written snippets -- the checked-in
test-compose/compose.yaml skeleton is reserved for later integration tests
once an orchestrator exists, not these unit tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Two busybox services (a worker echoing a fixed reply, a server that relays
it) exercising the compose fields we intend to support: services/image/
command, container_name, environment/env_file, depends_on (condition only),
stop_grace_period, networks (internal/external), ports, and bind + named
volumes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Running the full device test suite on the real Android target found a
genuine test-harness gap: every extern-network test in
test_network_join_scenarios.cpp failed with "RTNETLINK answers: File
exists" adding its own uplink route. Root cause: each test runs under its
own ScratchXdgDirs, so its own persistent.yaml starts empty every time --
allocate_ipv4_subnet()'s own auto-allocation always picks the very first
slot (10.168.0.0/24) with no way to see whatever real, non-test networks
already exist on the host. The device happens to have a real, long-lived
"extern" network already occupying exactly that subnet from prior manual
testing, and since extern's uplink adds a route back into the (necessarily
shared, host-root) routing table -- unlike intern, whose routing lives
entirely inside its own isolated per-network namespace -- every extern
test collided with it. Not a production bug: a real end user only ever has
one persistent.yaml where allocation correctly sees every existing entry.
Fixed by giving each of the 10 test networks in
test_network_join_scenarios.cpp its own fixed, explicit subnet (--subnet)
in 10.169.0.0/16 -- a different /16 than production's own default
10.168.0.0/16 range, so a test run can't collide with a real network
regardless of how many the host already has.
Also renamed every network/hostname/container-name string literal used
across the test suite (test_network_join_scenarios.cpp,
test_root_networking.cpp, test_rootless_run.cpp,
test_session_cleanup.cpp) from "selftest*" to "test-*", to further reduce
the chance of colliding with anything a real invocation might already be
using. Low-level interface device literals (slkselftest0/thselftest0/
ethselftest in test_root_networking.cpp) are left as-is -- they're
internal identifiers for a throwaway unit test, not network or container
names.
Verified: clean rebuild, meson test, and 3 consecutive
[integration][root] suite runs (61 assertions, 14 test cases) with no
failures.
The tap+relay intern-ping test and the DNS hostname-ping test both
flaked intermittently (roughly 1 in 13-20 runs), always in a "found_success
== false" shape with no assertion-level clue as to why.
Root cause, confirmed via a temporary production-code diagnostic (since
reverted) and cross-referenced against network_join.cpp's own code: the
readiness poll only waited for eth0 to *exist* (`ip link show eth0`), but
an interface can become visible before join_one_network()'s own later
`ip addr add`/`ip link set ... up` steps for it have actually run. A
script that used the interface as soon as it merely existed could ping,
get "Network unreachable" (no address yet), and exit almost immediately
-- and since a session's own sandboxed process is the sole occupant of
its pid/net namespace (--unshare-pid/--unshare-net), its exit destroys
that namespace outright. That, in turn, made whichever other nsenter
call was still in flight against the same namespace -- join_one_network()'s
own remaining steps, or the entirely separate per-session DNS resolver
(network_dns.cpp's start_dns_resolver(), which enters every networked
session's namespace regardless of whether --hostname was given) -- fail
with "No such file or directory" against a namespace that had already
collapsed underneath it.
This is the same general class of race network_join.{h,cpp}'s own
CLAUDE.md entry already documents (a very-short-lived sandboxed command
can outrun its own concurrent network setup), just one step further than
the eth0-existence race already fixed earlier in this file -- a test-code
issue, not a production bug. Fixed by polling for an actually assigned
address on eth0 instead of mere existence, in both wait_for_eth0_then()
and BackgroundPeer's own inline readiness script.
Verified with the diagnostic in place that the DNS resolver's own
namespace lookup was never itself stale, isolating the cause to the
script's own premature exit. Re-verified extensively after the fix:
8/8 isolated repeats of the previously-flaky tap+relay test, and 6
consecutive full [integration][root][net] suite runs (78 test-case
executions total) with no failures.
Two peers on the same intern network, one started with --hostname
peer-a, resolve and ping each other by name via the per-session dnsmasq
resolver (network_dns.cpp). Skips cleanly when dnsmasq isn't available.
wait_for_hostname_then() retries the whole ping-by-name probe (not just
an eth0 existence check) until it succeeds or the bound is hit, since
this races against two independent things starting concurrently with
the sandboxed command: the interface coming up, and the per-session
resolver picking up the peer's own hosts record -- same underlying
join_networks() timing limitation the earlier wait_for_eth0_then() fix
was for. Verified end-to-end as root, both variants.
The positive counterpart to the intern-network isolation tests: a
container joined to an extern network gets a default route through its
uplink and can reach a real outside address. Verified end-to-end as
root, both variants.
Same shape as the intern-network peer-ping pair, on an extern network
instead: confirms same-bridge peer reachability still works once the
extern uplink (network_bridge.cpp's ensure_uplink_provisioned()) is also
provisioned alongside the bridge. Verified end-to-end as root, both
veth and tap+relay variants.
A container joined only to an intern network gets no default route
(network_join.cpp), so pinging a real outside address (8.8.8.8) must
fail outright, not merely succeed slower than an extern join would.
Verified end-to-end as root: both variants correctly report
"Network unreachable" and the tests assert a RESULT= line was seen
(the command actually ran) that isn't RESULT=0.
First of a planned series of end-to-end -n/--network join tests
(tests/integration/test_network_join_scenarios.cpp, [integration][root][net]):
two containers joined to the same intern network ping each other by IP,
run once with a real veth pair and once forced onto the tap+relay
fallback, since the two are genuinely different implementations. IPv6 is
deliberately excluded pending a known device-specific peculiarity.
split_lines_trimmed()/extract_marked_lines() moved from
test_rootless_run.cpp into tests/support/fixtures.{h,cpp} for reuse here.
wait_for_eth0_then() wraps a sandboxed command's own network-touching
script in a poll for eth0 to exist first: join_networks() runs
concurrently with, not before, the sandboxed command starting, so a
near-instant command can otherwise exit before its own join finishes --
the exact limitation already documented in network_join.{h,cpp}'s own
CLAUDE.md entry. Confirmed by testing (not assumed): without this, the
container's own immediate ping-and-exit sometimes raced ahead of the
veth-move step, which then failed outright ("Invalid netns value")
against an already-exited pid.
Same class of test gap as the net-namespace test just fixed: this test
filtered which of pid/uts/ipc/cgroup to check by kernel support alone,
never by the config's own policy, so running the full suite as root with
every unshare-* flag forced off (via -c/--config-file) correctly made
bwrap skip requesting all four -- production code working as configured,
not a bug -- while the test still asserted each was isolated and failed.
Reuses namespace_type_would_isolate() (introduced in the previous commit)
to filter to_check by both gates instead of kernel support alone.
Full suite as root with everything disabled: 78 test cases, 76 passed, 2
skipped (this one and the net-namespace test), 0 failed -- the complete
picture requested.
Found by running the full suite as root with every unshare-* flag forced
off via -c/--config-file: this test only ever checked kernel support for
--unshare-net, never the config's own policy, so disabling it via
global.unshare-net correctly makes bwrap skip requesting the namespace --
production code working exactly as configured, not a bug -- while the test
still asserted isolation and failed.
Generalized the existing pid-only two-gate check into
namespace_type_would_isolate(type), covering any of bwrap.cpp's own
namespace_probes names, and used it here instead of a kernel-support-only
check.
create_session_cgroup() used to be called from run_bwrap()'s on_start
callback, in the parent, concurrently with the just-forked child execing
into bwrap and bwrap then doing its own internal clone() of the sandboxed
target. Without --unshare-pid, bwrap has little enough setup work to do
that it could reliably win that race, cloning its target before the
parent's own write into cgroup.procs completed -- leaving that target, and
everything it later spawns, permanently outside the tracked cgroup, so the
post-exit sweep found nothing to reap. Found via the user's own request to
test "pid namespace off, cgroup on" as root: confirmed directly by
inspecting cgroup.procs mid-session, showing only bwrap's own pid.
Fixed by giving run_process_foreground() a new before_exec hook, invoked in
the child synchronously right before execvp() -- the child cannot proceed
to exec (and thus cannot trigger any of bwrap's own internal forking) until
this has already returned, closing the race structurally rather than by
timing luck. run_bwrap() now creates the session cgroup there instead of in
on_start; the parent side just reconstructs the deterministic path
unconditionally, since the downstream sweep/cleanup functions already
tolerate a nonexistent directory gracefully either way.
Also fixes a false positive found while verifying this: the regression
test's own process-matching did a substring search across a whole cmdline
blob, which matched an unrelated manual `pkill -f 'sleep 137'` diagnostic
command run by hand during the investigation. Tightened to an exact
argv[0]/argv[1] match.
Finally, the regression test now SKIP()s (instead of failing) when neither
a pid namespace nor a working session cgroup is available for the current
effective config -- a documented, known residual limitation, not a
regression -- checked directly via two new helpers rather than assumed from
e.g. geteuid().
run_self_tests() now takes the same effective AppConfig any other command
gets and stashes it into a new g_test_app_config global (tests/support/
fixtures.h) before Catch2 runs anything. test_rootless_run.cpp's own
run_in_fixture() reads a copy of it instead of a hardcoded default
AppConfig{}, so -c actually reaches that test's container creation.
Verified: a config with every unshare-*/with-* key set to false, used via
-c <file> -t -- "[integration][net]~[root]", flips all 3 of that file's
container-creating tests to failing -- including the nohup-straggler
regression test, since with neither a pid namespace nor a cgroup nothing
reaps the backgrounded process -- while [unit] and non-networking
[integration] tests are completely unaffected, as expected.
Reproduces the user's reported real-world shape end to end with a real
busybox container ("nohup sleep 137 & exit"), verifying via the host's own
/proc that the backgrounded process is actually gone afterward -- checked
by cmdline substring, not pid, since a pid seen inside an isolated pid
namespace doesn't correspond to the same-numbered host pid. A bounded 2s
poll guards against the pid namespace's own kernel collapse-on-pid-1-exit
timing (which already covers this case for free on this dev machine).
Complements test_session_cleanup.cpp's existing [integration][root] test,
which exercises kill_via_cgroup() directly -- this one instead proves the
outward, visible contract holds through the real -r/--run path.
config.yaml now holds only the global section (log-level, unshare-*,
with-veth, with-ipv6); a new persistent.yaml holds volumes/networks.
load_config_file()/write_config_file() are replaced by
load_global_config()/load_persistent_config()/write_global_config()/
write_persistent_config(), each touching only their own file.
-c/--config-file <path> lets one invocation use an alternate file for the
global section only -- persistent.yaml is always the one fixed path,
regardless of -c, so an experiment can never affect real volumes/networks
(a -c file's own volumes/networks, if any, are simply never read either).
A -c path that doesn't exist is a hard error, unlike the default path's
existing missing-file leniency.
migrate_legacy_config_if_needed() moves volumes/networks out of an
old-format config.yaml into persistent.yaml on first run after upgrading,
always against the fixed default paths regardless of -c. A name collision
aborts the migration for that run (touching neither file) rather than
risking data loss.
Required reordering main() to parse CLI args before loading config (so
-c's value is known first) -- ParsedArgs::log_level_flag_given tracks
whether --log-level was already given so the config file's own log-level
doesn't clobber it despite the reversed call order.
Now that -r/--run and -x/--exec cover normal use, --mount/--umount/--cleanup
are debug-only escape hatches not worth a short letter. Reassigned their
long_options codes to long-option-only constants (options::mount/umount/
cleanup) and dropped m:/u:/c: from getopt_long's own short-options string.
Updated the fixture smoke test (tests/run_test.py) and docs, which invoked
-m/-u/-c directly.
Replaces --no-ipv6/--no-veth (plain flags) with --with-ipv6/--with-veth,
each taking an explicit true/false value (e.g. --with-veth=false), parsed
via the same parse_bool_flag() the config file itself already uses (now
exported from config_file.h so cli_args.cpp can reuse it).
create_network_command() now resolves ipv6/veth as CLI flag -> config's own
global.with-ipv6/global.with-veth -> true, so a host that always wants the
tap+relay fallback (or no IPv6) can set it once in the config instead of
passing the flag on every network creation. -w/--write-config fills in both
new keys like the existing six unshare-* bools.
test_session_cleanup.cpp exercises kill_via_cgroup() directly against two
plain forked processes (one setsid()-ing away from the other before it
exits), confirming a reparented straggler is actually reaped -- reproducing
the real escape shape (no pid namespace support at all) through a full
mount/bwrap session isn't possible from the CLI on a single run, since
--unshare-pid is a config-file-only setting, not a flag.
Also resolves TODO.md's SIGINT/SIGTERM entry and extends the relevant
CLAUDE.md sections (bwrap.{h,cpp}, session_cgroup.{h,cpp}, kill_session.{h,cpp})
with the fix's rationale and its known residual limitation (a kernel with
neither cgroup v2 nor pid namespace support still can't be reached
automatically).
A container process that daemonizes/double-forks and setsid()'s away can
escape bwrap's own pid tree and outlive the session, whether it ends via a
normal exit, a forwarded SIGINT/SIGTERM, or -D/--daemonize -- --kill's own
cgroup-based sweep already reaches such a straggler for a still-running
session, but nothing ran that sweep automatically once the session itself
ended.
Export kill_via_cgroup() (previously kill_session.cpp-local) and call it
from run_bwrap(), right after run_process_foreground() returns and before
the session cgroup is removed, whenever anything is still left in it -- runs
unconditionally regardless of why bwrap exited, and covers -D for free since
it re-enters this same run_bwrap() call from within the daemonized child.
Found via real-device testing (the actual Android target), not assumed:
both tests hardcoded assumptions that don't hold on every kernel.
1. "a fresh network namespace has only loopback" assumed exactly 3 lines
of /proc/net/dev (2-line header + one "lo" entry) -- the real target
device's kernel auto-creates several harmless placeholder tunnel
interfaces (sit0, ip6tnl0, ip_vti0, ip6_vti0) in *every* fresh network
namespace, alongside loopback. The namespace is still genuinely
isolated (confirmed: none of the *host's* real interfaces leak in) --
the test's assumption was just wrong for this kernel. Replaced the
exact-count check with a readlink-based /proc/self/ns/net identity
comparison (proves genuine isolation regardless of kernel config) plus
a simple "loopback is present" check, dropping the brittle count
assertion entirely.
2. "pid/uts/ipc namespaces differ from this process's own" assumed all
three are always readable via /proc/self/ns/<type>. The real target
device has neither PID nor IPC namespace support *as a kernel feature
at all* -- confirmed directly: even this test process's own `readlink
/proc/self/ns/pid`, run completely outside any container, fails
outright there. This matches this project's own already-documented
standing lesson (neither CONFIG_CHECKPOINT_RESTORE nor pid namespace
support on this target). Comparing against a namespace type the kernel
doesn't expose at all wouldn't prove anything either way.
Both tests now build their expectations from detect_bwrap_unshare_args()
(bwrap.h) -- this host's own live kernel-capability probe, the exact same
one build_bwrap_args() itself already gates on -- rather than assuming a
fixed set of namespace types is always available. "user" is deliberately
excluded from the generic per-type check: build_bwrap_args() never
requests --unshare-user when running as root, so asserting on it would be
wrong specifically when these tests are run as root (as they are on the
real device).
Verified: passes repeatably on this dev machine (all 6 namespace types
supported, 8 assertions/2 test cases either way -- same coverage as
before, just derived instead of hardcoded), full combined suite and
meson test both still clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
forward_signal_to_foreground_child() (process.cpp) does a plain kill() on
only the one tracked bwrap pid -- unlike --kill's kill_session(), which
picks a strategy (cgroup, pid-namespace, or tracked-pid) to reach every
process the session started. Anything inside the sandbox that
daemonizes/double-forks into a new session escapes the simple forward and
can be left running after Ctrl-C, even though --kill against the same
session would reach it. Reported by the user during real-device testing;
not yet reproduced with a specific repro, just the architectural gap.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
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
Two new test() entries alongside the existing fixture smoke test:
'unit-tests' (-t -- "[unit]") and 'integration-tests'
(-t -- "[integration]~[net]") -- both safe to run unprivileged with no
network setup, so meson test -C buildDir now catches regressions in those
categories automatically. [net] and [root] tests stay manual-only
(developer-run on a real/root-capable machine), matching how this
project's own self-tests were never part of meson test either. Only
registered when enable_tests is on, matching test_sources' own guard --
verified a -Denable_tests=false build still runs cleanly with just the
original smoke test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
tests/integration/test_rootless_run.cpp runs a real busybox image through
the exact real -r/--run dispatch path (dispatch_command(), commands.h) --
mount, resolve, run_bwrap, unmount, cleanup, in-process rather than via a
subprocess -- and confirms bwrap's *default* sandboxing (no -n/-p at all)
is genuinely isolating: a fresh network namespace with nothing but
loopback, and pid/uts/ipc namespaces that differ from this test process's
own. No root needed, same as a plain `-r image.tar -- <command>` already
isn't.
New tests/support helpers: 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) so the sandboxed command's own
output can actually be asserted on.
Two real, non-obvious findings from getting this working, not assumed:
1. bwrap's own sandbox mounts --proc /proc and --dev /dev, but *not*
/sys -- confirmed directly (`ls /sys/class/net` inside the sandbox:
"No such file or directory", reproduced identically via the real CLI,
not just this test). Switched the loopback-only check to
/proc/net/dev instead (two header lines + one "<iface>: ..." line per
interface), which correctly shows only "lo".
2. spdlog's default sink writes to stdout, not stderr, same as the plain
"mounted image at: ..." success line (see CLAUDE.md) -- so a naive
capture-and-line-split mixed 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.
Verified: both tests pass repeatably, 15 stress-test runs of the full
combined [unit]+[integration] suite with zero failures, plus a full run
as root.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Idempotent: does nothing if images/busybox.tar already exists (your own
unofficial build, or a previous fetch). Otherwise tries skopeo, then
podman, then 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 of the three are available and
no fixture already exists.
Verified the no-op (already-present) path and the no-tools-available error
path directly (temporarily moved the existing images/busybox.tar aside and
back) -- this dev machine has none of skopeo/podman/docker installed, so
the actual fetch path itself is unverified here; the format-verification
step (looks_like_oci_layout()) is what protects against a `docker save`
that produced the legacy Docker tar format on some other machine.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
tests/support/fixtures.{h,cpp}: find_busybox_fixture() (images/busybox.tar
relative to cwd, this project's own established manual-testing convention
-- nullopt if absent, so [net] tests can SKIP() rather than fail) and
ScratchXdgDirs, an RAII helper pointing XDG_CONFIG_HOME/XDG_STATE_HOME at a
fresh throwaway mkdtemp() directory for its lifetime, restoring the
previous environment and removing the directory on destruction -- so
integration tests that actually exercise config_file_path()/xdg_state_dir()
never touch the real developer's own config/state.
tests/integration/test_config_bwrap_chain.cpp: the user's own example --
write a config file, load it back, resolve a NamespaceConfig from it the
same way run_container() (commands.cpp) does, and confirm build_bwrap_args()'s
resulting argv actually reflects it (disabled unshare-net/unshare-uts never
requested; an all-default config matches the live host's own
detect_bwrap_unshare_args() probe exactly). Neither test mounts/runs
anything or needs privilege.
Real bug found via ~10-30 repeated combined [unit]+[integration] runs, not
assumed: ScratchXdgDirs's constructor 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; confirmed
clean across 30 repeated combined runs afterward, plus a full run as root.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
One file per source area, exercising the pure/isolated parsing and CIDR-
arithmetic functions already exposed via headers with no side effects --
parse_port_forward_spec() (protocol suffix parsing/validation, network
resolution left to add_port_forward()), resolve_env_specs() (literal and
--env-file parsing, ordering, error cases -- a small local RAII ScratchFile
helper writes the --env-file fixtures under /tmp), network_subnet.h's CIDR
validation/overlap/allocation/address-arithmetic functions, and parse_args()
itself against synthetic argv's.
Two real bugs found running parse_args() repeatedly in one process (never
possible before -- a real invocation only ever calls it once), not
assumed:
1. getopt_long's scanning position (`optind`) is process-global and never
reset, so a second parse_args() call would silently resume scanning
wherever the first one left off. Fixing this alone (optind = 1) wasn't
enough on its own, either --
2. -h/-V return out of the getopt_long loop early (their own `return 0`
case), before a call ever completes its scan and lets getopt_long null
out its own private `nextchar` pointer -- the *next* parse_args() call
then resumed scanning through that stale pointer into the *previous*
call's already-destroyed argv strings, misparsing its own fresh argv.
glibc documents `optind = 0` (not 1) as the "fully reinitialize private
state before rescanning a new argv" signal; switching to it fixed this
for good, confirmed by 3 repeated runs each in both random and
deterministic (--order lex) Catch2 ordering with zero flakiness either
way.
Neither bug could ever have surfaced in real usage (parse_args() is only
ever called once per process from main()) -- purely a testability gap the
new unit tests exposed, now fixed at the source rather than worked around
in the test file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
persistent-netns, tap-relay, and dns-resolver (formerly hand-rolled
bool-returning functions in self_test.cpp, called unconditionally by the
old ad hoc run_self_tests()) move to tests/integration/test_root_networking.cpp
as TEST_CASEs tagged [integration][root][net] -- SKIP() (not a whole-suite
skip) when not root, or when dnsmasq isn't installed for the DNS one, so
e.g. -t -- "[unit]" on a rootless machine is unaffected. Every assertion
uses CHECK, not REQUIRE: these tests manage real host-side namespaces,
bridges, and tap devices that must not leak just because an earlier
assertion failed, so execution always falls through to the same
unconditional cleanup at the end (guarded only by simple pid/bool checks
to skip meaningless dependent steps).
Real bug found running the tap-relay test under Catch2, not assumed:
Catch2 installs its own fatal-signal handler around a running TEST_CASE,
which create_tap_relay()'s own forked relay child inherits -- so the
relay's ordinary shutdown SIGTERM (sent by stop_tap_relay()) got caught by
that *inherited* handler in the child instead of terminating it via the
default disposition the relay's own design relies on, 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, just confusing). Fixed by
resetting SIGTERM to SIG_DFL for the narrow window 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.
meson.build: the new tests/integration/*.cpp sources are only added to
slocker-lite's own source list when enable_tests is true (mirroring
config.h's ENABLE_TESTS runtime guard, added last commit), and src/ is
added to the target's own include_directories so test sources under
tests/ can #include project headers the same way src/*.cpp already does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
-t now accepts trailing args (mirroring -r/-x's own trailing-command
capture, ParsedArgs::test_args), forwarded unmodified to Catch2. Bare -t
runs every registered TEST_CASE (currently none); a tag expression after
'--' selects a subset once real tests land, e.g. -t -- "[unit]". The '--'
matters since Catch2's own -r/--reporter and -c/--section collide with
slocker-lite's -r/--run and -c/--cleanup.
Guarded by config.h's ENABLE_TESTS macro (from Meson's existing
enable_tests option, already linking catch2_dep into the binary but never
actually used until now) -- a -Denable_tests=false build prints a clear
message instead of failing to link.
The 3 hand-rolled root-only self-tests this replaced (persistent-netns,
tap-relay, dns-resolver) are being ported to proper tagged TEST_CASEs in
a follow-up commit, not lost.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
run_process_foreground() previously collapsed "child killed by a signal"
into the exact same exit_code == -1 sentinel as "fork() itself failed",
triggering commands.cpp's "failed to run bwrap" spdlog::error for the
ordinary case of Ctrl-C (SIGINT, forwarded to bwrap by this project's own
forward_signal_to_foreground_child handler) or `kill`/--kill (SIGTERM)
ending a foreground -r/--run session -- both ways this project deliberately
supports stopping one cleanly, not failures.
Now distinguishes WIFSIGNALED from a real fork() failure, returning
128+signal (the same convention a shell itself uses for $? after a
signal-killed job) instead of -1. SIGINT/SIGTERM specifically log at debug
(invisible at the default log level) rather than warn; any other signal
still warns, since that's a genuine, unexpected crash. commands.cpp's own
`exit_code < 0` check is now accurate -- it only ever fires on a genuine
fork() failure.
Verified directly (rootless, this dev machine): SIGINT and SIGTERM against
a running foreground session both now exit 130/143 respectively with no
error or warning logged at the default level (only debug), unmount/cleanup
still ran either way; a genuine failure (nonexistent command inside the
sandbox) still warns and exits 1, unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
CLAUDE.md's port_forward.{h,cpp} entry, README.md's -p table row, and
docs/networking-design.md's syntax line all updated for the new
[/tcp|udp] suffix. Includes the local dev-machine (root, via the scoped
doas rule) verification detail: TCP unaffected, UDP confirmed end-to-end
(a raw datagram sent to the forwarded host port was read back inside the
container via -x/--exec), same port pair coexisting on both protocols,
invalid-protocol parse errors, clean teardown, and --clean-processes
sweeping both the old 3-field and new 4-field state-file formats. Real
Android iptables/tetherctrl_FORWARD confirmation for UDP is still open.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Extends the spec syntax with an optional /tcp|udp suffix
([<network>:]<host-port>:<container-port>[/proto]), defaulting to tcp so
every existing -p spec keeps working unchanged. PortForwardSpec and
ActivePortForward carry the resolved PortForwardProtocol; add/remove_port_forward()
use it to pick iptables' own -p tcp/-p udp for both the DNAT and FORWARD
ACCEPT rules. The port-forward state file gains a 4th field for the
protocol; clean_stale_port_forwards() parses per-line rather than chaining
extraction operators, so a pre-UDP 3-field record still gets its rule
removed instead of silently short-circuiting.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
The user identified the exact trigger: declare -p HOME showed
`declare -- HOME="/root"` (no -x), meaning HOME was a plain shell
variable, never exported, so slocker-lite's own getenv("HOME") saw
nothing -- identical to HOME being fully unset from a child process's
point of view. An earlier full environment dump had looked like it
already had HOME correctly set and ruled this out; it didn't, since
that dump listed all shell variables (declare -p style), not strictly
the exported environment a child process actually receives.
Reproduced directly on the real device (env -u HOME bash -c 'HOME=/root;
declare -p HOME; slocker-lite -w') and confirmed the existing fix
(resolve_home_dir()'s passwd-database fallback) already handles it
correctly -- resolves to /root/.config/slocker-lite/config.yaml, not a
cwd-relative path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
The previous entry's fix (xdg_state_dir()/config_file_path() now
absolute-by-construction, plus a passwd-database $HOME fallback) has
landed, but the user's own environment dump after hitting the dnsmasq
symptom showed $HOME correctly set to /root with nothing that should
have produced a relative path under the old code either -- so the exact
mechanism that triggered it originally is still unconfirmed, even though
the fix should cover it defensively regardless of cause.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Root cause of two real bugs reported from the real target device: the
per-session DNS resolver failing to start ("dnsmasq: failed to open
pidfile .local/state/slocker-lite/dns-resolvers/... No such file or
directory" -- a relative path, not absolute), and, earlier, two
slocker-lite invocations from different working directories silently
ending up with two completely disjoint config.yaml/state trees.
xdg_state_dir()/config_file_path() built
`std::filesystem::path(home ? home : "") / ".local" / "state"` whenever
$HOME wasn't available to the exact invoking process -- which resolves
to a relative path ("./.local/state"), not an absolute one, with no
error. Harmless as long as every reader/writer shared the same process
and cwd, but broke outright once dnsmasq (network_dns.cpp), a genuinely
separate process, tried to open a --pid-file built from that same
relative path.
Fixed two ways: a new resolve_home_dir() (pid_file.{h,cpp}) falls back
to the passwd database entry for the current uid when $HOME itself is
unset, the same fallback well-behaved tools like su/sshd already use;
and both xdg_state_dir() and config_file_path() now make their own
final return value absolute (std::filesystem::absolute()) regardless of
which piece was relative, covering a relative
$XDG_STATE_HOME/$XDG_CONFIG_HOME override too, not just an unset $HOME.
Verified locally: with $HOME unset entirely, -w now correctly resolves
to the real home directory via the passwd fallback instead of a
cwd-relative path; with XDG_CONFIG_HOME set to a relative value, the
result is still a proper absolute path (resolved against cwd), not a
bare relative one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Records the design decision (one dnsmasq instance per session, not per
network -- avoids the NXDOMAIN-fallthrough problem a per-network design
would have hit for multi-network containers) and the three real bugs
found while building it (dnsmasq's --pid-file needing daemonize mode,
its default privilege drop breaking $XDG_STATE_HOME access, and REFUSED
AAAA answers breaking getaddrinfo()-based tools), matching the level of
detail already recorded for the other networking features in this
document and in CLAUDE.md's own file-by-file reference.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Found on the real device: two slocker-lite invocations from different
working directories ended up with completely separate config.yaml/state
trees (one under /root, one under /root/src/slocker-lite), both
auto-allocating the same 10.168.0.0/24 subnet and independently mutating
host-level ip/iptables state with no awareness of each other. Looks like
$HOME being unset/empty in some invocations, causing the $HOME-relative
fallback to silently resolve relative to cwd instead -- not yet confirmed,
needs a real repro before deciding on a fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Tracks the security follow-up for network_dns.cpp's --user=root
--group=root workaround -- dnsmasq's own default privilege drop broke
reading state under /root (mode 0700), so it's kept at root entirely for
now. Not urgent (networking here is already root-only throughout), but
worth revisiting later.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
Containers on a shared -n <network> can now resolve each other by the
name given via --hostname on any network they share, plus
host.containers.internal (podman's convention) resolving to the first
extern network's own gateway, if any.
One dnsmasq instance per session, not per network: with one instance per
network instead, a container joined to two networks would list two
nameserver lines in resolv.conf, and standard stub resolvers don't fall
through to the next nameserver on NXDOMAIN, only on timeout -- a name
that exists only on the second network would silently fail to resolve.
Running one instance per session, entered into the container's own
network namespace and bound to 127.0.0.1:53, configured (via dnsmasq's
own repeatable --hostsdir) to watch every network that specific
container joined, avoids the problem entirely.
Three real bugs found via direct testing while building this, not
assumed:
- dnsmasq only writes --pid-file while actually daemonizing;
-d/--no-daemon suppresses it, so the startup-confirmation poll needs
to read the real daemon pid back from the file rather than assume the
forked/exec'd pid is it.
- dnsmasq drops root privileges to an unprivileged user by default,
which then couldn't read $XDG_STATE_HOME (under /root, mode 0700) at
all -- fixed with an explicit --user=root --group=root (tracked as a
security follow-up in TODO.md: run it as a low-privilege user instead
and relocate the files it needs).
- an AAAA query for a name with only an A record came back REFUSED
(breaking any getaddrinfo()-based tool, e.g. ping, that queries both
types together) unless --filter-AAAA is given; host.containers.internal
additionally needed to be served via a plain --addn-hosts file rather
than dnsmasq's own --address=/name/ip option, which stayed REFUSED for
AAAA even with --filter-AAAA.
Best-effort throughout: gated on dnsmasq actually being found in PATH,
with a new --no-dns opt-out. Verified end-to-end both on this dev
machine and on the real Android target device: two containers on a
shared network resolve each other (including self-resolution) and can
ping by name; a container joined to both an intern and an extern
network resolves both its intern peer and host.containers.internal
simultaneously (the specific scenario the per-network-instance design
would have broken).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz