tar-rs applies a directory's tar mode as soon as the entry is extracted, so images shipping read-only directories abort on their own contents: fedora:latest's dr-xr-xr-x ca-trust tree fails with EACCES on etc/pki/ca-trust/extracted/pem/directory-hash/002c0b4f.0. Directory modes are now recorded and applied once after the last layer is written. Hard links were canonicalized inline, failing with ENOENT when the target appears later in the tar, lives in an earlier layer, or exists nowhere. Links are now applied after the file pass and fall back to a verbatim symlink with a warning when the target is absent, matching Docker/podman behaviour for these entries. Verified against a real fedora:latest layer: extraction previously aborted at the first directory-hash symlink, now all 438 entries land with tls-ca-bundle.pem intact.
ecr - ephemeral chroot
Instantly drop into a disposable Linux environment on your host machine.
No Docker daemon. No VM. No root. Just namespaces.
ecr debian # interactive Debian shell
ecr alpine:3.23 # specific Alpine version
ecr ubuntu:noble # Ubuntu by codename
ecr fedora # any Docker Hub image
How it works
ecr pulls a root filesystem (Alpine/Ubuntu direct from their CDNs; everything else from Docker Hub), extracts it into a temporary directory, and execs a shell inside a user + mount + PID + UTS namespace. The process tree is isolated, the rootfs is discarded on exit, and your host is never touched.
Library
ecr is a cargo workspace: crates/ecr is the library crate (package ecr), crates/ecr-cli the thin command-line front-end. Rust consumers can use the library directly — resolve an image, download it through the content cache, and run commands with a fully caller-controlled environment, bind targets and architecture:
[dependencies]
ecr = { git = "https://github.com/…" } # or a path / registry source
use ecr::{chroot, BindTarget, ExecOptions, PrepareRequest, RootfsCache};
// Download alpine through the cache (~/.cache/ecr) and extract to a scratch dir
let cache = RootfsCache::new(RootfsCache::default_dir()?);
let rootfs = cache.prepare(&PrepareRequest::new("alpine:3.23"))?;
// Caller-composed envp, explicit bind targets, target architecture
let code = rootfs.exec(&ExecOptions {
arch: "amd64".into(), // empty = host arch
binds: vec![BindTarget { // host dir -> in-rootfs mount
source: "./data".into(),
target: "/mnt/data".into(),
read_only: false, // true = overlay (ro)
}],
env: chroot::default_env(rootfs.dir()), // or your own envp
command: vec!["cat".into(), "/etc/os-release".into()],
..ExecOptions::default()
})?;
rootfs.persist()?; // write the rootfs back into the cache (see below)
Provisioned-rootfs caching (hot cells)
Prepare once, provision once, amortize every later run — including runs from other processes sharing the same cache directory:
let rootfs = cache.prepare_provisioned(&PrepareRequest::new("alpine:3.23"), |rootfs| {
// runs at most once per cache entry
rootfs.exec(&ExecOptions {
command: vec!["apk".into(), "add".into(), "curl".into()],
env: chroot::default_env(rootfs.dir()),
..ExecOptions::default()
})?;
Ok(())
})?;
On the first call the pristine image is downloaded, the closure provisions it, and the result is persisted into the cache with a .provisioned marker. On every subsequent call the entry is hit directly: no download, no provisioning. Note a provisioned entry no longer tracks the upstream image; delete the entry (or pass no_cache: true) to re-provision.
Usage
ecr [OPTIONS] <DISTRO[:VERSION]> [-- COMMAND...]
| Argument | Description |
|---|---|
ubuntu, alpine, debian, fedora, … |
Distribution to enter |
:version |
Optional version tag — codename, number, or latest / lts / edge |
-- cmd arg… |
Run a command instead of an interactive shell |
Options
| Flag | Description |
|---|---|
--bind <PATH> |
Overlay-mount a directory read-only inside the chroot (default: current directory at /root/<name>) |
--bind-rw <PATH> |
Bind-mount a directory read-write at /mnt/<name> |
--no-bind |
Skip all directory mounts |
--no-cache |
Force a fresh download, bypassing the cache |
-v, --verbose |
Print diagnostic output (URLs, layer info, extraction steps) |
-a, --arch <ARCH> |
Target architecture (amd64, arm64, armhf, riscv64, …) |
--kernel[=PATH] |
Boot with QEMU system emulation. Downloads Alpine's default kernel if no =PATH given (linux-virt, or linux-lts on riscv64 where the virt flavor is not built) |
-m, --memory <SIZE> |
Memory for QEMU VM (default: 2G, only with --kernel) |
Examples
# Quick shell in the latest Debian
ecr debian
# Run a one-shot command
ecr alpine -- sh -c 'apk add curl && curl -s https://example.com'
# Work on your project inside Ubuntu — changes are visible on the host
ecr ubuntu --bind-rw ~/projects/myapp
# Native-compile for ARM64 through emulation (requires QEMU binfmt_misc)
ecr --arch arm64 alpine -- uname -m
# Always pull a fresh image
ecr --no-cache fedora
# Boot with QEMU system emulation (auto-downloads default kernel)
ecr --kernel alpine
# Boot with your own kernel (note the `=` — a space would parse the path as the distro)
ecr --kernel=/boot/vmlinuz ubuntu
# Boot with custom memory
ecr --kernel --memory 4G alpine
QEMU System Mode
When --kernel is specified, ecr boots the rootfs in a full QEMU virtual machine instead of using namespaces:
# Auto-download Alpine's default kernel (recommended)
ecr --kernel alpine
# Use your own kernel
ecr --kernel=/boot/vmlinuz ubuntu
This mode:
- Creates an uncompressed CPIO initramfs from the rootfs (streamed to disk)
- Boots QEMU with your kernel (or auto-downloads Alpine's default kernel:
linux-virt, falling back tolinux-ltson riscv64 where the virt flavor is not built; the downloaded image is stored decompressed so QEMU can load it) - Provides full VM isolation
- Works for any architecture (no binfmt_misc needed)
- Caches the default kernel in
~/.cache/ecr/
Host bind mounts (--bind, --bind-rw) are not applied in this mode.
Requirements:
qemu-system-<arch>installed- For custom kernels: kernel must have serial console support
- riscv64 VMs need QEMU ≥ 10.1 (
rva23s64CPU, required by Ubuntu 25.10+ RVA23 userland)
Supported distributions
| Name | Source | Version examples |
|---|---|---|
alpine |
Alpine CDN | latest, stable, edge, 3.23, 3.22 |
ubuntu |
Ubuntu CDN | latest, lts, noble, 24.04 |
debian fedora arch gentoo |
Docker Hub | latest, any tag |
| Any Docker Hub / OCI image | Registry | org/image:tag, ghcr.io/…, localhost:5000/… |
Cache
Downloaded images are cached in ~/.cache/ecr/. For latest OCI tags the registry manifest digest is checked on each run — the image is only re-downloaded when it has actually changed. Use --no-cache to force a fresh pull regardless. The library's PreparedRootfs::persist can replace a cache entry with a provisioned rootfs (see Provisioned-rootfs caching above).
Requirements
- Linux kernel ≥ 5.1
- Unprivileged user namespaces enabled (
/proc/sys/kernel/unprivileged_userns_clone= 1 on some distros) newuidmap/newgidmapin$PATH- An entry in
/etc/subuidand/etc/subgidfor your user - For foreign-arch: QEMU user-mode emulation registered with
binfmt_misc
