Describe what downloads are verified against (registry digests for OCI, published checksums for distro CDNs) and the degradation policy; document the ecr::Error variants and the Result alias for library consumers.
7.9 KiB
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. The library never writes to the terminal: diagnostics go through the log crate, and downloads, extraction and initramfs creation report progress through a caller-supplied Progress sink (pass &NoProgress for silence, or implement the trait to render your own UI). Public functions return Result<T, ecr::Error>: match on the Error variants for host-readiness failures (user namespaces disabled, binfmt_misc unregistered, missing subuid/subgid entry, architecture the registry does not serve) — everything else arrives as Error::Other with the full context chain. 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, NoProgress, 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"), &NoProgress)?;
// 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"), &NoProgress, |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) |
The command starts in the directory mapped from your current one: /root/<name> with the default --bind, /mnt/<name> with --bind-rw. With --no-bind (or when the current directory is not mapped) it starts in /root.
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
# (the shell starts in /mnt/myapp, where the project is mounted)
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/. Downloads are verified before they enter the cache: OCI layers and manifests against the registry's content digests, direct distro tarballs against the checksums Alpine and Ubuntu publish next to them (a missing checksum artifact degrades to an unverified download with a warning). 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
