Add OCI image support#191
Conversation
Leveraging existing Go-based tools and packages is a great step toward full OCI image support. I agree with this change. I suggest repositioning the In other words, |
c18f864 to
ed17166
Compare
Runtime file injection de-symlinked only the /etc directory itself; the
per-file os.WriteFile still followed an image-shipped symlink at
etc/{hostname,hosts,resolv.conf}, letting a malicious image redirect
the write outside the rootfs. Named --user resolution had the same
flaw on the read side: a symlinked etc/passwd or etc/group made
lookupPasswd/lookupGroup read host account files.
Route both through os.OpenRoot (the same containment the layer
unpacker already uses): injection unlinks the existing entry and
recreates it O_EXCL, and passwd/group opens are rootfs-bounded.
Regression tests pin both behaviors.
Reported by cubic review on PR sysprog21#191.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mode finalization ran only when an entry carried setuid/setgid/sticky bits, but the creation modes passed to os.Root.OpenFile and MkdirAll are masked by the process umask: under e.g. umask 0077 a layer's 0755/0644 entries unpacked as 0700/0600 and were never corrected. Chmod every created file and directory entry to its exact tar mode (applyMode), and split the ensure-parent path out (ensureParent) so finalizing an entry cannot reset the mode of an already-unpacked parent directory to the 0755 default. Regression test unpacks under umask 0077 and checks exact modes, including a 0700 parent that a later child entry must not widen. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both run paths infer "already unpacked" from the rootfs path existing (csrun.go and the plain-directory path in commands.go), but unpackImage created the destination before applying layers and left it behind on failure. A run after a failed unpack therefore skipped the unpack and executed against the truncated tree. Delete the destination on unpack failure. The cleanup applies only when unpackImage created the directory itself, so an explicit pre-existing `unpack --rootfs DIR` target is never removed. Regression test drives a two-layer image whose second layer fails and checks both the cleanup and the pre-existing-directory guard. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pin/rmi updated refs.json with an unlocked load-modify-save cycle and a fixed temp filename, so two concurrent pulls (or a pull racing an rmi) could clobber each other's temp file and drop a just-recorded pin -- a later unpack/run then reports the image as not pulled even though its pull succeeded. index.json has the same read-modify-write shape inside the layout package. Add an exclusive flock on <store>/.lock held across pin's cycle, addImage's check-append-pin, and rmi's whole resolve-modify-GC sequence, and give savePins a unique temp name. A 16-writer concurrent-pin regression test asserts no entry is lost. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cmdRun treated every s.image failure as "not pulled" and fell into the auto-pull path, so a corrupt refs.json or unreadable layout triggered a surprise network pull instead of reporting the store problem. Introduce an errNotPulled sentinel wrapped by digestFor and resolvePinnedTarget (user-facing message text is unchanged) and gate the auto-pull on errors.Is. A regression test pins the two error kinds apart. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two GC robustness fixes: DirEntry.Info() returning ENOENT (a blob reclaimed by a concurrent rmi/prune between ReadDir and Info) aborted the whole GC pass; skip the vanished entry instead, matching the IsNotExist tolerance already used elsewhere in gc.go. A last-pin rmi committed the pin removal to refs.json before removing the manifest descriptor from index.json. If descriptor removal then failed, the image was stranded: no ref resolves to it, the descriptor keeps every blob live, and prune never removes descriptors. Reorder the writes so the same failure window leaves a stale pin over a removed descriptor, which a retried rmi resolves and completes (RemoveDescriptors is a filter; re-removal is a no-op). Regression test forces the descriptor write to fail and checks the pin survives and the retry finishes. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pruneCSBundle ran the crash-recovery sweep -- including an unconditional hdiutil detach -force of any attached volume -- before checking whether the digest was still pinned, so a non---all `prune --cache` could rip the rootfs out from under an active run (the sweep cannot tell a crashed leftover mount from a live one by mount state alone). Two guards fix this. The live[key] pin check now runs before the sweep, so a plain prune never touches a pinned bundle; a crashed pinned bundle is recovered by the next run's provision or by --all. And sweepCSBundle now reports a volume busy instead of detaching when a run-<pid> clone of a live process remains inside it, protecting the --all and legacy/unpinned paths too. The gated darwin round-trip now covers the busy path, and folds in two review fixes of its own: the orphan clone uses a never-assignable pid (999999999 > kern.maxpid) instead of a reaped pid that the OS could reuse mid-test, and a t.Cleanup force-detach keeps failed runs from leaking an attached volume. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
clearDir followed a symlink at the mount-point path, so pre-attach cleanup of a corrupt or tampered store could empty a directory outside the OCI cache. Reject a symlinked mount dir with an error instead. Also drop csMount.imagePath: it was set but never read in production (every consumer derives <bundle>/rootfs.sparsebundle itself), so the field was state to maintain with no behavior behind it. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
runMainSubprocess read the stdout pipe to EOF before touching the stderr pipe -- the sequential-read pattern the os/exec docs warn can deadlock once the unread stream fills its ~64KB buffer. Hand both streams to exec.Cmd as bytes.Buffers instead, which the package drains concurrently; less code and no deadlock potential as outputs grow. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The jq expression comparing registry truth against the store pin returned every manifest matching os/arch, so a manifest list with two matching entries (several variants, or a future extra descriptor) produced a multi-line string and failed the equality check on a valid image. Wrap the selection in first(...) and exclude BuildKit attestation manifests, mirroring what crane.Pull(WithPlatform) resolves. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prune summary presented one number as a uniform on-disk-allocation figure, but blob bytes come from logical file sizes while cache-dir bytes come from st_blocks allocation. Say so in the pruneReport doc and mark the user-facing total approximate rather than pretending to a single metric. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v1.18.7 fixes an out-of-bounds read in s2.NewDict. The dependency is indirect (via go-containerregistry) and nothing here imports the s2 package, so there is no reachable exposure -- this is dependency hygiene while the report is fresh. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every pre-launch failure path unlinks a FUSE-materialized temporary ELF (elf_host_temp) before returning; the --env/--clear-env OOM branch was the lone omission and leaked the temp file on disk. Mirror the sibling paths. Reported by cubic review on PR sysprog21#191. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
8085731 to
d58ae6f
Compare
|
Root cause: Since elfuse remaps uid/gid and does not implement setuid-exec semantics, |
1106e14 to
0d33c1e
Compare
Alternatively, name it |
Thanks for testing and reporting the bug. I will address the issue by emitting an error message such that we are not silently dropping special bits without the user knowing about it. |
Renamed accordingly and updated the PR description. |
elfuse_launch owns guest bring-up: guest_bootstrap_prepare, the FUSE-temp unlink, the sysroot casefold probe, vCPU creation, GDB init/sync/wait, the run loop, gdb_stub_shutdown, the shim counter and syscall histogram dumps, and guest_destroy. main() retains the original CLI argv (proctitle rewriting), option parsing, sysroot provisioning, the shebang loop, the --gdb x86_64 guard, host cwd, and the heap resource cleanup, and now hands off through launch_args_t so other launchers (the OCI run helper) can share one bring-up path. The launch_args_t envp field generalizes the old hard-coded environ: NULL keeps the host environment, so main()'s behavior is unchanged. Bring-up failures unwind through a single fail label instead of repeating the guest_destroy-plus-unlink tail at every error site. Ownership of the FUSE-materialized temp ELF moves with the bring-up: elfuse_launch owns the unlink from the prepare call onward (teardown and the post-prepare error paths), and main() drops its claim before handing off, so main's shared goto unwind cannot double-unlink a path whose ownership has been transferred. The embedded shim blob include moves along with its only consumer, so shim_bin has a single object definition site.
An OCI image front end needs to set the guest identity, working directory, and environment without patching the runtime; the new flags map onto launch_args_t fields and `elfuse-oci run` drives exactly this interface. The --user identity is staged before bring-up (proc_set_initial_ids) so the auxv AT_UID/AT_GID snapshot taken by build_linux_stack matches what getuid()/getgid() later report. --workdir rejects non-absolute paths up front instead of silently resolving them against the host cwd, and is applied by elfuse_launch after the casefold probe so the translation sees the sysroot's real case behavior. --env/--clear-env build the guest environment with env(1) semantics: KEY=VAL sets, bare KEY inherits from the host environ, --clear-env starts empty; with neither flag given envp stays NULL and the host environ is used unchanged. The new heap resources join main()'s shared goto unwind: envp, workdir, and the raw --env override array are released at the single cleanup label on every exit path.
elfuse-oci is a standalone Go binary that owns the OCI image pipeline; elfuse itself stays a pure Linux syscall-to-Darwin runtime with no OCI commands. This first slice is the acquisition half: an OCI image-layout store plus the pull and inspect commands, built on go-containerregistry ($ELFUSE_OCI_STORE or ~/.local/share/elfuse/oci by default). The store is a spec-shape image layout other tools can read, with a refs.json pin table mapping references to manifest digests. An exclusive flock serializes refs.json/index.json updates so concurrent pulls cannot lose pins, pin persistence syncs the temp file and directory around the rename, and a nil-object refs.json is rejected as corrupt instead of treated as empty. addImage distinguishes genuinely-absent from unreadable descriptors by scanning index membership, so store corruption surfaces rather than duplicating entries. digestFor returns a distinct errNotPulled for a missing ref so later callers can tell "not pulled" from "store broken". Two helpers keep the plumbing in one place: every subcommand opens the store through commonFlags.openResolvedStore (resolve the store path, then open the layout), and store.withLock scopes lock-held sections; pin and addImage wrap their load-modify-save cycles in it so a critical section cannot leak its lock on an error path. pull resolves the requested platform (default linux/arm64) and validates --platform shape up front; inspect prints the manifest and config summary (or --json) and propagates digest/size and writer errors instead of reporting partial output as success. Tests cover the pin table, store locking, error kinds, flag parsing, and the command dispatch, with cranePull as a swappable seam so no test touches the network.
4dbe1dd to
b2484ce
Compare
Addressed. Also added 5 workload images listed in #224 to the CI as a test. Will make the CI tasks simpler for now, as the Go test currently failed due to it hitting elfuse's fixed thread-table cap, and the python test triggers an unhandled guest CPU exception in elfuse. |
Apply a stored image's layers into a rootfs directory, whiteouts and all, so `unpack` (and later `run`) can materialize a filesystem tree from the store without any external tool. Layer application is hardened against hostile archives: extraction is bounded by os.Root so no entry, symlink, or hardlink escapes the destination; parent components are Lstat'd and a symlinked or non-dir intermediate is replaced with a real directory (containerd/Docker behavior); opaque whiteouts clear through a real directory only, are order-independent within a layer, and an invalid bare .wh. entry fails extraction instead of deleting its parent; a directory entry replaces a lower-layer non-directory. File bodies are bounded by the tar header size and short bodies are an error, and permissions are finalized with an explicit chmod so the host umask cannot skew modes. setuid/setgid/sticky bits are re-applied where the host allows it, and degrade gracefully where it does not. An unprivileged chmod that sets setuid/setgid is rejected with EPERM on macOS when the unpacked file's inherited group is one the invoking user is not in (a new file takes its parent directory's group under BSD semantics, e.g. wheel under /tmp, not the tar's root/shadow owner). Since the rootfs is owned by the invoking user those bits could not be honored at runtime there anyway, so they are dropped with a warning naming the lost bit rather than aborting the whole unpack, which is what lets Debian-family images and their shadow suite (chage, passwd, ...) unpack at all. Reported at sysprog21#191 (comment) Unpack stages into a temp sibling directory and renames into the final cache path (keyed by manifest digest under <store>/rootfs/), so a concurrent reader never observes a partial tree and the loser of a rename race adopts the winner's complete one. A failed unpack removes only what it created. Tests drive whiteouts, opaque ordering, parent-symlink replacement, hardlink identity, exact-size reads, mode preservation, the special- bit degrade decision, and the staged-rename semantics over synthetic layer tarballs.
run is the last pipeline stage: resolve the image config into a
concrete runspec, materialize the rootfs, and exec the existing
`elfuse --sysroot <rootfs> ...` positional launch path, reusing
elfuse's HVF bring-up, shebang, and dynamic-linker plumbing rather
than reinventing guest launch. elfuse is located as a sibling binary
($ELFUSE_BIN overrides for tests) and replaced via exec so the shell
reaps the same pid and terminal signals reach the guest directly.
The runspec resolves Entrypoint/Cmd/Env/User/WorkingDir with the
usual precedence (--entrypoint drops image Cmd; --env and --clear-env
follow env(1) semantics; --workdir must be guest-absolute). A PATH is
guaranteed: when neither the image config nor --env supplies one,
Docker's conventional default is appended, so a guest whose image
omits PATH still has a search path after the --clear-env launch. A
symbolic --user or image User is resolved against the image's own
/etc/passwd and /etc/group through os.Root-bounded, no-follow opens,
so a crafted rootfs cannot redirect resolution to host account files.
run auto-pulls only when the ref is genuinely absent (errNotPulled)
and surfaces store corruption instead of masking it behind a network
pull; an explicit --platform must match the pinned image so a ref
pulled for another architecture is not silently launched. Before
exec, host-truth /etc/{resolv.conf,hosts,hostname} are injected into
the rootfs through the same os.Root bounds.
Tests cover runspec resolution and precedence, symbolic user lookup,
runtime-file injection, and the exec argv shape via the
execElfuseForRun seam and a subprocess exec probe.
A plain directory rootfs on the default case-insensitive APFS volume folds Linux filenames that differ only by case. Default runs now use a case-sensitive APFS sparsebundle per pinned manifest digest, with a per-run clonefile COW rootfs so guest writes never mutate the warm base tree and repeated runs skip the unpack. Liveness and lifecycle transitions are decided by per-digest advisory flocks in the bundle directory (bundlelock.go), not by pids or directory scans: every live run holds run.lock shared from before the volume is attached until guest exit, so a killed run cannot leak liveness, and attach.lock serializes provisioning (always acquired before run.lock, making the exclusive-to-shared downgrade in provision race-free). Provisioning reuses a mount a live run still holds and only force-detaches one proven stale by an exclusive run.lock probe, so a second run of the same digest can never rip the rootfs out from under a live guest. --keep clones carry an .elfuse-keep marker so sweeps preserve them. The plain-rootfs path remains available with --plain-rootfs, and the non-Darwin stub keeps elfuse-oci buildable for pull/inspect/unpack tests on Linux. Darwin tests drive runCaseSensitive through seams for provisioning, clonefile, spawn, cleanup, and exit, and cover sparsebundle provisioning, mount/detach, attach-failure teardown, and the lock protocol with a fake hdiutil; the mount-probe and force-detach hooks are function variables so tests need no real disk images. The spawn path intercepts SIGHUP alongside INT/TERM/QUIT and installs the handler before the child starts, so a hangup or an early signal still flows through the forward/reap/teardown path. elfuse is invoked with a "--" separator before the guest command, so an image Entrypoint beginning with "-" cannot steer the host launcher. hdiutil attach failures keep stderr in the error message (stdout stays clean for the plist parse), and the parsed mount path is XML-entity-decoded so a store path containing "&" or quotes still round-trips.
Add list/images, rmi, and prune on top of the OCI image-layout store. rmi and prune use reachability GC: shared manifests, configs, and layers stay on disk while any remaining ref reaches them. Cache cleanup handles plain rootfs caches and macOS sparsebundle caches through platform-specific seams, with safety rules for active workloads: a prune without --all never touches a still-pinned digest's cache, and a cache a live run still uses is never reclaimed. Liveness is the same flock discipline on both cache kinds: the bundle's run.lock, and for the plain digest-keyed rootfs a sibling <hex>.lock that a run holds shared from before the existence probe until guest exit. The plain run path execs elfuse in place, so the lock descriptor is made exec-survivable: the kernel releases it exactly when the elfuse process exits, SIGKILL included. prune takes each lock exclusively non-blocking and skips busy caches (dry runs never advertise them); rmi refuses a busy cache even with --force, before any pin or descriptor is touched. The lock is a sibling of the cache dir, not inside it, because the dir is the guest's / and its existence is unpackImage's atomic-rename publication signal. prune's GC sweep and list's snapshot run under the store lock, closing the windows where a concurrent pull's fresh blobs could be reclaimed before their descriptor lands or a listing could observe a half-removed ref. prune scopes its GC-plus-cache sweep with store.withLock and drops the lock before the reporting output; list and rmi keep the explicit acquire-and-defer because their entire body is the critical section, so a closure would only add indentation. list reports the full os/arch/variant platform. Tests cover reachability GC (shared blobs, stale temp blobs, digest prefixes), the prune and rmi busy semantics for both cache kinds (live holder, stale lock file, dry-run visibility), lock survival across the exec boundary (FD_CLOEXEC clearing plus a child-process flock-lifetime proxy), which runs hold the lock at exec time, and end-to-end command wrappers over pull/run/list/rmi/prune. The darwin cache sweep is driven through the isMountPointFn and detachForce seams with reapSweepableClones deciding the reap set, so the sweep is testable without real disk images.
The on-disk store is the contract: pulls must produce a valid OCI image-layout that other tools can read and that agrees with registry truth on the manifest digest. Conformance tests pin the layout shape, and scripts/oci-interop.sh cross-checks the store with crane, skopeo, and umoci. CI splits by runner capability: a Linux job runs the pure-Go store paths (pull, unpack, inspect, lifecycle) without Hypervisor.framework, a hosted macOS job builds and tests the darwin sparsebundle code and drives a run-less pull/inspect/list/rmi/prune lifecycle smoke, and the self-hosted release leg boots guests end to end under HVF: the alpine:3 default-entrypoint smoke plus a full pull -> inspect -> list -> run -> rmi -> prune lifecycle that runs python:3.12-slim with an --entrypoint override and asserts the teardown half of the lifecycle. The lifecycle teardown covers all three reclamation guardrails: a plain rmi reclaims the cold cache with the image, run --keep output refuses rmi without --force, and a live --plain-rootfs guest parked in sleep pins its cache; prune --cache --all must skip it and rmi must refuse until the guest exits, the only end-to-end exercise of the run-lock descriptor riding through the exec into elfuse.
Cover the two-binary model in README and docs: usage.md documents the elfuse-oci commands and flags, testing.md the offline/CI validation split, internals.md the host-literal path fallback semantics, and oci-design.md records the design rationale (the C/Go boundary, the image-layout store with its refs.json pin table, layer application, run paths, and lifecycle GC), plus an explicit scope-and-limitations accounting of which OCI features are and are not implemented. oci-design.md also records the concurrency model: store metadata is lock-serialized, per-digest sparsebundle state is coordinated by the attach.lock/run.lock pair, and the plain digest-keyed rootfs cache is guarded by a sibling per-digest lock a run holds across the exec into elfuse, so prune skips and rmi refuses a cache a live guest still uses. usage.md notes the resolv.conf fallback nameserver and its split-DNS implication. README states the positioning up front: OCI images are consumed as a distribution vehicle for Linux root filesystems, replacing hand-built --sysroot trees, and the non-isolation limitations (host-path fallback, shared network identity, PID space, and clock) are called out explicitly rather than implied.
Issue sysprog21#224 profiled five real images (python:3.12-slim, node:22-alpine, golang:1.23-alpine, eclipse-temurin:21, gcc:14). Add one CI job per image that boots the image under HVF via `elfuse-oci run` and drives that image's operations, so a change that breaks any of them is caught on the PR rather than by hand. A shared driver, scripts/ci/oci-workload.sh <key>, maps each key to its image and guest workload under scripts/ci/workloads/ and asserts a per-image sentinel token: - python: a single-threaded SQLite insert plus an aggregate query, a small file write/read/checksum fan-out, and a JSON round-trip. - node: in-guest compute (fs/crypto/zlib/JSON) plus an HTTP server the job reaches over the host loopback; elfuse maps guest sockets to host sockets and does no network-namespace isolation, so a guest bound to 127.0.0.1 is reachable host-side. - go: go version and a gofmt of a canonical file, with GOMAXPROCS pinned low. - jvm: javac + java exercising collections, file I/O, SHA-256, an 8-thread pool, and a subprocess. - c: a small multi-file make project plus a heavier single translation unit compiled with gcc -O1. The go and python lanes are kept deliberately light so they stay within the runtime's current limits; heavier variants that stress those limits are submitted separately. The jobs run only on self-hosted Apple Silicon because `run` needs Hypervisor.framework. They share a composite action that fetches the elfuse binary from build-macos and builds elfuse-oci, and each keeps a warm per-key store on the runner's persistent disk so only the first run pulls over the network. gcc:14 and eclipse-temurin:21 ship the shadow suite, so those jobs also exercise the unpack setuid/setgid degrade end to end. The existing python workload moves under scripts/ci/workloads/; oci-lifecycle.sh follows the new path.
Prior work: #34
Tracking issue: #31
We introduce
elfuse-oci, a standalone Go companion binary that owns the OCI image pipeline:pull,unpack,inspect,run,list(aliasimages),rmi, andprune, backed by a real OCI image-layout store. elfuse itself stays a pure Linux syscall-to-Darwin runtime with no OCI awareness; the two binaries meet only at the existingelfuse --sysroot <rootfs> <entrypoint> <args>launch path.OCI images are used here strictly as a distribution format for Linux root filesystems — a reproducible replacement for hand-built
--sysroottrees. This is not a container runtime. There is no isolation layer: the guest shares the host network identity, PID space, and clock, and unresolved guest paths fall back to host truth. A workload that needs namespaces, cgroups, port mapping,execinto a running container, or a daemon needs a real container runtime, not an ELF personality.Why a separate Go binary
The OCI ecosystem is a Go ecosystem. Rather than grow the C runtime with an image pipeline, the acquisition/lifecycle half is ~4.4k lines of Go (plus ~7.3k lines of tests) built on
github.com/google/go-containerregistry, and validated for on-disk conformance againstcrane,skopeo, andumoci. Keeping it out of the C tree keeps both sides small and lets each lean on its native tooling.Design
docs/oci-design.mdis the source of truth: the C/Go boundary, the image-layout store and itsrefs.jsonpin table, hardened layer application, therunpaths, the concurrency/locking model, and an explicit accounting of which OCI features are and are not implemented.docs/usage.mdcovers the commands;docs/testing.mdthe offline/CI split;docs/internals.mdthe host-literal path fallback.Highlights:
os.Root-bounded extraction (no symlink/hardlink escape), correct whiteout/opaque handling, staged temp-dir + atomic rename so readers never see a partial tree.env(1)/Docker precedence, guarantees a PATH, resolves symbolic--useragainst the image's own/etc/passwd+/etc/group(no-follow), injects host/etc/{resolv.conf,hosts,hostname}, then execs elfuse in place so signals and the pid pass through.attach.lock/run.lock), not pids.--plain-rootfsremains available.rmi/pruneuse reachability GC (shared blobs survive while any ref reaches them) and never reclaim a cache a live run still holds — the run-lock rides through the exec into elfuse and releases exactly on guest exit.Try it
Images are stored under
$ELFUSE_OCI_STORE, or~/.local/share/elfuse/ociby default.CI
Split by runner capability
crane/skopeo/umoci) with no Hypervisor.frameworkSummary by cubic
Adds OCI image support with a new Go CLI,
elfuse-oci, covering pull, unpack, inspect, list, run, remove, and prune on a real OCI image‑layout store. Refactors guest bring‑up into sharedelfuse_launchand adds--user,--workdir, and--envfor precise runtime control; default macOS runs use case‑sensitive APFS sparsebundles with per‑run COW clones.New Features
elfuse_launch;--user,--workdir,--env/--clear-env; initial IDs staged so auxv AT_UID/GID match; forwards INT/TERM/QUIT/HUP; uses--before the guest argv and execselfuse --sysroot; host/etc/{resolv.conf,hosts,hostname}injected; envp generalized so the host environ is optional.elfuse-oci(ongithub.com/google/go-containerregistry) defaults tolinux/arm64with--platform; addspull,unpack(hardened apply with whiteouts/opaque and staged rename),inspect(--json),list(--json),run,rmi, andprune(--cache,--all,--dry-run). Pins refs by digest with store locking and reachability GC. macOS uses sparsebundles with per‑run COW clones and attach/run locks; stale mounts are detached and.elfuse-keepclones are preserved. Plain rootfs remains available (--plain-rootfs) with a sibling per‑digest lock made exec‑survivable.rmi/pruneskip busy caches, refuse live ones, and honor--keep; default PATH and symbolic--userresolution happen inside the image rootfs.Docs & CI
docs/oci-design.md; expanded usage, testing, and internals (host‑literal fallback, PATH defaults); README and Makefile updated to buildelfuse-oci.crane,skopeo,umoci) on Linux; macOS darwin build, unit tests, sparsebundle round‑trip, and run‑less lifecycle; self‑hosted end‑to‑end HVF runs plus lifecycle teardown guardrails; per‑image workload smokes (python/node/go/jvm/c) that boot under HVF and exercise characteristic operations; shared setup action with fail‑fast guard on superseded PR commits.Written for commit 031824e. Summary will update on new commits.