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.
anyhow at the public boundary left library consumers unable to match
on failure modes: every mistake was an opaque error, and the ones
worth branching on (host prerequisites, unsupported architectures)
could only be recognized by string matching.
Public functions now return Result<T, ecr::Error> (thiserror) with
semantic variants for those conditions: user namespaces unavailable,
binfmt_misc unregistered, architecture not served by the registry,
missing subuid/subgid entry. anyhow stays the internal error type;
everything else converts to Error::Other, which transparently wraps
the full context chain. Errors raised inside the namespace child
cross the process boundary as formatted chains and also surface as
Other; the error pipe wraps them in anyhow before formatting so the
chain still survives. The CLI maps the error back into anyhow, so
its output is unchanged.
Downloaded content was hashed nowhere: OCI layer digests were only
used to build blob URLs and distro tarballs were trusted as served,
so a truncated or tampered rootfs went straight into the cache and
from there into execution.
Hash sha256 and sha512 while streaming and verify before the atomic
rename, so a bad download never enters the cache:
- OCI architecture manifests requested by digest are checked against
that digest, since the layer digests read from them are trusted
next; layer blobs are checked against the digest the manifest names.
- Direct distro tarballs are checked against the checksums published
next to them (Alpine: per-file .sha256; Ubuntu: SHA256SUMS in the
release directory). A missing or unfetchable checksum artifact
degrades to an unverified download with a warning, mirroring the
freshness-check policy; a mismatch always fails.
- Digest algorithms other than sha256/sha512 cannot be checked: warn
and accept.
The hostname tests reimplemented the generation algorithm inline, so
they kept passing if the real code drifted; check the actual
generate_hostname path instead, and drop the tautological
check_user_namespace test.
Extract utils::random_suffix and replace the misleading
HOSTNAME_SUFFIX_BITS constant (it held the 2^24 entropy space, not a
bit count, and the suffix length was derived through a log2()/4
round-trip) with HOSTNAME_SUFFIX_LEN. The QEMU VM hostname generator
duplicated the algorithm with different mixing; both callers now share
the helper.
The default --bind overlay mounted the current directory at
/root/<basename>, but the command still started in /root: the
library's working-directory fallback only considers read-write
binds. Pass the mapped target as the explicit working_dir so
relative paths keep meaning what they meant on the host.
Bind sources are canonicalized when the targets are built
instead of at mount time. This fixes --bind . and other
basename-less relative paths, which cli_mount_target rejected,
and lets the --bind/--bind-rw dedup match relative and absolute
spellings of the same directory.
Diagnostics now go through the log facade: the library no longer
decides where output goes, and consumers plug in their own sink (the
CLI installs a stderr logger printing warnings always and ecr debug
diagnostics under -v; dependency records stay filtered out). The
kernel 'quiet' flag moves from the global verbosity flag to an
explicit QemuConfig field.
The CLI owns the terminal now: TermProgress turns the library's
Progress events into bars and spinners on stderr (hidden by indicatif
when stderr is not a terminal). Named transfers (OCI layers, kernel,
tarball) render as byte bars, extraction and initramfs as file-count
spinners.
create_initramfs emits Progress events instead of drawing a spinner
inside the library; the cpio walker counts the entries it visits so
the verbose summary no longer reads the count off a progress bar.
The direct-tarball and OCI-blob download bars drew to stderr from
inside the library; download_image now emits Progress events with the
content length when the server provides one.
The extraction spinner drew straight to stderr from inside the
library, so every consumer got terminal UI it never asked for.
extract_tarball and its helpers now count extracted files themselves
and emit Progress events; rendering belongs to the caller.
Long-running library operations (downloads, extraction, initramfs
creation) will report their progress through a caller-supplied
Progress sink instead of drawing terminal UI themselves. NoProgress
keeps library calls silent for consumers that want no output.
Records in SPEC.md how extract_tarball handles directory modes and
hard links the tar order cannot resolve inline; the constraint behind
the fedora:latest directory-hash extraction fix.
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.
Ubuntu builds its riscv64 port against the RVA23 profile (since
25.10), and QEMU's default rv64 CPU does not implement all profile
extensions: Ubuntu binaries die with SIGILL during init and the
kernel panics with 'Attempted to kill init'. Select the rva23s64
profile CPU (QEMU 10.1+) for riscv64 under TCG; profiles are
supersets, so baseline rv64gc rootfses run unchanged on it.
KVM mode keeps -cpu host.
qemu-system-riscv64's default machine is spike, not virt: spike has no
PCI bus, so virtio-net-pci failed with "No 'PCI' bus found", and no
16550 UART, so console=ttyS0 output went nowhere. Pass -machine virt
explicitly for riscv64; other architectures keep their emulator's
default.
Alpine does not build the linux-virt flavor for riscv64, so --kernel
without a path failed there with 'linux-virt package not found'. Scan
the APKINDEX once for both flavors and prefer linux-virt, taking
linux-lts when the virt flavor is missing.
Also store the cached kernel decompressed: riscv64 and aarch64 ship
their image gzipped (Image.gz), and QEMU's riscv -kernel loader
understands only ELF, uImage and raw images, so the gzipped image hung
at boot after the OpenSBI banner.
execve(2) does not search PATH, so `ecr alpine -- echo hi` failed with
ENOENT: the old code only used PATH as an existence check and then
still exec'd the bare name, which the kernel resolved relative to the
working directory. Pre-dates the crate split, but bare names are the
natural CLI usage so it needs to work.
Programs are now resolved execvp-style: a bare name is looked up in
the caller-composed PATH (empty components skipped rather than treated
as the cwd), paths containing '/' are used as-is with a friendlier
error than a raw ENOENT.
README gains a Library section covering prepare, the exec options
(envp, bind targets, arch) and the provisioned-rootfs hot-cell flow.
SPEC documents the workspace layout, the library API, the cache
sidecars (.digest, .provisioned) and the updated execution flow.
AGENTS.md scope paths follow the new crates/ layout.
Add ecr::rootfs with the full rootfs lifecycle behind the library:
- RootfsCache::prepare resolves an image reference, downloads through
the tarball cache (with the OCI :latest digest freshness check) and
extracts into a scratch directory tracked by PreparedRootfs.
- PreparedRootfs::persist packs the current rootfs back into its cache
entry (compressed to match the entry's extension, symlinks and
permissions preserved) and marks it with a .provisioned sidecar.
- RootfsCache::prepare_provisioned composes both into the hot-cell
flow: provision once, and every later call sharing the cache skips
the download and the provisioning step.
extract: an "oci-" cache entry without a layers.manifest is a
persisted provisioned rootfs; extract it as a plain archive.
The CLI now drives prepare and drops its inline cache/orchestration
code and the dirs/tempfile dependencies. The binfmt check moves ahead
of the download so foreign-arch runs fail before pulling an image.
Add ecr::exec: run a command inside a prepared rootfs in fresh
user/PID/mount/UTS namespaces, with the caller composing the full
environment (ExecOptions::env), the bind targets (BindTarget, with
explicit absolute mount points inside the rootfs) and the target
architecture (binfmt_misc is verified for foreign arches).
mount::setup_mounts now takes &[BindTarget] instead of parallel
read-only/read-write path lists; chroot::run_chroot takes the envp and
a resolved working directory, and chroot::default_env composes the
previous hardcoded environment as a starting point for callers.
The CLI maps its flags onto the new API; behavior is unchanged
(overlay at /root/<basename>, rw bind at /mnt/<basename>, cwd default).
The root package becomes a virtual workspace: `crates/ecr` holds the
library (package name `ecr`) and `crates/ecr-cli` the command line
front-end, which keeps installing the `ecr` binary. No behavior change.
The library must not depend on CLI types, so mount::setup_mounts now
takes the `no_bind` flag instead of a `&Args`.
- hard links in the rootfs were written as zero-size entries with no
inode set (all zero), so the kernel could link them to the wrong file;
assign a synthetic inode per (device, inode) group.
- build the cpio archive by streaming entries to disk instead of holding
the whole archive in memory.
- the VM command was joined into a single string and shell-expanded in
the guest, mangling quotes and nested commands; pass the argv
base64-encoded (ECR_ARGV) and exec it verbatim.
- parse ecr parameters from /proc/cmdline in the init script, set the
hostname via procfs (arch has no hostname binary), and give the serial
console a moment to drain before poweroff.
- --kernel took an optional value greedily, swallowing the DISTRO
positional: 'ecr --kernel alpine' failed with a missing-argument error
and 'ecr --kernel alpine -- cmd' pulled a garbage OCI ref. require
=PATH syntax for the kernel path instead.
- setup_namespaces returns the child's exit code (or 128+signal) instead
of calling process::exit, so the extracted rootfs tempdir is cleaned
up before exiting instead of leaking into /tmp.
- warn that --bind/--bind-rw are ignored when booting with --kernel.
extract_kernel_from_apk fell through to an unconditional error after a
successful extraction, so every first --kernel run failed after
downloading the 45MB package; the second run worked from cache. Also
remove the downloaded APK even when extraction fails.
- Add tests for hostname format and uniqueness
- Add tests for chroot environment setup
- Add tests for environment isolation and PATH configuration
- Verify all 34 tests pass
- Create unified Arch enum in utils.rs with methods for all naming conventions
- Replace 5 duplicate architecture mapping functions with single source of truth
- Add memory string validation for QEMU
- Add KVM capability verification via ioctl
- Fix TOCTOU race in resolv.conf writing using O_EXCL
- Extract magic numbers to named constants
- Use constants for stack size, buffer sizes, hostname entropy
Add an init script to the initramfs that traps shell exit and triggers
poweroff via /proc/sysrq-trigger. This ensures QEMU terminates cleanly
when the user exits the shell, rather than hanging indefinitely.
Changes:
- Add /init script to initramfs that runs as PID 1
- Use -no-reboot QEMU flag to exit on guest poweroff
- Simplify kernel command line using environment variables
- Init script handles hostname, device creation, and poweroff on exit
- Move detect_shell() to new src/utils.rs for reuse
- Use detected shell in QEMU VM mode (bash when available)
- Update chroot.rs and qemu_vm.rs to use shared function
Generate hostname like "ecr-vm-<hex>" at boot time via kernel command
line, leaving initramfs cacheable. Set via echo to /etc/hostname and
hostname command before spawning shell.