Compare commits

..
33 Commits
Author SHA1 Message Date
vhaudiquet f36897abc2 docs: document the riscv64 RVA23 profile CPU requirement
CI / Check (push) Successful in 1m8s
CI / Format (push) Successful in 15s
CI / Clippy (push) Successful in 1m9s
CI / Test (push) Successful in 1m23s
2026-09-21 01:24:11 +02:00
vhaudiquet ba90e0f367 fix(qemu): select the rva23s64 CPU for riscv64 under TCG
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.
2026-09-21 01:24:11 +02:00
vhaudiquet 12f771d326 docs: document riscv64 kernel flavor and virt machine 2026-09-21 01:03:21 +02:00
vhaudiquet 810bf50814 fix(qemu): select the virt machine for riscv64
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.
2026-09-21 01:03:21 +02:00
vhaudiquet 5dd9dff76a fix(kernel): fall back to linux-lts where linux-virt is absent
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.
2026-09-21 01:03:18 +02:00
vhaudiquet de507682c1 fix(chroot): resolve bare command names through PATH before exec
CI / Check (push) Successful in 1m11s
CI / Format (push) Successful in 15s
CI / Clippy (push) Successful in 1m8s
CI / Test (push) Successful in 1m23s
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.
2026-09-21 00:16:25 +02:00
vhaudiquet af06264e60 docs: document library crate, exec and rootfs cache apis
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.
2026-09-21 00:08:49 +02:00
vhaudiquet b6ddd85525 feat(rootfs): cache-aware rootfs preparation with persist hook
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.
2026-09-21 00:06:20 +02:00
vhaudiquet 4f669fb5ec feat(exec): exec API with caller envp, bind targets and arch
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).
2026-09-20 23:34:54 +02:00
vhaudiquet b6e5b4f006 refactor: split ecr into library and cli crates
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`.
2026-09-20 23:29:03 +02:00
vhaudiquet 7137aa15c5 docs: add AGENTS.md with commit and code conventions 2026-09-20 23:08:28 +02:00
vhaudiquet 2c47a5c662 docs: update QEMU mode docs for =PATH syntax and uncompressed initramfs
CI / Check (push) Successful in 1m7s
CI / Format (push) Successful in 14s
CI / Clippy (push) Successful in 1m8s
CI / Test (push) Successful in 1m24s
2026-09-20 22:37:33 +02:00
vhaudiquet 240a66c532 fix: initramfs hard links, streaming archive, verbatim argv transport
- 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.
2026-09-20 22:37:33 +02:00
vhaudiquet d64da0671b fix: --kernel =PATH syntax, exit-code cleanup, bind warning in VM mode
- --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.
2026-09-20 22:37:11 +02:00
vhaudiquet ef00776414 fix: return Ok after extracting kernel from APK
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.
2026-09-20 22:36:49 +02:00
vhaudiquet e1d69eaed6 fix: make kernel verbose with --kernel -v 2026-06-19 16:51:24 +02:00
vhaudiquet 3188566b6e feat: download alpine linux-virt kernel for single --kernel 2026-06-19 16:46:23 +02:00
vhaudiquet 1d2031b3ca fix: fmt 2026-06-17 17:42:56 +02:00
vhaudiquet 49343e5811 fix: clippy 2026-06-17 17:42:41 +02:00
vhaudiquet f3aec10618 test: add unit tests for namespace and chroot modules
- 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
2026-06-17 17:28:50 +02:00
vhaudiquet 5834630d60 refactor: multiple fixes
- 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
2026-06-17 17:24:41 +02:00
vhaudiquet 6bd6f2cf77 feat: enable kvm with --kernel if relevant 2026-06-17 16:42:34 +02:00
vhaudiquet 09661ec9e0 feat: use uncompressed initramfs 2026-06-17 16:35:28 +02:00
vhaudiquet 503578d648 feat: terminate when init process exits (--kernel)
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
2026-06-17 14:03:04 +02:00
vhaudiquet 7a37f99030 feat: detect shell for --kernel
- 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
2026-06-17 11:13:05 +02:00
vhaudiquet d52310c0f4 feat: set unique hostname in QEMU VM mode
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.
2026-06-17 10:52:00 +02:00
vhaudiquet b3ffa89faa fix: handle hard links in initramfs creation
- Track inodes to avoid duplicating data for hard-linked files
- Use fast compression level for faster initramfs creation
2026-06-17 10:40:10 +02:00
vhaudiquet 3e3af3dab8 feat: add progressbar for initramfs compression 2026-06-17 10:11:04 +02:00
vhaudiquet 4475dff141 feat: add progressbar for extraction and initramfs creation 2026-06-17 00:11:12 +02:00
vhaudiquet a81e699619 fix: enable job control for shell with --kernel 2026-06-16 23:38:09 +02:00
vhaudiquet 8875bcc92a feat: do not display kernel logs with --kernel 2026-06-16 22:07:28 +02:00
vhaudiquet 931a6dcfd5 feat: use cpio crate for initramfs creation
Add the `cpio` crate as dependency, removing e2fsprogs external dependency.
2026-06-16 20:31:25 +02:00
vhaudiquet 4f44af4449 feat: add --kernel flag for QEMU system emulation mode
Add --kernel <PATH> option to boot extracted rootfs in a QEMU virtual
machine instead of namespace/chroot mode. The rootfs is converted to an
ext4 disk image using mke2fs and booted with the provided kernel.
2026-06-16 19:16:34 +02:00
26 changed files with 3691 additions and 681 deletions
+89
View File
@@ -0,0 +1,89 @@
# AGENTS.md
Conventions for working in this tree. They apply to every commit; the
whole history follows them.
## Before every commit
Run, in order, and make sure they are clean before committing:
```sh
cargo fmt --all
cargo clippy --all-targets # no new warnings
cargo test # keep green when touching behavior
```
These mirror CI (`.github/workflows/ci.yml` runs check, `fmt --check`,
clippy, and `cargo test --all-features`). `cargo fmt` may amend files
you did not touch — include those changes in the commit (or in a
separate `chore:` commit) rather than leaving the tree dirty.
## Commit messages
Follow Conventional Commits with a component scope:
```
<type>(<scope>): <short summary>
```
Rules:
- Types: `feat`, `fix`, `refactor`, `perf`, `test`, `docs`, `build`,
`ci`, `chore`.
- Scopes match the component touched — the module name under
`crates/ecr/src/` (library) or `crates/ecr-cli/src/` (CLI):
- `cli` — entry point and flags (`crates/ecr-cli/src/main.rs`,
`crates/ecr-cli/src/cli.rs`)
- `rootfs` — cache-aware preparation and persistence
(`crates/ecr/src/rootfs.rs`)
- `exec` — namespace-mode execution API (`crates/ecr/src/exec.rs`)
- `config` — config file (`crates/ecr/src/config.rs`)
- `distro` — distro definitions and mirrors (`crates/ecr/src/distro.rs`)
- `download` — image download (`crates/ecr/src/download.rs`)
- `extract` — rootfs extraction (`crates/ecr/src/extract.rs`)
- `chroot` — chroot setup (`crates/ecr/src/chroot.rs`)
- `namespace` — Linux namespaces (`crates/ecr/src/namespace.rs`)
- `mount` — bind mounts and mount table (`crates/ecr/src/mount.rs`)
- `kernel` — kernel/initramfs handling for `--kernel`
(`crates/ecr/src/kernel.rs`)
- `qemu` — QEMU VM mode (`crates/ecr/src/qemu.rs`,
`crates/ecr/src/qemu_vm.rs`)
- `utils`, `verbose` — shared helpers (`crates/ecr/src/utils.rs`,
`crates/ecr/src/verbose.rs`)
- `deps` — dependency additions/bumps (manifests, lockfile)
- Omit the scope entirely for repo-wide changes that do not belong to a
single component (README.md, SPEC.md, root config).
- Summary: imperative mood ("add", never "added" or "adds"), lowercase
first letter, no trailing period, max ~72 characters.
- Body (optional): separated by a blank line, wrapped at 72 columns;
explain why rather than what. Reference issues as `#123`.
- A commit touching several components should be split into one commit
per component when practical; otherwise use the dominant scope.
- **No merge commits.** Integrate work by rebasing onto the target
branch (`git rebase`, `git pull --rebase`, `git cherry-pick`); the
history stays linear. When several work streams run in parallel, land
them one rebase at a time.
### Examples
```
feat(namespace): set unique hostname per run
fix(extract): handle hard links in rootfs archives
fix(kernel): parse =PATH syntax for --kernel
test(chroot): cover path resolution helpers
feat(qemu): enable KVM when available
deps: bump nix to 0.31
docs: document QEMU system mode and =PATH syntax
chore: apply cargo fmt
```
## Code
- Tests live inline as `#[cfg(test)]` modules at the bottom of each
`src/` module; there is no `tests/` directory. Scope test commits to
the module under test.
- Comments state constraints the code cannot show; no narration.
- Anything user-facing (CLI flags, config keys, error text users act
on) is reflected in `README.md` before commit.
- Design decisions that outlive the session go to `SPEC.md`, not to
new docs or repos.
Generated
+48 -1
View File
@@ -243,6 +243,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpio"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "938e716cb1ade5d6c8f959c13a7248b889c07491fc7e41167c3afe20f8f0de1e"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -295,7 +301,8 @@ name = "ecr"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"base64",
"cpio",
"dirs",
"flate2",
"futures-util",
@@ -310,10 +317,26 @@ dependencies = [
"tempfile",
"tokio",
"users",
"which",
"xz2",
"zstd",
]
[[package]]
name = "ecr-cli"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"ecr",
]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "encode_unicode"
version = "1.0.0"
@@ -329,6 +352,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1997,6 +2026,18 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "which"
version = "7.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
dependencies = [
"either",
"env_home",
"rustix",
"winsafe",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -2197,6 +2238,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
+5 -38
View File
@@ -1,47 +1,14 @@
[package]
name = "ecr"
[workspace]
resolver = "2"
members = ["crates/ecr", "crates/ecr-cli"]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.77"
description = "Enter chroot environments with Linux namespaces"
license = "MIT"
authors = ["Valentin Haudiquet"]
[dependencies]
# CLI parsing
clap = { version = "4", features = ["derive", "env"] }
# Config parsing
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
# HTTP downloads
reqwest = { version = "0.13", features = ["blocking", "stream"] }
# Tarball extraction
tar = "0.4"
flate2 = "1"
xz2 = "0.1"
zstd = "0.13"
# Unix syscall bindings
nix = { version = "0.31", features = ["fs", "mount", "sched", "signal", "user", "process", "hostname"] }
# Temp directories
tempfile = "3"
# Error handling
anyhow = "1"
# Utilities
dirs = "6"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] }
futures-util = "0.3"
indicatif = "0.18"
serde_json = "1"
libc = "0.2"
users = "0.11"
[profile.release]
strip = true
opt-level = "z"
+88 -1
View File
@@ -16,6 +16,56 @@ ecr fedora # any Docker Hub image
`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:
```toml
[dependencies]
ecr = { git = "https://github.com/…" } # or a path / registry source
```
```rust
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:
```rust
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
```
@@ -38,6 +88,8 @@ ecr [OPTIONS] <DISTRO[:VERSION]> [-- COMMAND...]
| `--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
@@ -56,8 +108,43 @@ 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:
```sh
# 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 to `linux-lts` on 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 (`rva23s64` CPU, required by Ubuntu 25.10+ RVA23 userland)
## Supported distributions
| Name | Source | Version examples |
@@ -69,7 +156,7 @@ ecr --no-cache fedora
## 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.
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
+154 -14
View File
@@ -1,5 +1,63 @@
# ecr - implementation specification
## Workspace Layout
Cargo workspace with two crates:
- `crates/ecr` — the library (package `ecr`). All functionality lives here:
image resolution, download, extraction, namespaces, mounts, chroot exec,
QEMU VM mode.
- `crates/ecr-cli` — the CLI front-end (package `ecr-cli`, binary `ecr`).
Parses flags, maps them onto the library API, propagates exit codes.
The library must not depend on CLI types (`clap` is a CLI-only dependency).
## Library API
### Rootfs lifecycle (`ecr::rootfs`)
- `RootfsCache::new(dir)` / `RootfsCache::default_dir()` — the image tarball
cache (default `~/.cache/ecr`).
- `cache.prepare(&PrepareRequest)` — parse the image reference, resolve
floating version aliases ("latest", "lts", "edge") to a concrete version
before computing the cache key, run the OCI `:latest` digest freshness
check, download through the cache when needed, and extract into a scratch
directory. Returns a `PreparedRootfs` (TempDir-backed; dropped on drop).
- `cache.prepare_provisioned(&req, provision)` — hot-cell amortization: on a
cache hit with a `.provisioned` sidecar the entry is extracted as-is (no
freshness check, no provisioning); otherwise the rootfs is prepared,
`provision(&PreparedRootfs)` runs once, and `persist` writes the result
back. `no_cache` forces re-download + re-provision.
- `PreparedRootfs::persist()` — packs the current rootfs into its cache
entry (compression chosen from the entry's filename extension: gz, xz,
zstd, plain tar; symlinks stored as links; permissions preserved) and
writes the `.provisioned` sidecar. The entry's contents are replaced:
plain `prepare` also returns the provisioned contents from then on.
### Execution (`ecr::exec`)
- `exec(rootfs, &ExecOptions)` — run a command inside a prepared rootfs in
fresh user/PID/mount/UTS namespaces; returns the exit code (128+signal on
kill). Empty option fields select defaults:
- `arch`: host architecture; a foreign arch requires binfmt_misc.
- `env`: caller-composed envp as `(key, value)` pairs; empty selects
`chroot::default_env`. The host environment is never inherited.
- `binds`: explicit `BindTarget { source, target, read_only }` — host
directory mounted at an absolute in-rootfs mount point; read-only via
overlay, read-write via bind. `..` targets are rejected.
- `dns`: nameservers written to /etc/resolv.conf; empty copies the host
resolver.
- `command`: argv; empty runs the rootfs default shell.
- `working_dir`: explicit in-rootfs cwd; empty picks the first read-write
bind target that exists, then `/root`, then `/`.
### Mount plumbing (`ecr::mount`)
`setup_mounts(rootfs, &[BindTarget])` mounts proc/dev/devpts/sys and applies
the bind targets; `in_rootfs(rootfs, target)` maps in-chroot paths and
rejects escaping targets. Overlay upper/work directories are temp dirs the
caller must keep alive (returned by `setup_mounts`).
## Synopsis
```
@@ -22,6 +80,9 @@ ecr [OPTIONS] <DISTRO[:VERSION]> -- [COMMAND]...
| `--bind-rw <path>` | none | Read-write bind mount at `/mnt/<basename>` (can be specified multiple times, overrides `--bind` for same path) |
| `--no-cache` | false | Download fresh tarball, ignore cache |
| `--no-bind` | false | Skip mounting any directory |
| `--kernel[=PATH]` | none | Boot with QEMU system emulation; downloads the default Alpine kernel when no `=PATH` is given (`linux-virt`, falling back to `linux-lts` on architectures without a virt flavor, e.g. riscv64) |
| `-m, --memory <size>` | 2G | Memory size for QEMU VM (only used with `--kernel`) |
| `-v, --verbose` | false | Print diagnostic messages |
| `-h, --help` | - | Show help |
| `-V, --version` | - | Show version |
@@ -33,11 +94,21 @@ ecr [OPTIONS] <DISTRO[:VERSION]> -- [COMMAND]...
~/.cache/ecr/
├── ubuntu-noble-amd64.tar.gz
├── alpine-latest-x86_64.tar.gz
├── debian-bookworm-amd64.tar.gz
├── oci-docker_io-library_archlinux-latest-amd64.tar.gz
├── oci-docker_io-library_archlinux-latest-amd64.tar.gz.digest
└── ...
```
No metadata files. Tarballs are downloaded once and never redownloaded. Users can delete files manually or use `--no-cache` to fetch fresh.
Sidecar files, never counted as image entries:
- `<entry>.digest` — manifest digest of the last OCI download, used by the
`:latest` freshness check.
- `<entry>.provisioned` — marker written by `PreparedRootfs::persist`;
`prepare_provisioned` treats an entry with this marker as provisioned.
Tarballs are downloaded once and never redownloaded (unless the digest
moves, `--no-cache` is passed, or a provisioned entry is deleted). Users
can delete files manually.
### Config File
@@ -108,20 +179,20 @@ Error: No manifest found for architecture 'riscv64'. Available: amd64, arm64, pp
## Execution Flow
1. Parse CLI arguments
2. Resolve distro/version/arch to image source
The CLI delegates to the library; the namespace-mode flow is:
1. Parse CLI arguments, map flags onto library requests
2. `cache.prepare`: resolve distro/version/arch to image source
3. Check cache for existing tarball
4. If not cached, download tarball (direct or OCI)
5. Create temp directory for extraction
6. Extract tarball to temp directory
7. Create namespaces: user, pid, mount, uts
8. Set up mounts: /proc, /sys (ro), /dev, /dev/pts
5. Extract tarball to a temporary directory
6. `ecr::exec`: create namespaces: user, pid, mount, uts
7. Set up mounts: /proc, /sys (ro), /dev, /dev/pts
8. Apply bind targets: overlays (ro) and bind mounts (rw)
9. Write /etc/resolv.conf with DNS servers
10. Set up overlay mounts for bind paths
11. Set up read-write bind mounts
12. Set environment variables
13. Exec shell or command in chroot
14. On exit, clean up temp directory
10. Set the working directory
11. Exec shell or command in chroot with the composed envp
12. On exit, clean up the temporary directory; propagate the exit code
## Namespace Setup
@@ -182,6 +253,74 @@ Install QEMU user emulation:
No action required. Modern qemu-user-static packages register binfmt_misc with the `F` (fix binary) flag, loading the interpreter into kernel memory. The kernel handles foreign binary execution transparently.
## QEMU System Emulation Mode
When `--kernel` is specified, ecr switches from namespace/chroot mode to QEMU system emulation. The extracted rootfs is converted to an uncompressed CPIO initramfs and booted with the provided kernel.
### Usage
The kernel path uses `=` syntax (`--kernel=PATH`); `--kernel` without a value downloads the default kernel. Without `=`, a following path would be parsed as the DISTRO argument.
```sh
ecr --kernel ubuntu:noble
ecr --kernel=/boot/vmlinuz ubuntu:noble
ecr --kernel=/boot/vmlinuz --memory 4G alpine
ecr --kernel=/boot/vmlinuz debian -- /bin/sh -c "echo hello"
```
### Execution Flow
1. Download/cache rootfs tarball (same as namespace mode)
2. Extract tarball to temporary directory
3. Create uncompressed CPIO initramfs from rootfs (streamed to disk), including essential device nodes (/dev/ttyS0, /dev/null, /dev/tty) and an `/init` script that mounts proc/sys/dev, sets the hostname, execs the requested command argv verbatim (each argv element base64-encoded in the cmdline as `ECR_ARGV`), and powers off on exit
4. Launch QEMU with:
- `-machine virt` on riscv64 only - qemu-system-riscv64's default machine is `spike`, which has neither a PCI bus (virtio-net-pci fails) nor a 16550 UART (console output is lost); `virt` has both plus bundled OpenSBI firmware
- `-kernel <path>` - provided (or downloaded) kernel
- `-initrd initramfs.cpio` - rootfs as initramfs
- `-append "console=ttyS0 [quiet] ECR_SHELL=... [ECR_ARGV=...] ECR_HOSTNAME=..."` - kernel command line (`quiet` unless `-v`)
- `-m <memory>` - memory size (default 2G)
- `-display none -serial mon:stdio` - console on stdio
- `-netdev user,id=net0 -device virtio-net-pci,netdev=net0` - network NIC
- `-enable-kvm -cpu host` - when the host supports KVM and the target matches the host architecture
- `-cpu rva23s64` - riscv64 under TCG otherwise. Ubuntu builds its riscv64 port against the RVA23 profile (since 25.10); QEMU's default `rv64` CPU does not implement all profile extensions, so Ubuntu binaries die with SIGILL during init. Profiles are supersets, so baseline rv64gc rootfses (Alpine, older Ubuntu) run unchanged. Needs QEMU ≥ 10.1 (named profile CPUs).
5. Wait for QEMU to exit (init powers the VM off when the command/shell exits; `-no-reboot` makes QEMU terminate)
6. Cleanup temporary files
### Default Kernel Download
The default kernel (`--kernel` without `=PATH`) comes from Alpine's `main` repository. `linux-virt` is preferred; where it is not built (riscv64), the index lookup falls back to `linux-lts`. Some architectures package their kernel image gzipped (riscv64, aarch64 ship `Image.gz`), and QEMU's riscv `-kernel` loader understands only ELF, uImage and raw images, so the cached kernel is stored decompressed (gzip magic `1f 8b` detected and gunzipped at download time).
### Initramfs Creation
The rootfs directory is converted to an uncompressed CPIO archive (newc format) using the `cpio` crate, streamed entry by entry so large rootfs images never need to fit in memory. Hard links are preserved: the first occurrence of a (device, inode) pair carries the data with a synthetic inode, subsequent occurrences are zero-size entries sharing that inode, which the kernel's initramfs loader turns into real hard links.
### Architecture Support
| ecr Arch | QEMU System Binary |
|----------|-------------------|
| amd64/x86_64 | qemu-system-x86_64 |
| arm64/aarch64 | qemu-system-aarch64 |
| armhf/armv7 | qemu-system-arm |
| riscv64 | qemu-system-riscv64 |
| ppc64el | qemu-system-ppc64 |
| s390x | qemu-system-s390x |
### Requirements
- QEMU system emulator installed (`qemu-system-<arch>`)
- Kernel with required drivers (serial console, virtio-net for network)
### Differences from Namespace Mode
| Feature | Namespace Mode | QEMU Mode |
|---------|---------------|-----------|
| Isolation | User namespace | Full VM |
| Performance | Near-native | Emulated (KVM-accelerated when available) |
| Root access | No | Yes (inside the VM) |
| Foreign arch | binfmt_misc required | Built-in emulation |
| Bind mounts | Overlay/bind | Not supported (flags are ignored with a warning) |
| Network | Host network | User-mode NIC (not configured inside the guest) |
## File Handling
### Overlay Mount (Default)
@@ -235,7 +374,8 @@ dns:
## Environment Variables
Default environment inside chroot:
Default environment inside chroot (`chroot::default_env`, used by the CLI;
library callers compose their own envp):
- HOME=/root
- USER=root
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "ecr-cli"
description = "Enter chroot environments with Linux namespaces"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
# The installed binary keeps the `ecr` name even though the CLI package is
# `ecr-cli`; the library package owns the `ecr` name for consumers.
[[bin]]
name = "ecr"
path = "src/main.rs"
[dependencies]
ecr = { path = "../ecr" }
# CLI parsing
clap = { version = "4", features = ["derive", "env"] }
# Error handling
anyhow = "1"
+21
View File
@@ -33,6 +33,27 @@ pub struct Args {
#[arg(short = 'v', long)]
pub verbose: bool,
/// Boot with QEMU system emulation (optionally specify kernel path with =PATH, or omit to download default)
///
/// Examples:
/// --kernel Download and use the default Alpine kernel
/// (linux-virt, or linux-lts on riscv64)
/// --kernel=./vmlinuz Use a specific kernel file
///
/// The kernel path must use `=` syntax: with `--kernel ./vmlinuz` the
/// path would be parsed as the DISTRO argument.
#[arg(
long,
value_name = "KERNEL_PATH",
num_args = 0..=1,
require_equals = true
)]
pub kernel: Option<Option<PathBuf>>,
/// Memory size for QEMU VM (only used with --kernel, e.g., 512M, 2G)
#[arg(short = 'm', long, default_value = "2G", value_name = "SIZE")]
pub memory: String,
/// Command to run inside the chroot (default: interactive shell)
#[arg(
trailing_var_arg = true,
+178
View File
@@ -0,0 +1,178 @@
mod cli;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::Parser;
use cli::Args;
use ecr::chroot;
use ecr::config::Config;
use ecr::exec::ExecOptions;
use ecr::mount::BindTarget;
use ecr::{kernel, qemu, qemu_vm, utils, veprintln, verbose, PrepareRequest, RootfsCache};
fn main() -> Result<()> {
let args = Args::parse();
// Initialise verbosity before anything else so all downstream code can use veprintln!.
verbose::set(args.verbose);
// Load config file
let config = Config::load()?;
// Get architecture
let host_arch = utils::get_host_arch().debian_name().to_string();
let arch = args.arch.clone().unwrap_or_else(|| host_arch.clone());
// Check QEMU binfmt early for foreign architectures (namespace mode only;
// VM mode uses system emulation). Failing here avoids the download.
if args.kernel.is_none() && arch != host_arch {
qemu::check_binfmt(&arch)?;
}
// Resolve, download (through the cache) and extract the image
let cache = RootfsCache::new(RootfsCache::default_dir()?);
let request = PrepareRequest {
image: args.distro.clone(),
arch: args.arch.clone().unwrap_or_default(),
no_cache: args.no_cache,
};
let rootfs = cache.prepare(&request)?;
// Branch based on --kernel flag
// Option<Option<PathBuf>> (require_equals: the value must use =PATH syntax
// so it can never swallow the DISTRO positional):
// None -> --kernel not specified, use namespace mode
// Some(None) -> --kernel without path, download default kernel
// Some(Some(path)) -> --kernel=/path/to/vmlinuz, use provided kernel
if let Some(kernel_opt) = &args.kernel {
// VM mode boots an initramfs: host bind mounts are never applied
if !args.bind.is_empty() || !args.bind_rw.is_empty() {
eprintln!(
"Warning: --bind/--bind-rw are ignored with --kernel \
(the VM boots from an initramfs, no host directories are mounted)"
);
}
// QEMU system mode
let kernel_path = match kernel_opt {
Some(path) => {
veprintln!("QEMU mode: using provided kernel {}", path.display());
path.clone()
}
None => {
veprintln!("QEMU mode: downloading default kernel...");
kernel::get_default_kernel(cache.dir(), &arch)?
}
};
let command = if args.command.is_empty() {
None
} else {
Some(args.command.clone())
};
let result = qemu_vm::launch_qemu(qemu_vm::QemuConfig {
kernel_path,
rootfs_path: rootfs.dir().to_path_buf(),
memory: args.memory.clone(),
arch: arch.clone(),
command,
});
// Cleanup happens automatically via tempfile
if result.is_ok() {
veprintln!("Cleanup complete.");
}
result
} else {
// Namespace/chroot mode
let exit_code = namespace_mode(&args, rootfs.dir(), &config, &arch)?;
// Propagate the command's exit code, cleaning up the extracted rootfs
// first: process::exit does not run destructors.
drop(rootfs);
if exit_code != 0 {
std::process::exit(exit_code);
}
Ok(())
}
}
/// Run in namespace/chroot mode, returning the command's exit code
fn namespace_mode(args: &Args, rootfs: &Path, config: &Config, arch: &str) -> Result<i32> {
let opts = ExecOptions {
arch: arch.to_string(),
binds: cli_bind_targets(args)?,
env: chroot::default_env(rootfs),
dns: config.dns.clone(),
command: args.command.clone(),
working_dir: None,
};
let exit_code = ecr::exec(rootfs, &opts);
// Cleanup happens automatically via tempfile
if exit_code.is_ok() {
veprintln!("Cleanup complete.");
}
exit_code
}
/// Translate --bind/--bind-rw/--no-bind into explicit bind targets:
/// --bind overlays read-only at /root/<basename>, --bind-rw bind-mounts
/// read-write at /mnt/<basename> (taking precedence over --bind for the
/// same source path). With no --bind given, the current directory is
/// overlaid by default.
fn cli_bind_targets(args: &Args) -> Result<Vec<BindTarget>> {
// --no-bind means "skip mounting any directory". Combining it with an
// explicit --bind-rw is contradictory; error rather than silently ignoring
// the flag the user asked for.
if args.no_bind {
if !args.bind_rw.is_empty() {
return Err(anyhow::anyhow!(
"--no-bind and --bind-rw cannot be used together: \
--no-bind skips all mounts, including read-write ones"
));
}
return Ok(Vec::new());
}
let mut binds = Vec::new();
let ro_sources: Vec<PathBuf> = if args.bind.is_empty() {
vec![std::env::current_dir().context("Could not get current directory")?]
} else {
args.bind.clone()
};
for source in ro_sources {
if args.bind_rw.contains(&source) {
continue;
}
binds.push(BindTarget {
target: cli_mount_target(&source, "root")?,
source,
read_only: true,
});
}
for source in &args.bind_rw {
binds.push(BindTarget {
target: cli_mount_target(source, "mnt")?,
source: source.clone(),
read_only: false,
});
}
Ok(binds)
}
fn cli_mount_target(source: &Path, parent: &str) -> Result<PathBuf> {
let basename = source
.file_name()
.ok_or_else(|| anyhow::anyhow!("Invalid bind path: {}", source.display()))?;
Ok(Path::new("/").join(parent).join(basename))
}
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "ecr"
description = "Ephemeral chroot environments with Linux namespaces"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
# Config parsing
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
# HTTP downloads
reqwest = { version = "0.13", features = ["blocking", "stream"] }
# Tarball extraction
tar = "0.4"
flate2 = "1"
xz2 = "0.1"
zstd = "0.13"
# Unix syscall bindings
nix = { version = "0.31", features = ["fs", "mount", "sched", "signal", "user", "process", "hostname"] }
# Temp directories
tempfile = "3"
# Error handling
anyhow = "1"
# Utilities
dirs = "6"
which = "7"
cpio = "0.4"
base64 = "0.22"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] }
futures-util = "0.3"
indicatif = "0.18"
serde_json = "1"
libc = "0.2"
users = "0.11"
+246
View File
@@ -0,0 +1,246 @@
use crate::veprintln;
use anyhow::{anyhow, Context, Result};
use nix::unistd::{chroot, execve};
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
/// Compose the default environment for a rootfs: HOME, USER, SHELL (detected
/// inside `rootfs`), TERM (inherited from the host process) and PATH.
/// Callers use this as a starting point for their own envp, overriding or
/// extending entries before passing them to [`run_chroot`] / [`crate::exec`].
pub fn default_env(rootfs: &Path) -> Vec<(String, String)> {
let host_term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string());
let shell = crate::utils::detect_shell(rootfs);
vec![
("HOME".to_string(), "/root".to_string()),
("USER".to_string(), "root".to_string()),
("SHELL".to_string(), shell.to_string()),
("TERM".to_string(), host_term),
(
"PATH".to_string(),
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
),
]
}
/// Run a command in the chroot environment.
///
/// `env` is the full environment (envp) of the exec'd process — the host
/// environment is never inherited. `working_dir` must already be resolved
/// (absolute, inside the rootfs); exec fails if it does not exist there.
/// `command` defaults to the rootfs shell when empty or None. On success
/// this function never returns (execve replaces the process).
pub fn run_chroot(
rootfs: &Path,
command: Option<&[String]>,
env: &[(String, String)],
working_dir: &Path,
) -> Result<()> {
// Set hostname in UTS namespace
if let Err(e) = crate::namespace::set_hostname("chroot") {
eprintln!("Warning: Failed to set hostname: {}", e);
}
// Detect shell before chroot (we're still outside)
let shell = crate::utils::detect_shell(rootfs);
// Change to root directory in chroot
chroot(rootfs).context("Failed to chroot")?;
// Now we're inside the chroot
// Determine the command to run
let (program, args) = match command {
Some(cmd) if !cmd.is_empty() => {
let program = cmd[0].clone();
let args = cmd
.iter()
.map(|s| {
std::ffi::CString::new(s.as_str())
.with_context(|| format!("Argument contains a null byte: {:?}", s))
})
.collect::<Result<Vec<_>>>()?;
(program, args)
}
_ => {
// Run shell (already determined above based on chroot filesystem)
let program = shell.to_string();
let args =
vec![std::ffi::CString::new(shell).context("Shell path contains a null byte")?];
(program, args)
}
};
// Build the envp from the caller-composed pairs. execve takes this array
// directly; the host process environment is not touched at all.
let env_cstrings = env
.iter()
.map(|(k, v)| {
std::ffi::CString::new(format!("{}={}", k, v))
.with_context(|| format!("Environment variable contains a null byte: {}={}", k, v))
})
.collect::<Result<Vec<_>>>()?;
std::env::set_current_dir(working_dir).with_context(|| {
format!(
"Failed to change to working directory {}",
working_dir.display()
)
})?;
// Print welcome message
veprintln!("Entering chroot at {}", rootfs.display());
veprintln!("Working directory: {}", working_dir.display());
// execve(2) does not search PATH: resolve the program through the
// caller-composed environment like execvp(3) would.
let program_path = resolve_program(&program, env)?;
// Exec the program directly with the caller-composed, isolated environment.
// execve never returns on success.
let program_cstr = std::ffi::CString::new(program_path.as_os_str().as_bytes())
.with_context(|| format!("Invalid program path: {}", program_path.display()))?;
let result = execve(&program_cstr, &args, &env_cstrings);
match result {
Ok(_) => Ok(()), // Never reached
Err(e) => Err(anyhow!("Failed to exec {}: {}", program_path.display(), e)),
}
}
/// Resolve the program to exec. Commands containing a '/' are used as-is
/// (existing check gives a clearer error than a raw ENOENT); a bare command
/// name is looked up in the caller-composed PATH, like execvp(3) would.
fn resolve_program(program: &str, env: &[(String, String)]) -> Result<PathBuf> {
if program.contains('/') {
let path = PathBuf::from(program);
if !path.exists() {
return Err(anyhow!("Program not found: {}", program));
}
return Ok(path);
}
let path_var = env
.iter()
.find(|(k, _)| k == "PATH")
.map(|(_, v)| v.as_str())
.unwrap_or("/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin");
// Empty PATH components are skipped rather than treated as the current
// directory (execvp semantics) — cwd-relative lookup is a footgun here.
path_var
.split(':')
.filter(|dir| !dir.is_empty())
.map(|dir| PathBuf::from(dir).join(program))
.find(|candidate| candidate.exists())
.ok_or_else(|| anyhow!("Program not found: {} (PATH={})", program, path_var))
}
#[cfg(test)]
mod tests {
use super::*;
fn env_get<'a>(env: &'a [(String, String)], key: &str) -> Option<&'a str> {
env.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
}
#[test]
fn resolve_program_bare_name_via_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("true"), b"binary").unwrap();
let env = vec![(
"PATH".to_string(),
format!("/nonexistent:{}", dir.path().display()),
)];
assert_eq!(
resolve_program("true", &env).unwrap(),
dir.path().join("true")
);
}
#[test]
fn resolve_program_bare_name_defaults_to_standard_path() {
// No PATH in the composed env: the standard fallback must still
// resolve a program present on any Linux host.
let resolved = resolve_program("sh", &[]).unwrap();
assert!(resolved.exists());
}
#[test]
fn resolve_program_bare_name_missing() {
let env = vec![("PATH".to_string(), "/nonexistent".to_string())];
assert!(resolve_program("definitely-missing", &env).is_err());
}
#[test]
fn resolve_program_absolute_path_passthrough() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("tool"), b"binary").unwrap();
let path = dir.path().join("tool");
assert_eq!(resolve_program(path.to_str().unwrap(), &[]).unwrap(), path);
}
#[test]
fn resolve_program_absolute_path_missing() {
assert!(resolve_program("/nonexistent/tool", &[]).is_err());
}
#[test]
fn resolve_program_skips_empty_path_components() {
// An empty PATH component must not mean "current directory";
// Cargo.toml exists in the test process cwd and must not resolve.
let env = vec![("PATH".to_string(), ":".to_string())];
assert!(resolve_program("Cargo.toml", &env).is_err());
}
#[test]
fn test_default_env_entries() {
let dir = tempfile::tempdir().unwrap();
let env = default_env(dir.path());
assert_eq!(env.len(), 5);
assert_eq!(env_get(&env, "HOME"), Some("/root"));
assert_eq!(env_get(&env, "USER"), Some("root"));
assert_eq!(env_get(&env, "SHELL"), Some("/bin/sh"));
}
#[test]
fn test_default_env_shell_detection() {
// A rootfs with bash present must select /bin/bash
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("bin")).unwrap();
std::fs::write(dir.path().join("bin/bash"), b"#!").unwrap();
let env = default_env(dir.path());
assert_eq!(env_get(&env, "SHELL"), Some("/bin/bash"));
}
#[test]
fn test_default_env_path_contains_standard_directories() {
let dir = tempfile::tempdir().unwrap();
let env = default_env(dir.path());
let path = env_get(&env, "PATH").expect("PATH should be set");
// Verify essential directories are in PATH
assert!(path.contains("/bin"));
assert!(path.contains("/usr/bin"));
assert!(path.contains("/sbin"));
assert!(path.contains("/usr/sbin"));
}
#[test]
fn test_default_env_has_no_host_specific_variables() {
// Verify the default env is a clean environment without inheriting
// anything from the host beyond TERM
let dir = tempfile::tempdir().unwrap();
let env = default_env(dir.path());
let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
assert!(!keys.contains(&"LANG"));
assert!(!keys.contains(&"DISPLAY"));
assert!(!keys.contains(&"PWD"));
assert!(keys.contains(&"TERM"));
}
}
+6 -29
View File
@@ -144,39 +144,16 @@ fn parse_oci_ref(input: &str, arch: &str) -> Result<ImageSource> {
/// Map architecture to OCI standard names
pub fn map_oci_arch(arch: &str) -> String {
match arch {
"amd64" | "x86_64" => "amd64".to_string(),
"arm64" | "aarch64" => "arm64".to_string(),
"armhf" | "armv7" => "arm".to_string(),
"riscv64" => "riscv64".to_string(),
"ppc64el" | "ppc64le" => "ppc64le".to_string(),
"s390x" => "s390x".to_string(),
_ => arch.to_string(),
}
crate::utils::map_oci_arch(arch)
}
/// Map ecr architecture names to distro-specific names
pub fn map_arch(distro: Distro, arch: &str) -> String {
match distro {
Distro::Ubuntu => match arch {
"amd64" => "amd64".to_string(),
"arm64" => "arm64".to_string(),
"armhf" => "armhf".to_string(),
"riscv64" => "riscv64".to_string(),
"ppc64el" => "ppc64el".to_string(),
"s390x" => "s390x".to_string(),
_ => arch.to_string(),
},
Distro::Alpine => match arch {
"amd64" => "x86_64".to_string(),
"arm64" => "aarch64".to_string(),
"armhf" => "armv7".to_string(),
"riscv64" => "riscv64".to_string(),
"ppc64el" => "ppc64le".to_string(),
"s390x" => "s390x".to_string(),
_ => arch.to_string(),
},
}
let distro_name = match distro {
Distro::Ubuntu => "ubuntu",
Distro::Alpine => "alpine",
};
crate::utils::map_arch_for_distro(distro_name, arch)
}
/// Resolve the download URL for a known distro (optimized path)
+305
View File
@@ -0,0 +1,305 @@
//! Namespace-mode execution: mount a prepared rootfs and run a command
//! inside fresh user/PID/mount/UTS namespaces.
//!
//! The caller composes everything: the environment ([`ExecOptions::env`]),
//! the bind targets ([`ExecOptions::binds`]) and the target architecture
//! ([`ExecOptions::arch`]). Fields left empty fall back to the CLI
//! defaults (see each field's docs).
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::chroot;
use crate::mount::{self, BindTarget};
use crate::namespace;
use crate::qemu;
use crate::utils::{self, Arch};
use crate::veprintln;
/// Options for [`exec`].
#[derive(Debug, Clone)]
pub struct ExecOptions {
/// Target architecture of the rootfs (e.g. "amd64", "arm64"). When it
/// differs from the host, exec verifies binfmt_misc registration for
/// QEMU user emulation before starting. Empty selects the host arch.
pub arch: String,
/// Bind targets mounted into the rootfs before the command runs.
pub binds: Vec<BindTarget>,
/// Environment of the exec'd process, as (key, value) pairs. The host
/// environment is never inherited. Compose from
/// [`chroot::default_env`] to start from the CLI defaults. Empty
/// selects [`chroot::default_env`] as-is.
pub env: Vec<(String, String)>,
/// DNS servers written to /etc/resolv.conf. Empty copies the host
/// resolver (falling back to public resolvers when unreadable).
pub dns: Vec<String>,
/// argv to exec inside the rootfs. Empty runs the rootfs default shell
/// (bash if present, else sh).
pub command: Vec<String>,
/// Working directory inside the rootfs. Empty picks the first
/// read-write bind target that exists, then /root, then /.
pub working_dir: Option<PathBuf>,
}
impl ExecOptions {
pub fn new() -> Self {
ExecOptions {
arch: String::new(),
binds: Vec::new(),
env: Vec::new(),
dns: Vec::new(),
command: Vec::new(),
working_dir: None,
}
}
}
impl Default for ExecOptions {
fn default() -> Self {
Self::new()
}
}
/// Run a command inside `rootfs` in fresh user/PID/mount/UTS namespaces and
/// return its exit code (128+signal when the command is killed by a signal).
/// Setup failures are returned as Err.
pub fn exec(rootfs: &Path, opts: &ExecOptions) -> Result<i32> {
let arch = if opts.arch.is_empty() {
utils::get_host_arch().debian_name().to_string()
} else {
opts.arch.clone()
};
// Foreign-architecture rootfs needs QEMU user emulation to run its
// binaries; fail before doing any mount work.
if Arch::from_str(&arch) != utils::get_host_arch() {
qemu::check_binfmt(&arch)?;
}
namespace::check_user_namespace()?;
let mut opts = opts.clone();
if opts.env.is_empty() {
opts.env = chroot::default_env(rootfs);
}
let rootfs_buf = rootfs.to_path_buf();
namespace::setup_namespaces(move || {
// Setup mounts - overlay_temps must be kept alive for overlays to work
let overlay_temps = mount::setup_mounts(&rootfs_buf, &opts.binds)?;
for bind in &opts.binds {
if !bind.read_only {
veprintln!("Read-write mount: {}", bind.target.display());
}
}
write_resolv_conf(&rootfs_buf, &opts.dns)?;
let working_dir = resolve_working_dir(&rootfs_buf, &opts)?;
let command = if opts.command.is_empty() {
None
} else {
Some(opts.command.clone())
};
let result = chroot::run_chroot(&rootfs_buf, command.as_deref(), &opts.env, &working_dir);
// Keep overlay_temps alive until chroot exits
drop(overlay_temps);
result
})
}
/// Resolve the working directory for the exec'd command.
///
/// Existence checks happen against `rootfs`-prefixed paths, which is
/// equivalent to checking the absolute paths after chroot(2) — same tree,
/// same mounts (this runs after setup_mounts).
fn resolve_working_dir(rootfs: &Path, opts: &ExecOptions) -> Result<PathBuf> {
if let Some(dir) = &opts.working_dir {
return Ok(dir.clone());
}
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(bind) = opts.binds.iter().find(|b| !b.read_only) {
candidates.push(bind.target.clone());
}
candidates.push("/root".into());
candidates.push("/".into());
// Deduplicate while keeping first-seen order
let mut seen = HashSet::new();
candidates.retain(|c| seen.insert(c.clone()));
for candidate in candidates {
let exists = mount::in_rootfs(rootfs, &candidate)
.map(|p| p.exists())
.unwrap_or(false);
if exists {
return Ok(candidate);
}
}
Ok(PathBuf::from("/"))
}
/// Write /etc/resolv.conf with the caller's DNS servers (host resolver when
/// empty), atomically and never through a symlink.
pub fn write_resolv_conf(rootfs: &Path, dns: &[String]) -> Result<()> {
let resolv_conf = rootfs.join("etc/resolv.conf");
// Create /etc if it doesn't exist
if let Some(parent) = resolv_conf.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
// Copy host's resolv.conf if dns is empty, otherwise use provided DNS
let content = if dns.is_empty() {
// Try to copy from host
match std::fs::read_to_string("/etc/resolv.conf") {
Ok(host_resolv) => host_resolv,
Err(_) => "nameserver 1.1.1.1\nnameserver 8.8.8.8\n".to_string(),
}
} else {
let mut c = dns
.iter()
.map(|s| format!("nameserver {}", s))
.collect::<Vec<_>>()
.join("\n");
c.push('\n');
c
};
// Remove any existing file or symlink before writing so that we always
// create a plain file. Without this, an absolute symlink such as
// /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf would cause the
// write to follow the symlink through the *host* root (chroot() has not been
// called yet) and corrupt the host's DNS configuration.
//
// Use atomic file creation with O_CREAT | O_EXCL to prevent TOCTOU race:
// if an attacker creates a symlink between our remove_file and write, the
// exclusive create will fail rather than writing to the symlink target.
let _ = std::fs::remove_file(&resolv_conf); // ignore ENOENT
// Use OpenOptions with create_new(true) for atomic exclusive creation
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&resolv_conf)
.with_context(|| {
format!(
"Failed to create resolv.conf at {} (symlink attack prevented)",
resolv_conf.display()
)
})?;
file.write_all(content.as_bytes())
.with_context(|| format!("Failed to write to {}", resolv_conf.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(binds: Vec<BindTarget>, working_dir: Option<PathBuf>) -> ExecOptions {
ExecOptions {
binds,
working_dir,
..ExecOptions::default()
}
}
#[test]
fn explicit_working_dir_wins() {
let dir = tempfile::tempdir().unwrap();
let resolved =
resolve_working_dir(dir.path(), &opts(Vec::new(), Some("/custom".into()))).unwrap();
assert_eq!(resolved, PathBuf::from("/custom"));
}
#[test]
fn first_read_write_bind_target_preferred() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("mnt/data")).unwrap();
let binds = vec![
BindTarget {
source: "/host/ro".into(),
target: "/root/ro".into(),
read_only: true,
},
BindTarget {
source: "/host/data".into(),
target: "/mnt/data".into(),
read_only: false,
},
];
let resolved = resolve_working_dir(dir.path(), &opts(binds, None)).unwrap();
assert_eq!(resolved, PathBuf::from("/mnt/data"));
}
#[test]
fn falls_back_to_root_dir_when_no_binds() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("root")).unwrap();
let resolved = resolve_working_dir(dir.path(), &opts(Vec::new(), None)).unwrap();
assert_eq!(resolved, PathBuf::from("/root"));
}
#[test]
fn missing_read_write_target_falls_through_to_root_dir() {
// The bind target does not exist under rootfs (e.g. bind skipped):
// the candidate list must move on to /root rather than failing.
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("root")).unwrap();
let binds = vec![BindTarget {
source: "/host/data".into(),
target: "/mnt/data".into(),
read_only: false,
}];
let resolved = resolve_working_dir(dir.path(), &opts(binds, None)).unwrap();
assert_eq!(resolved, PathBuf::from("/root"));
}
#[test]
fn last_resort_is_slash() {
let dir = tempfile::tempdir().unwrap();
let resolved = resolve_working_dir(dir.path(), &opts(Vec::new(), None)).unwrap();
assert_eq!(resolved, PathBuf::from("/"));
}
#[test]
fn write_resolv_conf_uses_caller_dns() {
let dir = tempfile::tempdir().unwrap();
write_resolv_conf(dir.path(), &["8.8.8.8".to_string(), "1.0.0.1".to_string()]).unwrap();
let content = std::fs::read_to_string(dir.path().join("etc/resolv.conf")).unwrap();
assert_eq!(content, "nameserver 8.8.8.8\nnameserver 1.0.0.1\n");
}
#[test]
fn write_resolv_conf_replaces_existing_file() {
// Re-running exec on the same rootfs must overwrite, not fail on
// the exclusive create.
let dir = tempfile::tempdir().unwrap();
write_resolv_conf(dir.path(), &["8.8.8.8".to_string()]).unwrap();
write_resolv_conf(dir.path(), &["9.9.9.9".to_string()]).unwrap();
let content = std::fs::read_to_string(dir.path().join("etc/resolv.conf")).unwrap();
assert_eq!(content, "nameserver 9.9.9.9\n");
}
}
+100 -88
View File
@@ -1,5 +1,6 @@
use crate::veprintln;
use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::Path;
@@ -17,6 +18,14 @@ pub fn extract_tarball(tarball: &Path, dest: &Path) -> Result<()> {
return extract_multi_layer_oci(tarball, dest);
}
extract_single_archive(tarball, dest)
}
/// Extract a plain rootfs tarball, detecting the compression format from
/// the filename with a magic-byte fallback.
fn extract_single_archive(tarball: &Path, dest: &Path) -> Result<()> {
let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
let file = File::open(tarball)
.with_context(|| format!("Failed to open tarball: {}", tarball.display()))?;
@@ -99,9 +108,18 @@ fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> {
.context("Failed to unpack OCI bundle")?;
}
let manifest_path = temp_dir.path().join("layers.manifest");
if !manifest_path.exists() {
// Not our layer-bundle layout: a provisioned rootfs persisted over
// an OCI cache entry (the dispatch above keys on the "oci-" filename
// prefix). The outer archive is a plain rootfs tarball.
veprintln!("No layers.manifest in bundle; extracting as plain rootfs tarball");
return extract_single_archive(tarball, dest);
}
// Read the layers manifest
let manifest = std::fs::read_to_string(temp_dir.path().join("layers.manifest"))
.context("Failed to read layers.manifest")?;
let manifest =
std::fs::read_to_string(&manifest_path).context("Failed to read layers.manifest")?;
// Apply each layer in order with full whiteout handling.
for layer_name in manifest.lines() {
@@ -130,16 +148,22 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> {
let reader = BufReader::new(file);
if layer_name.ends_with(".tar.gz") || layer_name.ends_with(".tgz") {
extract_archive_with_whiteouts(
extract_with_progress(
tar::Archive::new(flate2::read::GzDecoder::new(reader)),
dest,
"Extracting OCI layer",
)
} else if layer_name.ends_with(".tar.xz") || layer_name.ends_with(".txz") {
extract_archive_with_whiteouts(tar::Archive::new(xz2::read::XzDecoder::new(reader)), dest)
extract_with_progress(
tar::Archive::new(xz2::read::XzDecoder::new(reader)),
dest,
"Extracting OCI layer",
)
} else if layer_name.ends_with(".tar.zst") || layer_name.ends_with(".tar.zstd") {
extract_archive_with_whiteouts(
extract_with_progress(
tar::Archive::new(zstd::stream::read::Decoder::new(reader)?),
dest,
"Extracting OCI layer",
)
} else {
// Fall back to magic-byte detection
@@ -148,53 +172,97 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> {
let _ = peek.read_exact(&mut magic); // short reads are fine for detection
drop(peek);
match magic {
[0x1f, 0x8b, ..] => extract_archive_with_whiteouts(
[0x1f, 0x8b, ..] => extract_with_progress(
tar::Archive::new(flate2::read::GzDecoder::new(BufReader::new(File::open(
layer_path,
)?))),
dest,
"Extracting OCI layer",
),
[0xfd, b'7', b'z', b'X', b'Z', 0x00] => extract_archive_with_whiteouts(
[0xfd, b'7', b'z', b'X', b'Z', 0x00] => extract_with_progress(
tar::Archive::new(xz2::read::XzDecoder::new(BufReader::new(File::open(
layer_path,
)?))),
dest,
"Extracting OCI layer",
),
[0x28, 0xb5, 0x2f, 0xfd, ..] => extract_archive_with_whiteouts(
[0x28, 0xb5, 0x2f, 0xfd, ..] => extract_with_progress(
tar::Archive::new(zstd::stream::read::Decoder::new(BufReader::new(
File::open(layer_path)?,
))?),
dest,
"Extracting OCI layer",
),
_ => extract_archive_with_whiteouts(
_ => extract_with_progress(
tar::Archive::new(BufReader::new(File::open(layer_path)?)),
dest,
"Extracting OCI layer",
),
}
}
}
/// Apply one OCI layer archive to `dest`, interpreting Docker whiteout markers:
///
/// - `.wh.<name>` — Delete `<name>` from a lower layer that was already
/// extracted into `dest`.
/// - `.wh..wh..opq` — Opaque whiteout: the directory that contains this entry
/// is new in this layer; delete everything already in that
/// directory from lower layers before applying new content.
///
/// All other entries are extracted normally via `Entry::unpack_in`.
fn extract_archive_with_whiteouts<R: std::io::Read>(
fn extract_gz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let gz_decoder = flate2::read::GzDecoder::new(reader);
let archive = tar::Archive::new(gz_decoder);
extract_with_progress(archive, dest, "Extracting gzip archive")?;
Ok(())
}
fn extract_xz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let xz_decoder = xz2::read::XzDecoder::new(reader);
let archive = tar::Archive::new(xz_decoder);
extract_with_progress(archive, dest, "Extracting xz archive")?;
Ok(())
}
fn extract_zst<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let zst_decoder = zstd::Decoder::new(reader)?;
let archive = tar::Archive::new(zst_decoder);
extract_with_progress(archive, dest, "Extracting zstd archive")?;
Ok(())
}
fn extract_tar<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let archive = tar::Archive::new(reader);
extract_with_progress(archive, dest, "Extracting tar archive")?;
Ok(())
}
/// Extract tar archive with progress bar, handling whiteout files for OCI layers
fn extract_with_progress<R: std::io::Read>(
mut archive: tar::Archive<R>,
dest: &Path,
msg: &str,
) -> Result<()> {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg} ({pos} files)")
.unwrap(),
);
pb.set_message(msg.to_string());
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(false);
archive.set_unpack_xattrs(false);
for entry in archive.entries().context("Failed to iterate tar entries")? {
let mut entry = entry.context("Failed to read tar entry")?;
let entries = archive
.entries()
.context("Failed to read archive entries")?;
// Clone the path before any mutable borrow of entry (needed for unpack_in)
for entry in entries {
let mut entry = entry.context("Failed to read archive entry")?;
// Clone the path before any mutable borrow of entry
let path = entry.path().context("Invalid tar entry path")?.into_owned();
let filename = path
@@ -202,9 +270,9 @@ fn extract_archive_with_whiteouts<R: std::io::Read>(
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
// Handle whiteout files (OCI layer markers for deletions)
if filename == ".wh..wh..opq" {
// Opaque whiteout: clear all previously-extracted content in the
// parent directory so only this layer's content is visible.
// Opaque whiteout: clear all previously-extracted content in the parent directory
let parent = path.parent().unwrap_or(Path::new(""));
let dest_dir = dest.join(parent);
if dest_dir.symlink_metadata().is_ok() {
@@ -218,24 +286,27 @@ fn extract_archive_with_whiteouts<R: std::io::Read>(
})?;
}
}
// Do not extract the .wh..wh..opq marker itself.
// Do not extract the .wh..wh..opq marker itself
} else if let Some(real_name) = filename.strip_prefix(".wh.") {
// Regular whiteout: delete the named path from lower layers.
// Regular whiteout: delete the named path from lower layers
let parent = path.parent().unwrap_or(Path::new(""));
let target = dest.join(parent).join(real_name);
// symlink_metadata (lstat) does not follow symlinks, so a dangling
// symlink is correctly detected and removed rather than silently skipped.
if target.symlink_metadata().is_ok() {
remove_path(&target)
.with_context(|| format!("Whiteout: failed to remove {}", target.display()))?;
}
// Do not extract the .wh.* marker itself.
// Do not extract the .wh.* marker itself
} else {
entry
.unpack_in(dest)
.with_context(|| format!("Failed to extract {}", path.display()))?;
}
pb.inc(1);
}
pb.finish_and_clear();
veprintln!("Extracted {} files", pb.position());
Ok(())
}
@@ -251,62 +322,3 @@ fn remove_path(path: &Path) -> std::io::Result<()> {
std::fs::remove_file(path)
}
}
fn extract_gz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let gz_decoder = flate2::read::GzDecoder::new(reader);
let mut archive = tar::Archive::new(gz_decoder);
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(false);
archive.set_unpack_xattrs(false);
archive
.unpack(dest)
.context("Failed to extract gzip archive")?;
Ok(())
}
fn extract_xz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let xz_decoder = xz2::read::XzDecoder::new(reader);
let mut archive = tar::Archive::new(xz_decoder);
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(false);
archive.set_unpack_xattrs(false);
archive
.unpack(dest)
.context("Failed to extract xz archive")?;
Ok(())
}
fn extract_zst<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let zst_decoder = zstd::Decoder::new(reader)?;
let mut archive = tar::Archive::new(zst_decoder);
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(false);
archive.set_unpack_xattrs(false);
archive
.unpack(dest)
.context("Failed to extract zstd archive")?;
Ok(())
}
fn extract_tar<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let mut archive = tar::Archive::new(reader);
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(false);
archive.set_unpack_xattrs(false);
archive
.unpack(dest)
.context("Failed to extract tar archive")?;
Ok(())
}
+467
View File
@@ -0,0 +1,467 @@
//! Default kernel download for QEMU VM mode.
//!
//! When `--kernel` is specified without a path, we download a default kernel
//! suitable for VM booting from Alpine's kernel packages:
//!
//! - Small size (~10-15MB compressed)
//! - VM-optimized configuration (`linux-virt`)
//! - Multi-architecture support
//! - Simple direct download URLs
//!
//! `linux-virt` is preferred, but it is not built for every architecture
//! (riscv64 has no virt flavor in `main`), so we fall back to `linux-lts`
//! when the virt flavor is absent from the repository index.
//!
//! The kernel is cached in the same cache directory as rootfs images,
//! decompressed: some architectures (aarch64, riscv64) package their
//! kernel image gzipped, and QEMU's riscv `-kernel` loader understands
//! only ELF, uImage and raw images.
use crate::veprintln;
use anyhow::{anyhow, Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use std::io::{Read, Seek};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Alpine kernel packages to consider, most preferred first
const KERNEL_PACKAGES: [&str; 2] = ["linux-virt", "linux-lts"];
/// Alpine architecture mapping for kernel packages
fn alpine_kernel_arch(arch: &str) -> &'static str {
match arch {
"amd64" | "x86_64" => "x86_64",
"arm64" | "aarch64" => "aarch64",
"armhf" | "armv7l" | "arm" => "armv7",
"riscv64" => "riscv64",
"ppc64le" => "ppc64le",
"s390x" => "s390x",
"x86" | "i386" | "i686" => "x86",
_ => "x86_64",
}
}
/// Get the Alpine version branch for kernel downloads
/// We use the latest stable branch
fn get_alpine_branch() -> Result<String> {
// Fetch the latest-stable branch from Alpine CDN
// The URL redirects to the current stable version
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.context("Failed to create HTTP client")?;
let response = client
.head("https://dl-cdn.alpinelinux.org/alpine/latest-stable/main/")
.send()
.context("Failed to check Alpine latest-stable")?;
// The final URL after redirect contains the version, e.g.:
// https://dl-cdn.alpinelinux.org/alpine/v3.23/main/
if let Some(final_url) = response
.url()
.as_str()
.strip_prefix("https://dl-cdn.alpinelinux.org/alpine/")
{
if let Some(branch) = final_url.split('/').next() {
return Ok(branch.to_string());
}
}
// Fallback: parse from the releases YAML
fetch_alpine_branch_from_yaml()
}
fn fetch_alpine_branch_from_yaml() -> Result<String> {
#[derive(serde::Deserialize)]
struct AlpineRelease {
version: Option<String>,
}
let url =
"https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/x86_64/latest-releases.yaml";
let text = reqwest::blocking::get(url)
.context("Failed to fetch Alpine latest-releases.yaml")?
.text()
.context("Failed to read Alpine latest-releases.yaml")?;
let releases: Vec<AlpineRelease> =
serde_yaml::from_str(&text).context("Failed to parse Alpine latest-releases.yaml")?;
if let Some(release) = releases.first() {
if let Some(version) = &release.version {
// version is like "3.23.0", we want "v3.23"
let parts: Vec<&str> = version.split('.').collect();
if parts.len() >= 2 {
return Ok(format!("v{}.{}", parts[0], parts[1]));
}
}
}
Err(anyhow!("Could not determine Alpine version from releases"))
}
/// Find the newest kernel package among `KERNEL_PACKAGES` in Alpine's
/// package index. Returns the package name and version. The index is
/// scanned once; whichever candidate flavor appears with the highest
/// preference (lowest index) wins.
fn find_kernel_package(branch: &str, arch: &str) -> Result<(&'static str, String)> {
// Alpine package index URL
let url = format!(
"https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/APKINDEX.tar.gz",
branch, arch
);
veprintln!("Fetching package index: {}", url);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.context("Failed to create HTTP client")?;
let response = client
.get(&url)
.send()
.context("Failed to fetch Alpine APKINDEX")?;
if !response.status().is_success() {
return Err(anyhow!(
"Failed to fetch APKINDEX: HTTP {}",
response.status()
));
}
let bytes = response.bytes().context("Failed to read APKINDEX")?;
// Extract APKINDEX from the tar.gz
// We need to own the bytes to avoid lifetime issues
let bytes_owned = bytes.to_vec();
veprintln!(" Downloaded {} bytes", bytes_owned.len());
// First decompress gzip to memory, then parse tar
// Note: Alpine's APKINDEX.tar.gz uses concatenated gzip members (multi-member gzip)
// flate2::read::GzDecoder only reads the first member, so we use MultiGzDecoder
let cursor = std::io::Cursor::new(&bytes_owned);
let mut gz_decoder = flate2::read::MultiGzDecoder::new(cursor);
let mut decompressed = Vec::new();
gz_decoder
.read_to_end(&mut decompressed)
.context("Failed to decompress gzip")?;
veprintln!(" Decompressed {} bytes", decompressed.len());
let tar_cursor = std::io::Cursor::new(decompressed);
let mut archive = tar::Archive::new(tar_cursor);
// Iterate through entries directly
let entries_iter = archive
.entries()
.context("Failed to read APKINDEX tar entries")?;
let mut entry_count = 0;
for entry_result in entries_iter {
entry_count += 1;
let mut entry = match entry_result {
Ok(e) => e,
Err(e) => {
veprintln!(
" Warning: failed to read tar entry #{}: {}",
entry_count,
e
);
continue;
}
};
let path = entry.path().context("Failed to get entry path")?;
let path_str = path.to_string_lossy();
veprintln!(" Entry #{}: {}", entry_count, path_str);
if path_str == "APKINDEX" {
let mut contents = String::new();
entry
.read_to_string(&mut contents)
.context("Failed to read APKINDEX contents")?;
veprintln!(" APKINDEX size: {} bytes", contents.len());
// Parse the APKINDEX to find the best kernel package
// Format:
// P:linux-virt
// V:6.12.8-r0
// ...
// The first V: line of the highest-preference package wins; the
// earliest version entry in the index is the latest build.
let mut pkg_name: Option<String> = None;
// (preference index, package, version)
let mut best: Option<(usize, &'static str, String)> = None;
for line in contents.lines() {
if let Some(name) = line.strip_prefix("P:") {
pkg_name = Some(name.trim().to_string());
} else if let Some(version) = line.strip_prefix("V:") {
if let Some(name) = &pkg_name {
if let Some(idx) = KERNEL_PACKAGES.iter().position(|p| *p == name) {
let preferred = best
.as_ref()
.map_or(true, |(best_idx, _, _)| idx < *best_idx);
if preferred {
best =
Some((idx, KERNEL_PACKAGES[idx], version.trim().to_string()));
}
}
}
}
}
match best {
Some((_, pkg, version)) => return Ok((pkg, version)),
None => {
return Err(anyhow!(
"no kernel package found in APKINDEX (looked for: {}). \
Available packages may vary by architecture.",
KERNEL_PACKAGES.join(", ")
))
}
}
}
}
veprintln!(" Total entries processed: {}", entry_count);
Err(anyhow!(
"APKINDEX file not found in tar.gz archive (processed {} entries)",
entry_count
))
}
/// Download and extract the kernel from an Alpine package repository
fn download_alpine_kernel(branch: &str, arch: &str, dest: &Path) -> Result<()> {
let (package, version) = find_kernel_package(branch, arch)?;
veprintln!("Found {} version: {}", package, version);
// Construct the download URL for the kernel .apk
// Format: https://dl-cdn.alpinelinux.org/alpine/v3.23/main/x86_64/linux-virt-6.12.8-r0.apk
let url = format!(
"https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/{}-{}.apk",
branch, arch, package, version
);
veprintln!("Downloading kernel: {}", url);
// Use async download via the existing download module pattern
let rt = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?;
rt.block_on(download_kernel_async(&url, dest))?;
// Extract the kernel image from the APK, then store it decompressed.
// Remove the ~45MB APK whatever the outcome — don't leave it behind
// in the cache directory on failure.
let temp_apk = dest.with_extension("apk");
let result = extract_kernel_from_apk(&temp_apk, dest, package)
.and_then(|()| decompress_kernel_if_gzipped(dest));
std::fs::remove_file(&temp_apk).ok();
result
}
async fn download_kernel_async(url: &str, dest: &Path) -> Result<()> {
use futures_util::StreamExt;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.build()
.context("Failed to create HTTP client")?;
let response = client
.get(url)
.send()
.await
.context("Failed to start kernel download")?;
if !response.status().is_success() {
return Err(anyhow!(
"Kernel download failed: HTTP {}",
response.status()
));
}
let total_size = response.content_length().unwrap_or(0);
// Setup progress bar
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("#>-"),
);
let temp_apk = dest.with_extension("apk.partial");
let mut file = tokio::fs::File::create(&temp_apk)
.await
.context("Failed to create temp APK file")?;
let mut downloaded: u64 = 0;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Failed to read chunk")?;
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
.await
.context("Failed to write chunk")?;
downloaded += chunk.len() as u64;
pb.set_position(downloaded);
}
tokio::io::AsyncWriteExt::flush(&mut file)
.await
.context("Failed to flush file")?;
pb.finish_with_message("Download complete");
// Rename to final name
let final_apk = dest.with_extension("apk");
tokio::fs::rename(&temp_apk, &final_apk)
.await
.context("Failed to rename temp file")?;
Ok(())
}
/// Extract the kernel image from an Alpine APK file
fn extract_kernel_from_apk(apk_path: &Path, dest: &Path, package: &str) -> Result<()> {
// The kernel binary is named after the flavor: boot/vmlinuz-virt for
// linux-virt, boot/vmlinuz-lts for linux-lts
let flavor = package
.strip_prefix("linux-")
.ok_or_else(|| anyhow!("unexpected kernel package name: {}", package))?;
let kernel_name = format!("boot/vmlinuz-{}", flavor);
veprintln!("Extracting kernel from APK...");
let file = std::fs::File::open(apk_path).context("Failed to open APK file")?;
// Use MultiGzDecoder because Alpine APKs have concatenated gzip members
let gz_decoder = flate2::read::MultiGzDecoder::new(file);
let mut archive = tar::Archive::new(gz_decoder);
for entry in archive.entries().context("Failed to read APK entries")? {
let mut entry = entry.context("Failed to read tar entry")?;
let path = entry.path().context("Failed to get entry path")?;
let path_str = path.to_string_lossy();
veprintln!(" APK entry: {}", path_str);
if path_str == kernel_name || path_str == format!("./{}", kernel_name) {
// Extract to destination
entry.unpack(dest).context("Failed to extract kernel")?;
veprintln!(" Extracted kernel to: {}", dest.display());
return Ok(());
}
}
Err(anyhow!("{} not found in APK package", kernel_name))
}
/// Decompress the cached kernel if it is gzip-compressed, in place.
/// Alpine packages the kernel image gzipped on some architectures (the
/// riscv64 and aarch64 vmlinuz are Image.gz), but QEMU's riscv `-kernel`
/// loader understands only ELF, uImage and raw images — a gzipped Image
/// is loaded verbatim and never boots. Uncompressed formats (x86 bzImage,
/// ppc64le ELF, raw Image) are left untouched.
fn decompress_kernel_if_gzipped(path: &Path) -> Result<()> {
let mut file =
std::fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
let mut magic = [0u8; 2];
let len = file
.read(&mut magic)
.context("Failed to read kernel magic bytes")?;
if len < magic.len() || magic != [0x1f, 0x8b] {
return Ok(());
}
veprintln!("Kernel is gzip-compressed, decompressing...");
file.rewind().context("Failed to rewind kernel file")?;
let mut decompressed = Vec::new();
flate2::read::MultiGzDecoder::new(file)
.read_to_end(&mut decompressed)
.context("Failed to decompress kernel")?;
std::fs::write(path, &decompressed)
.with_context(|| format!("Failed to write decompressed kernel to {}", path.display()))?;
Ok(())
}
/// Get the path to the cached default kernel for the given architecture.
/// Downloads and caches it if not present.
pub fn get_default_kernel(cache_dir: &Path, arch: &str) -> Result<PathBuf> {
let alpine_arch = alpine_kernel_arch(arch);
// Cache filename includes architecture
let kernel_filename = format!("ecr-default-kernel-{}.vmlinuz", alpine_arch);
let kernel_path = cache_dir.join(&kernel_filename);
// Check if already cached
if kernel_path.exists() {
veprintln!("Using cached default kernel: {}", kernel_path.display());
return Ok(kernel_path);
}
// Create cache directory if needed
std::fs::create_dir_all(cache_dir).context("Failed to create cache directory")?;
// Determine Alpine branch
let branch = get_alpine_branch()?;
veprintln!("Using Alpine branch: {}", branch);
// Download and extract the kernel
download_alpine_kernel(&branch, alpine_arch, &kernel_path)?;
Ok(kernel_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_alpine_kernel_arch() {
assert_eq!(alpine_kernel_arch("amd64"), "x86_64");
assert_eq!(alpine_kernel_arch("x86_64"), "x86_64");
assert_eq!(alpine_kernel_arch("arm64"), "aarch64");
assert_eq!(alpine_kernel_arch("aarch64"), "aarch64");
assert_eq!(alpine_kernel_arch("armhf"), "armv7");
assert_eq!(alpine_kernel_arch("riscv64"), "riscv64");
assert_eq!(alpine_kernel_arch("ppc64le"), "ppc64le");
assert_eq!(alpine_kernel_arch("s390x"), "s390x");
}
#[test]
fn test_decompress_kernel_if_gzipped() {
use flate2::write::GzEncoder;
use std::io::Write;
// A gzip-compressed kernel is decompressed in place
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("kernel");
let mut encoder = GzEncoder::new(
std::fs::File::create(&path).unwrap(),
flate2::Compression::default(),
);
encoder.write_all(b"fake kernel image").unwrap();
encoder.finish().unwrap();
decompress_kernel_if_gzipped(&path).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"fake kernel image");
}
#[test]
fn test_decompress_leaves_plain_kernel_untouched() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("kernel");
// An ELF-ish header that is not the gzip magic
std::fs::write(&path, b"\x7fELFfake kernel image").unwrap();
decompress_kernel_if_gzipped(&path).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"\x7fELFfake kernel image");
}
}
+41
View File
@@ -0,0 +1,41 @@
//! ecr — ephemeral chroot environments with Linux namespaces.
//!
//! This crate is the library behind the `ecr` CLI. It resolves distro and
//! OCI image references, downloads them through a content cache, extracts
//! the rootfs into a scratch directory, and runs commands inside
//! unprivileged user/PID/mount/UTS namespaces — or boots it in a QEMU VM
//! (see the `qemu_vm` module).
//!
//! The main entry points are [`rootfs`] (cache-aware rootfs preparation and
//! persistence) and [`exec`] (running a command inside a prepared rootfs
//! with a caller-composed environment, explicit bind targets and a target
//! architecture).
pub mod chroot;
pub mod config;
pub mod distro;
pub mod download;
pub mod exec;
pub mod extract;
pub mod kernel;
pub mod mount;
pub mod namespace;
pub mod qemu;
pub mod qemu_vm;
pub mod rootfs;
pub mod utils;
pub mod verbose;
/// Print to stderr only when verbose mode is active (see [`verbose::set`]).
#[macro_export]
macro_rules! veprintln {
($($arg:tt)*) => {
if $crate::verbose::is_verbose() {
eprintln!($($arg)*);
}
};
}
pub use exec::{exec, ExecOptions};
pub use mount::BindTarget;
pub use rootfs::{PrepareRequest, PreparedRootfs, RootfsCache};
+81 -41
View File
@@ -21,16 +21,41 @@ fn escape_overlay_path(path: &Path) -> Result<String> {
Ok(s.replace('\\', "\\\\").replace(',', "\\,"))
}
use crate::cli::Args;
/// A host directory to expose inside the rootfs.
///
/// Read-only targets are overlaid (host content stays pristine, chroot
/// writes go to a scratch upperdir that dies with the session); read-write
/// targets are plain bind mounts (chroot writes land on the host).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BindTarget {
/// Directory on the host.
pub source: std::path::PathBuf,
/// Absolute mount point inside the rootfs (e.g. `/root/myapp`).
pub target: std::path::PathBuf,
/// Mount read-write (bind) instead of read-only (overlay).
pub read_only: bool,
}
/// Map an absolute in-chroot path to its location under `rootfs`.
/// Rejects targets containing `..` components, which would escape the rootfs
/// and let a mount clobber host paths outside it.
pub fn in_rootfs(rootfs: &Path, target: &Path) -> Result<std::path::PathBuf> {
let relative = target.strip_prefix("/").unwrap_or(target);
let escapes = relative
.components()
.any(|c| matches!(c, std::path::Component::ParentDir));
if escapes {
return Err(anyhow!(
"Path '{}' escapes the rootfs: '..' components are not allowed",
target.display()
));
}
Ok(rootfs.join(relative))
}
/// Setup all required mounts inside the chroot
/// Returns a TempDir that must be kept alive for the duration of the chroot
pub fn setup_mounts(
rootfs: &Path,
bind_paths: &[std::path::PathBuf],
bind_rw_paths: &[std::path::PathBuf],
args: &Args,
) -> Result<Vec<TempDir>> {
pub fn setup_mounts(rootfs: &Path, binds: &[BindTarget]) -> Result<Vec<TempDir>> {
// Keep all overlay temp dirs alive
let mut overlay_temps: Vec<TempDir> = Vec::new();
@@ -59,21 +84,14 @@ pub fn setup_mounts(
eprintln!("Warning: Could not mount /sys: {}", e);
}
// Setup overlay mounts for bind paths (read-only via overlay)
if !args.no_bind {
for bind_path in bind_paths {
// Skip if this path is also in bind_rw (bind_rw takes precedence)
if !bind_rw_paths.contains(bind_path) {
let temp = setup_overlay(rootfs, bind_path)?;
overlay_temps.push(temp);
for bind in binds {
let mount_point = in_rootfs(rootfs, &bind.target)?;
if bind.read_only {
overlay_temps.push(setup_overlay(&bind.source, &mount_point)?);
} else {
setup_bind_rw(&bind.source, &mount_point)?;
}
}
}
// Setup read-write bind mounts (these override regular bind for same paths)
for bind_rw_path in bind_rw_paths {
setup_bind_rw(rootfs, bind_rw_path)?;
}
Ok(overlay_temps)
}
@@ -164,16 +182,10 @@ fn mount_devpts(rootfs: &Path) -> Result<()> {
Ok(())
}
/// Setup overlay mount for workspace directory
/// Setup overlay mount for a workspace directory
/// Returns a TempDir that must be kept alive for the overlay to work
fn setup_overlay(rootfs: &Path, source: &Path) -> Result<TempDir> {
let basename = source
.file_name()
.ok_or_else(|| anyhow!("Invalid bind path"))?
.to_string_lossy();
let mount_point = rootfs.join("root").join(basename.as_ref());
std::fs::create_dir_all(&mount_point)?;
fn setup_overlay(source: &Path, mount_point: &Path) -> Result<TempDir> {
std::fs::create_dir_all(mount_point)?;
// Create temp directories for overlay
let temp_dir = tempfile::tempdir()?;
@@ -196,7 +208,7 @@ fn setup_overlay(rootfs: &Path, source: &Path) -> Result<TempDir> {
mount(
Some("overlay"),
&mount_point,
mount_point,
Some("overlay"),
MsFlags::empty(),
Some(options.as_str()),
@@ -208,20 +220,14 @@ fn setup_overlay(rootfs: &Path, source: &Path) -> Result<TempDir> {
}
/// Setup read-write bind mount
fn setup_bind_rw(rootfs: &Path, source: &Path) -> Result<()> {
let basename = source
.file_name()
.ok_or_else(|| anyhow!("Invalid bind-rw path"))?
.to_string_lossy();
let mount_point = rootfs.join("mnt").join(basename.as_ref());
std::fs::create_dir_all(&mount_point)?;
fn setup_bind_rw(source: &Path, mount_point: &Path) -> Result<()> {
std::fs::create_dir_all(mount_point)?;
let source = source.canonicalize()?;
mount(
Some(&source),
&mount_point,
mount_point,
None::<&str>,
MsFlags::MS_BIND | MsFlags::MS_REC,
None::<&str>,
@@ -239,8 +245,42 @@ fn setup_bind_rw(rootfs: &Path, source: &Path) -> Result<()> {
#[cfg(test)]
mod tests {
use super::escape_overlay_path;
use std::path::Path;
use super::{escape_overlay_path, in_rootfs, BindTarget};
use std::path::{Path, PathBuf};
fn bind(source: &str, target: &str, read_only: bool) -> BindTarget {
BindTarget {
source: PathBuf::from(source),
target: PathBuf::from(target),
read_only,
}
}
#[test]
fn absolute_target_joins_under_rootfs() {
let p = in_rootfs(Path::new("/rootfs"), Path::new("/mnt/data")).unwrap();
assert_eq!(p, Path::new("/rootfs/mnt/data"));
}
#[test]
fn root_target_maps_to_rootfs() {
let p = in_rootfs(Path::new("/rootfs"), Path::new("/")).unwrap();
assert_eq!(p, Path::new("/rootfs"));
}
#[test]
fn parent_components_rejected() {
assert!(in_rootfs(Path::new("/rootfs"), Path::new("/../etc")).is_err());
assert!(in_rootfs(Path::new("/rootfs"), Path::new("/mnt/../../host")).is_err());
}
#[test]
fn bind_target_fields_roundtrip() {
let b = bind("/host/dir", "/mnt/dir", false);
assert_eq!(b.source, PathBuf::from("/host/dir"));
assert_eq!(b.target, PathBuf::from("/mnt/dir"));
assert!(!b.read_only);
}
#[test]
fn plain_path_unchanged() {
+104 -11
View File
@@ -58,8 +58,10 @@ pub fn check_user_namespace() -> Result<()> {
Ok(())
}
/// Setup namespaces and run the provided function inside them
pub fn setup_namespaces<F>(f: F) -> Result<()>
/// Setup namespaces and run the provided function inside them.
/// Returns the child's exit code (0 on success, 128+signal when killed by a
/// signal). Setup failures are returned as Err.
pub fn setup_namespaces<F>(f: F) -> Result<i32>
where
F: FnOnce() -> Result<()> + Send + 'static,
{
@@ -105,7 +107,7 @@ where
}
// Stack for the child process
let stack_size = 1024 * 1024;
let stack_size = crate::utils::CHILD_STACK_SIZE;
let mut stack = vec![0u8; stack_size];
// Wrap f in Option to allow taking it once inside the child closure
@@ -198,7 +200,7 @@ where
// O_CLOEXEC (on successful exec), so this read always terminates.
let child_error: Option<String> = unsafe {
let mut error_bytes = Vec::new();
let mut tmp = [0u8; 4096];
let mut tmp = [0u8; crate::utils::ERROR_BUFFER_SIZE];
loop {
let n = libc::read(
error_read.raw(),
@@ -222,20 +224,18 @@ where
let status = nix::sys::wait::waitpid(pid, None)?;
match status {
nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(()),
nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(0),
nix::sys::wait::WaitStatus::Exited(_, code) => {
// If the child reported an error (e.g., setup failure), return it.
// Otherwise, just forward the exit code without an error message.
if let Some(msg) = child_error {
Err(anyhow!("{}", msg))
} else {
std::process::exit(code);
Ok(code)
}
}
nix::sys::wait::WaitStatus::Signaled(_, sig, _) => {
Err(anyhow!("Child process killed by signal {:?}", sig))
}
_ => Ok(()),
nix::sys::wait::WaitStatus::Signaled(_, sig, _) => Ok(128 + sig as i32),
_ => Ok(0),
}
}
@@ -397,7 +397,9 @@ pub fn set_hostname(distro: &str) -> Result<()> {
let mut state = hasher.finish();
let chars = b"abcdefghijklmnopqrstuvwxyz0123456789";
let random_suffix: String = (0..6)
// Use HOSTNAME_SUFFIX_BITS for entropy (6 hex chars = 24 bits)
let suffix_len = (crate::utils::HOSTNAME_SUFFIX_BITS as f64).log2() as usize / 4;
let random_suffix: String = (0..suffix_len)
.map(|_| {
// Knuth multiplicative LCG — each step advances the full 64-bit state.
state = state
@@ -413,3 +415,94 @@ pub fn set_hostname(distro: &str) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::hash::{Hash, Hasher};
#[test]
fn test_check_user_namespace_returns_ok() {
// This test verifies the function runs without panicking
// On most modern Linux systems with user namespaces enabled, this should pass
let result = check_user_namespace();
// We can't assert success because it depends on system configuration
// But we can verify it doesn't panic and returns a Result
assert!(result.is_ok() || result.is_err());
}
#[test]
fn test_hostname_format() {
// Test that hostname generation produces valid format
use std::collections::HashSet;
let mut hostnames = HashSet::new();
for _ in 0..100 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::time::SystemTime::now().hash(&mut hasher);
std::process::id().hash(&mut hasher);
let mut state = hasher.finish();
let chars = b"abcdefghijklmnopqrstuvwxyz0123456789";
let suffix_len = (crate::utils::HOSTNAME_SUFFIX_BITS as f64).log2() as usize / 4;
let random_suffix: String = (0..suffix_len)
.map(|_| {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
chars[(state >> 33) as usize % chars.len()] as char
})
.collect();
let hostname = format!("ecr-test-{}", random_suffix);
// Verify hostname format
assert!(hostname.starts_with("ecr-test-"));
assert!(hostname.len() > 9); // "ecr-test-" + at least 1 char
assert!(hostname
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
hostnames.insert(hostname);
}
// With 100 iterations and good entropy, we should get many unique hostnames
assert!(
hostnames.len() > 50,
"Expected many unique hostnames, got {}",
hostnames.len()
);
}
#[test]
fn test_set_hostname_uniqueness() {
// Verify that rapid consecutive calls produce different hostnames
use std::collections::HashSet;
let mut hostnames = Vec::new();
for _ in 0..10 {
// Simulate the hostname generation logic
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::time::SystemTime::now().hash(&mut hasher);
std::process::id().hash(&mut hasher);
let mut state = hasher.finish();
let chars = b"abcdefghijklmnopqrstuvwxyz0123456789";
let suffix_len = (crate::utils::HOSTNAME_SUFFIX_BITS as f64).log2() as usize / 4;
let random_suffix: String = (0..suffix_len)
.map(|_| {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
chars[(state >> 33) as usize % chars.len()] as char
})
.collect();
hostnames.push(format!("ecr-test-{}", random_suffix));
}
let unique: HashSet<_> = hostnames.iter().collect();
// Most hostnames should be unique (high entropy)
assert!(unique.len() >= 8, "Expected mostly unique hostnames");
}
}
+1 -14
View File
@@ -4,7 +4,7 @@ use std::path::Path;
/// Check if binfmt_misc is registered for the target architecture
pub fn check_binfmt(arch: &str) -> Result<()> {
let qemu_arch = map_arch_to_qemu(arch);
let qemu_arch = crate::utils::Arch::from_str(arch).qemu_binfmt_name();
let binfmt_path = format!("/proc/sys/fs/binfmt_misc/qemu-{}", qemu_arch);
@@ -22,16 +22,3 @@ pub fn check_binfmt(arch: &str) -> Result<()> {
veprintln!("QEMU binfmt_misc registered for {}", arch);
Ok(())
}
/// Map ecr architecture names to QEMU binary names
fn map_arch_to_qemu(arch: &str) -> &str {
match arch {
"amd64" | "x86_64" => "x86_64",
"arm64" | "aarch64" => "aarch64",
"armhf" | "armv7" => "arm",
"riscv64" => "riscv64",
"ppc64el" | "ppc64le" => "ppc64le",
"s390x" => "s390x",
_ => arch,
}
}
+791
View File
@@ -0,0 +1,791 @@
use crate::veprintln;
use anyhow::{anyhow, Context, Result};
use cpio::{newc, NewcBuilder};
use indicatif::{ProgressBar, ProgressStyle};
use std::io::Write;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::Command;
/// QEMU system emulation configuration
pub struct QemuConfig {
/// Path to the kernel image (vmlinuz)
pub kernel_path: PathBuf,
/// Path to the rootfs directory
pub rootfs_path: PathBuf,
/// Memory size for VM (e.g., "2G", "512M")
pub memory: String,
/// Target architecture
pub arch: String,
/// Optional command to run instead of default init
pub command: Option<Vec<String>>,
}
/// Launch QEMU with the given configuration
pub fn launch_qemu(config: QemuConfig) -> Result<()> {
// Check that kernel exists
if !config.kernel_path.exists() {
return Err(anyhow!(
"Kernel not found: {}",
config.kernel_path.display()
));
}
// Check that rootfs exists
if !config.rootfs_path.exists() {
return Err(anyhow!(
"Rootfs not found: {}",
config.rootfs_path.display()
));
}
// Validate memory string format
crate::utils::validate_memory_string(&config.memory)
.with_context(|| format!("Invalid memory size: {}", config.memory))?;
// Create an uncompressed cpio initramfs from the rootfs
let initramfs = create_initramfs(&config.rootfs_path)?;
// Get QEMU binary for architecture
let qemu_bin = qemu_binary_for_arch(&config.arch);
// Check QEMU exists
which::which(&qemu_bin).context(format!(
"QEMU system emulator '{}' not found. Install it with:\n\
Ubuntu/Debian: sudo apt install qemu-system-{}\n\
Arch: sudo pacman -S qemu-system-{}\n\
Alpine: sudo apk add qemu-system-{}",
qemu_bin,
get_arch_package_suffix(&config.arch),
get_arch_package_suffix(&config.arch),
get_arch_package_suffix(&config.arch)
))?;
// Check if we can use KVM acceleration
let use_kvm = can_use_kvm(&config.arch);
if use_kvm {
veprintln!(" KVM: enabled (native acceleration)");
} else {
veprintln!(" KVM: disabled (using software emulation)");
}
// Detect the best available shell in the rootfs
let shell = crate::utils::detect_shell(&config.rootfs_path);
// Generate a unique hostname like "ecr-vm-a1b2c3"
// Use VM_HOSTNAME_SUFFIX_BITS constant for entropy
let hostname_suffix = format!(
"{:x}",
(std::process::id() as u64).wrapping_mul(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64
) % crate::utils::VM_HOSTNAME_SUFFIX_BITS
);
let hostname = format!("ecr-vm-{}", hostname_suffix);
// Build kernel command line
// For initramfs boot, use rdinit= instead of init=
// No root= needed as initramfs becomes the rootfs
// 'quiet' suppresses kernel log messages for a cleaner console (removed with -v)
// The init script (added to initramfs) handles hostname, shell, and poweroff
let quiet_flag = if crate::verbose::is_verbose() {
""
} else {
" quiet"
};
let kernel_append = if let Some(ref cmd) = config.command {
// The argv travels through the kernel cmdline, where quotes and
// spaces would be mangled — pass each element base64-encoded,
// comma-separated (the base64 alphabet contains neither). The init
// script decodes it back and execs the argv verbatim.
use base64::Engine as _;
let argv_b64 = cmd
.iter()
.map(|arg| base64::engine::general_purpose::STANDARD.encode(arg.as_bytes()))
.collect::<Vec<_>>()
.join(",");
format!(
"console=ttyS0{} ECR_SHELL={} ECR_ARGV={} ECR_HOSTNAME={}",
quiet_flag, shell, argv_b64, hostname
)
} else {
format!(
"console=ttyS0{} ECR_SHELL={} ECR_HOSTNAME={}",
quiet_flag, shell, hostname
)
};
veprintln!("Launching QEMU: {}", qemu_bin);
veprintln!(" Kernel: {}", config.kernel_path.display());
veprintln!(" Initramfs: {}", initramfs.display());
veprintln!(" Memory: {}", config.memory);
veprintln!(" Kernel append: {}", kernel_append);
// Build QEMU arguments
// -machine virt is selected explicitly on riscv64 (see machine_for_arch)
// -display none suppresses VGA/BIOS output
// -serial mon:stdio connects serial console to terminal with QEMU monitor muxed
// -no-reboot makes QEMU exit when the guest requests poweroff/reboot
let mut args: Vec<String> = Vec::new();
if let Some(machine) = machine_for_arch(&config.arch) {
args.push("-machine".to_string());
args.push(machine.to_string());
}
args.extend(vec![
"-kernel".to_string(),
config.kernel_path.to_string_lossy().to_string(),
"-initrd".to_string(),
initramfs.to_string_lossy().to_string(),
"-append".to_string(),
kernel_append,
"-m".to_string(),
config.memory.clone(),
"-display".to_string(),
"none".to_string(),
"-serial".to_string(),
"mon:stdio".to_string(),
"-no-reboot".to_string(),
"-netdev".to_string(),
"user,id=net0".to_string(),
"-device".to_string(),
"virtio-net-pci,netdev=net0".to_string(),
]);
// Add KVM acceleration if available
if use_kvm {
args.push("-enable-kvm".to_string());
args.push("-cpu".to_string());
args.push("host".to_string());
} else if let Some(cpu) = cpu_for_arch(&config.arch) {
args.push("-cpu".to_string());
args.push(cpu.to_string());
}
// Execute QEMU
let status = Command::new(&qemu_bin)
.args(&args)
.status()
.context("Failed to execute QEMU")?;
// Cleanup initramfs
if let Err(e) = std::fs::remove_file(&initramfs) {
veprintln!("Warning: failed to cleanup initramfs: {}", e);
}
if !status.success() {
return Err(anyhow!(
"QEMU exited with non-zero status: {}",
status.code().unwrap_or(-1)
));
}
Ok(())
}
/// Get QEMU system binary name for architecture
fn qemu_binary_for_arch(arch: &str) -> String {
let arch_enum = crate::utils::Arch::from_str(arch);
format!("qemu-system-{}", arch_enum.qemu_system_name())
}
/// Get the QEMU machine to select for the target architecture, if any.
///
/// qemu-system-riscv64's default machine is `spike`, which has neither a
/// PCI bus (virtio-net-pci fails with "No 'PCI' bus found") nor a 16550
/// UART (console=ttyS0 output goes nowhere); the `virt` board has both,
/// plus bundled OpenSBI firmware for -kernel boot.
fn machine_for_arch(arch: &str) -> Option<&'static str> {
match crate::utils::Arch::from_str(arch) {
crate::utils::Arch::Riscv64 => Some("virt"),
_ => None,
}
}
/// Get the QEMU TCG CPU model to select for the target architecture, when
/// the emulator default is not sufficient.
///
/// Ubuntu builds its riscv64 port against the RVA23 profile (since 25.10),
/// so its binaries execute instructions QEMU's default `rv64` CPU does not
/// implement and die with SIGILL early in boot. `rva23s64` implements the
/// full supervisor profile; baseline rv64gc rootfses (Alpine, older
/// Ubuntu) run unchanged on it because profiles are supersets. The named
/// profile CPU needs QEMU 10.1+.
fn cpu_for_arch(arch: &str) -> Option<&'static str> {
match crate::utils::Arch::from_str(arch) {
crate::utils::Arch::Riscv64 => Some("rva23s64"),
_ => None,
}
}
/// Get architecture suffix for package names
fn get_arch_package_suffix(arch: &str) -> &'static str {
crate::utils::Arch::from_str(arch).qemu_package_suffix()
}
/// Check if KVM acceleration can be used for the target architecture
fn can_use_kvm(target_arch: &str) -> bool {
use crate::utils::Arch;
// Normalize both to canonical form (uname -m style) and compare
let host_arch = crate::utils::get_host_arch();
let target_enum = Arch::from_str(target_arch);
if host_arch != target_enum {
return false;
}
// Check if /dev/kvm exists and is accessible (read+write required for VM execution)
match std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/kvm")
{
Ok(_) => {
// Additional check: verify KVM actually works by checking capabilities
// This catches cases where /dev/kvm exists but KVM is not functional
check_kvm_capabilities()
}
Err(_) => false,
}
}
/// Check if KVM capabilities are actually functional
fn check_kvm_capabilities() -> bool {
use std::os::unix::io::AsRawFd;
// Try to open /dev/kvm and check KVM_GET_API_VERSION
match std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/kvm")
{
Ok(file) => {
let fd = file.as_raw_fd();
// KVM_GET_API_VERSION ioctl = 0xAE00
// Expected return value is 12 (KVM_API_VERSION)
let ret = unsafe { libc::ioctl(fd, 0xAE00) };
ret == 12
}
Err(_) => false,
}
}
/// Create an uncompressed cpio initramfs from a directory.
/// Entries are streamed straight to disk so large rootfs images never have
/// to fit in memory as a whole archive.
fn create_initramfs(rootfs: &Path) -> Result<PathBuf> {
// Create a temporary file for the initramfs (uncompressed cpio)
// Use a temp file in the same directory as rootfs, or fall back to /tmp
let initramfs_path = rootfs
.parent()
.map(|p| p.join("initramfs.cpio"))
.unwrap_or_else(|| std::env::temp_dir().join("initramfs.cpio"));
// Create progress bar
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg} ({pos} files)")
.unwrap(),
);
pb.set_message("Creating initramfs...");
let file = std::fs::File::create(&initramfs_path)
.with_context(|| format!("Failed to create {}", initramfs_path.display()))?;
let mut writer = std::io::BufWriter::new(file);
let result = write_cpio_archive(rootfs, &mut writer, &pb);
let flushed = writer.flush().context("Failed to flush initramfs");
// On any error, don't leave a partial archive behind
if let Err(e) = result.or(flushed) {
std::fs::remove_file(&initramfs_path).ok();
return Err(e);
}
// Finish progress bar
let file_count = pb.position();
pb.finish_and_clear();
let total_bytes = std::fs::metadata(&initramfs_path)
.map(|m| m.len())
.unwrap_or(0);
veprintln!(
"Initramfs created: {} bytes, {} files",
total_bytes,
file_count
);
Ok(initramfs_path)
}
/// Write the newc-format cpio archive for a directory tree, streaming to `writer`
fn write_cpio_archive<W: Write>(rootfs: &Path, writer: &mut W, pb: &ProgressBar) -> Result<()> {
// Track hard links by (device, inode): the value is the synthetic cpio
// inode assigned to the first occurrence. The Linux initramfs loader
// turns later zero-size entries sharing that inode into hard links.
let mut seen_inodes: std::collections::HashMap<(u64, u64), u32> =
std::collections::HashMap::new();
let mut next_ino: u32 = 1;
// Names of entries already written, so we can skip duplicate device nodes
let mut written_names: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut total_data: u64 = 0;
write_dir_entries(
rootfs,
rootfs,
writer,
pb,
&mut seen_inodes,
&mut next_ino,
&mut written_names,
&mut total_data,
)?;
veprintln!(
"Collected {} entries, {} bytes total data",
written_names.len(),
total_data
);
// Add essential device nodes for serial console
// These are character devices (mode 0o020xxx)
let device_nodes = [
// /dev/ttyS0 - serial console (major 4, minor 64)
("dev/ttyS0", 0o020644, 4, 64),
// /dev/null (major 1, minor 3)
("dev/null", 0o020644, 1, 3),
// /dev/tty - controlling terminal (major 5, minor 0)
("dev/tty", 0o020666, 5, 0),
];
for (name, mode, major, minor) in device_nodes {
// Check if this device node already exists in the archive
if written_names.contains(name) {
continue;
}
let builder = NewcBuilder::new(name)
.mode(mode)
.uid(0)
.gid(0)
.nlink(1)
.mtime(0)
.rdev_major(major)
.rdev_minor(minor);
// Device nodes have zero size
let entry_writer = builder.write(&mut *writer, 0);
entry_writer
.finish()
.context("Failed to finish device node entry")?;
}
// Add the /init script that will be run as PID 1
// This script handles hostname setup, shell execution, and poweroff on exit
// Uses /proc/sysrq-trigger for poweroff since poweroff command may not be available
let init_script = r#"#!/bin/sh
# ECR init script - runs as PID 1
# Mount essential filesystems
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev 2>/dev/null || true
# Parse our parameters straight from the kernel cmdline. More robust than
# relying on the kernel forwarding unknown key=value params to init's env.
ECR_SHELL="/bin/sh"
ECR_ARGV=""
for param in $(cat /proc/cmdline); do
case "$param" in
ECR_SHELL=*) ECR_SHELL="${param#ECR_SHELL=}" ;;
ECR_ARGV=*) ECR_ARGV="${param#ECR_ARGV=}" ;;
ECR_HOSTNAME=*) ECR_HOSTNAME="${param#ECR_HOSTNAME=}" ;;
esac
done
# Set hostname from kernel cmdline (via procfs — no hostname binary needed)
if [ -n "$ECR_HOSTNAME" ]; then
echo "$ECR_HOSTNAME" > /etc/hostname
echo "$ECR_HOSTNAME" > /proc/sys/kernel/hostname
fi
# Create console device if missing
mknod -m 600 /dev/console c 5 1 2>/dev/null || true
mknod -m 666 /dev/ttyS0 c 4 64 2>/dev/null || true
# Function to poweroff - use sysrq-trigger which works without external binaries
do_poweroff() {
# Give the serial console a moment to drain pending output, otherwise
# the last command output can be dropped when the VM powers off
sleep 1
# Silence kernel printk to suppress shutdown messages
echo 0 > /proc/sys/kernel/printk
# 'o' means power off, see Documentation/admin-guide/sysrq.rst
echo o > /proc/sysrq-trigger
# Fallback: infinite loop to prevent kernel panic
while true; do sleep 1; done
}
# Trap exit to ensure poweroff runs
trap do_poweroff EXIT
# Rebuild the argv: each element is base64-encoded, elements are separated
# by commas. Decoding into "$@" avoids any shell re-parsing of the command.
set --
if [ -n "$ECR_ARGV" ]; then
for enc in $(printf '%s' "$ECR_ARGV" | tr ',' ' '); do
dec=$(printf '%s' "$enc" | base64 -d 2>/dev/null)
set -- "$@" "$dec"
done
fi
# Run the requested command verbatim, or an interactive shell in its own
# session (setsid enables job control on the serial console)
if [ "$#" -gt 0 ]; then
"$@"
else
setsid sh -c "exec $ECR_SHELL </dev/ttyS0 >/dev/ttyS0 2>&1"
fi
"#;
write_cpio_entry(writer, "init", 0o100755, 0, 1, 0, init_script.as_bytes())?;
// Write the trailer
newc::trailer(&mut *writer).context("Failed to write cpio trailer")?;
Ok(())
}
/// Write a single newc-format cpio entry
fn write_cpio_entry<W: Write>(
writer: &mut W,
name: &str,
mode: u32,
mtime: u32,
nlink: u32,
ino: u32,
data: &[u8],
) -> Result<()> {
let builder = NewcBuilder::new(name)
.mode(mode)
.uid(0)
.gid(0)
.ino(ino)
.nlink(nlink)
.mtime(mtime);
let mut entry_writer = builder.write(&mut *writer, data.len() as u32);
entry_writer
.write_all(data)
.with_context(|| format!("Failed to write {} to cpio archive", name))?;
entry_writer
.finish()
.with_context(|| format!("Failed to finish cpio entry {}", name))?;
Ok(())
}
/// Walk a directory tree, writing every entry to the cpio archive as it goes
/// Hard links are handled by assigning a synthetic inode to the first
/// occurrence of a (device, inode) pair; subsequent occurrences are written
/// as zero-size entries reusing that inode.
#[allow(clippy::too_many_arguments)]
fn write_dir_entries(
base: &Path,
current: &Path,
writer: &mut impl Write,
pb: &ProgressBar,
seen_inodes: &mut std::collections::HashMap<(u64, u64), u32>,
next_ino: &mut u32,
written_names: &mut std::collections::HashSet<String>,
total_data: &mut u64,
) -> Result<()> {
// Read directory entries
let dir_entries: Vec<_> = match std::fs::read_dir(current) {
Ok(entries) => entries.collect::<std::result::Result<_, _>>()?,
Err(e) => {
veprintln!(
"Warning: cannot read directory {}: {}",
current.display(),
e
);
return Ok(());
}
};
for entry in dir_entries {
let path = entry.path();
// Get metadata
let metadata = match std::fs::symlink_metadata(&path) {
Ok(m) => m,
Err(e) => {
veprintln!(
"Warning: skipping {} due to metadata error: {}",
path.display(),
e
);
continue;
}
};
// Increment progress counter
pb.inc(1);
let file_type = metadata.file_type();
// Determine mode (file type + permissions from filesystem)
let mode = if file_type.is_dir() {
// Directory: preserve permissions, ensure at least rwx for owner
0o040000 | (metadata.mode() & 0o7777)
} else if file_type.is_symlink() {
0o120777 // symlink with rwxrwxrwx (permissions don't matter for symlinks)
} else if file_type.is_file() {
// Regular file: preserve permissions from filesystem
0o100000 | (metadata.mode() & 0o7777)
} else {
continue; // Skip other types (sockets, fifos, etc.)
};
// Build the entry name (relative path from base)
let relative = path.strip_prefix(base).unwrap();
let entry_name = relative.to_string_lossy().into_owned();
// Determine (data, nlink, cpio inode).
// The kernel's initramfs loader records the first entry of a hard-link
// group (the one carrying the data) and turns later zero-size entries
// with the same inode into sys_link calls.
let (data, nlink, cpio_ino) = if file_type.is_file() && metadata.nlink() > 1 {
let inode_key = (metadata.dev(), metadata.ino());
match seen_inodes.get(&inode_key) {
Some(seen_cpio_ino) => {
// Subsequent occurrence: zero-size hard-link entry
(Vec::new(), metadata.nlink() as u32, *seen_cpio_ino)
}
None => {
// First occurrence: read the data, assign a synthetic inode
let cpio_ino = *next_ino;
*next_ino = next_ino.wrapping_add(1);
seen_inodes.insert(inode_key, cpio_ino);
let data = match std::fs::read(&path) {
Ok(data) => data,
Err(e) => {
veprintln!("Warning: cannot read file {}: {}", path.display(), e);
continue;
}
};
(data, metadata.nlink() as u32, cpio_ino)
}
}
} else if file_type.is_file() {
// Regular file with nlink=1
let data = match std::fs::read(&path) {
Ok(data) => data,
Err(e) => {
veprintln!("Warning: cannot read file {}: {}", path.display(), e);
continue;
}
};
(data, 1, 0)
} else if file_type.is_symlink() {
match std::fs::read_link(&path) {
Ok(target) => (target.to_string_lossy().into_owned().into_bytes(), 1, 0),
Err(e) => {
veprintln!("Warning: cannot read symlink {}: {}", path.display(), e);
continue;
}
}
} else {
// Directory
(Vec::new(), 2, 0)
};
*total_data += data.len() as u64;
write_cpio_entry(
writer,
&entry_name,
mode,
metadata.mtime() as u32,
nlink,
cpio_ino,
&data,
)?;
written_names.insert(entry_name);
// Recurse into directories
if file_type.is_dir() {
write_dir_entries(
base,
&path,
writer,
pb,
seen_inodes,
next_ino,
written_names,
total_data,
)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read as _;
#[test]
fn test_machine_for_arch() {
// riscv64 must select the virt machine explicitly: the
// qemu-system-riscv64 default is `spike`, which has no PCI bus and
// no 16550 UART
assert_eq!(machine_for_arch("riscv64"), Some("virt"));
// Other architectures keep their emulator's default machine
assert_eq!(machine_for_arch("amd64"), None);
assert_eq!(machine_for_arch("x86_64"), None);
assert_eq!(machine_for_arch("arm64"), None);
assert_eq!(machine_for_arch("aarch64"), None);
assert_eq!(machine_for_arch("ppc64le"), None);
assert_eq!(machine_for_arch("s390x"), None);
}
#[test]
fn test_cpu_for_arch() {
// Ubuntu's riscv64 userland needs the RVA23 profile CPU; QEMU's
// default `rv64` CPU SIGILLs on it early in boot
assert_eq!(cpu_for_arch("riscv64"), Some("rva23s64"));
// Other architectures keep their emulator's default CPU
assert_eq!(cpu_for_arch("amd64"), None);
assert_eq!(cpu_for_arch("x86_64"), None);
assert_eq!(cpu_for_arch("arm64"), None);
assert_eq!(cpu_for_arch("aarch64"), None);
}
/// Parse a newc cpio archive into (name, ino, mode, nlink, file_size, data) tuples
fn parse_cpio(archive: Vec<u8>) -> Vec<(String, u32, u32, u32, u32, Vec<u8>)> {
let mut cursor = std::io::Cursor::new(archive);
let mut entries = Vec::new();
loop {
let mut reader = newc::Reader::new(&mut cursor).expect("valid cpio entry");
let name = reader.entry().name().to_string();
let ino = reader.entry().ino();
let mode = reader.entry().mode();
let nlink = reader.entry().nlink();
let file_size = reader.entry().file_size();
let mut data = Vec::new();
reader.read_to_end(&mut data).expect("read entry data");
let is_trailer = reader.entry().is_trailer();
// Skip the padding after the entry data before parsing the next one
reader.finish().expect("skip entry padding");
entries.push((name, ino, mode, nlink, file_size, data));
if is_trailer {
break;
}
}
entries
}
#[test]
fn test_hard_links_share_inode() {
let dir = tempfile::tempdir().unwrap();
// Two distinct hard-link groups, each with two names
std::fs::write(dir.path().join("group1.txt"), b"hello").unwrap();
std::fs::hard_link(
dir.path().join("group1.txt"),
dir.path().join("group1b.txt"),
)
.unwrap();
std::fs::write(dir.path().join("group2.txt"), b"world!").unwrap();
std::fs::hard_link(
dir.path().join("group2.txt"),
dir.path().join("group2b.txt"),
)
.unwrap();
let pb = ProgressBar::hidden();
let mut archive = Vec::new();
write_cpio_archive(dir.path(), &mut archive, &pb).unwrap();
let entries = parse_cpio(archive)
.into_iter()
.map(|(name, ino, _, nlink, size, data)| (name, (ino, nlink, size, data)))
.collect::<std::collections::HashMap<String, (u32, u32, u32, Vec<u8>)>>();
let get = |name: &str| entries[name].clone();
// Both names of a hard link share one synthetic (nonzero) inode.
// readdir order decides which occurrence is walked first, so exactly
// one of the two entries carries the data and the other is zero-size.
let (ino1, nlink1, size1, data1) = get("group1.txt");
let (ino1b, nlink1b, size1b, data1b) = get("group1b.txt");
assert_eq!(nlink1, 2);
assert_eq!(nlink1b, 2);
assert_eq!(ino1, ino1b, "both names of a hard link must share an inode");
assert_ne!(ino1, 0, "hard-link group must get a synthetic inode");
assert_eq!(size1 + size1b, 5);
assert_eq!(
data1
.iter()
.chain(data1b.iter())
.cloned()
.collect::<Vec<u8>>(),
b"hello".to_vec()
);
// The other group gets a different inode — this is what keeps the
// kernel from linking group2 names to group1's file
let (ino2, _, size2, data2) = get("group2.txt");
let (ino2b, _, size2b, data2b) = get("group2b.txt");
assert_eq!(ino2, ino2b);
assert_ne!(
ino2, ino1,
"distinct hard-link groups must not share an inode"
);
assert_eq!(size2 + size2b, 6);
assert_eq!(
data2
.iter()
.chain(data2b.iter())
.cloned()
.collect::<Vec<u8>>(),
b"world!".to_vec()
);
}
#[test]
fn test_regular_files_have_zero_inode_and_data() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("sub/plain.txt"), b"plain").unwrap();
let pb = ProgressBar::hidden();
let mut archive = Vec::new();
write_cpio_archive(dir.path(), &mut archive, &pb).unwrap();
let entries = parse_cpio(archive);
let find = |name: &str| {
entries
.iter()
.find(|(n, ..)| n == name)
.map(|(_, ino, mode, nlink, size, data)| (*ino, *mode, *nlink, *size, data.clone()))
.expect("entry missing")
};
// Regular files with nlink=1 keep inode 0 and must keep their data
let (ino, _, nlink, size, data) = find("sub/plain.txt");
assert_eq!(ino, 0);
assert_eq!((nlink, size), (1, 5));
assert_eq!(data, b"plain");
// The init script is always appended, executable, and non-empty
let (_, init_mode, _, init_size, init_data) = find("init");
assert_eq!(init_mode, 0o100755);
assert!(init_size > 0);
assert!(init_data.starts_with(b"#!/bin/sh"));
}
}
+612
View File
@@ -0,0 +1,612 @@
//! Cache-aware rootfs lifecycle: resolve an image reference, download
//! through the tarball cache, extract into a scratch directory, and
//! optionally persist a provisioned rootfs back into the cache.
//!
//! The hot-cell flow is [`RootfsCache::prepare_provisioned`]: provision
//! once (package installs, user setup, …), and every subsequent call —
//! in this or another process sharing the cache — hits the persisted
//! entry and skips both the download and the provisioning step:
//!
//! ```no_run
//! use ecr::{chroot, ExecOptions, PrepareRequest, RootfsCache};
//!
//! # fn main() -> anyhow::Result<()> {
//! let cache = RootfsCache::new(RootfsCache::default_dir()?);
//! let req = PrepareRequest::new("alpine:3.23");
//!
//! let rootfs = cache.prepare_provisioned(&req, |rootfs| {
//! let code = rootfs.exec(&ExecOptions {
//! command: vec!["apk".into(), "add".into(), "curl".into()],
//! env: chroot::default_env(rootfs.dir()),
//! ..ExecOptions::default()
//! })?;
//! if code != 0 {
//! anyhow::bail!("provisioning command failed with exit code {}", code);
//! }
//! Ok(())
//! })?;
//!
//! // Amortized run: the provisioned entry is hit directly
//! let code = rootfs.exec(&ExecOptions {
//! command: vec!["curl".into(), "--version".into()],
//! env: chroot::default_env(rootfs.dir()),
//! ..ExecOptions::default()
//! })?;
//! # let _ = code; Ok(())
//! # }
//! ```
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use tempfile::TempDir;
use crate::distro::{
map_arch, parse_image_ref, resolve_distro_url, resolve_distro_version, Distro, ImageSource,
};
use crate::download::{digest_sidecar, download_image, fetch_oci_digest};
use crate::exec::ExecOptions;
use crate::extract::extract_tarball;
use crate::veprintln;
/// Sidecar suffix marking a cache entry that holds a provisioned rootfs.
const PROVISIONED_SUFFIX: &str = "provisioned";
/// Sidecar path for a cache entry: `foo.tar.gz` → `foo.tar.gz.<suffix>`.
fn sidecar(path: &Path, suffix: &str) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(".");
s.push(suffix);
PathBuf::from(s)
}
/// The image tarball cache (default location: `~/.cache/ecr`).
#[derive(Debug, Clone)]
pub struct RootfsCache {
dir: PathBuf,
}
impl RootfsCache {
pub fn new(dir: impl Into<PathBuf>) -> Self {
RootfsCache { dir: dir.into() }
}
/// Default cache directory: `~/.cache/ecr` (or the platform equivalent).
pub fn default_dir() -> Result<PathBuf> {
dirs::cache_dir()
.map(|p| p.join("ecr"))
.ok_or_else(|| anyhow!("Could not determine cache directory"))
}
pub fn dir(&self) -> &Path {
&self.dir
}
/// Cache entry (tarball path) for an image source and architecture.
pub fn tarball_path(&self, source: &ImageSource, arch: &str) -> PathBuf {
self.dir.join(generate_cache_filename(source, arch))
}
/// Resolve `req`, download through the cache when needed, and extract
/// into a fresh scratch directory (removed when the returned
/// [`PreparedRootfs`] is dropped).
pub fn prepare(&self, req: &PrepareRequest) -> Result<PreparedRootfs> {
let arch = req.resolved_arch();
let source = resolve_source(&req.image, &arch)?;
let cache_path = self.tarball_path(&source, &arch);
// OCI images with a floating tag (":latest") need a freshness check:
// fetch the current manifest digest from the registry and compare it
// against the digest stored from the last download. Only re-pull when
// the digest has actually changed. On a network error we fall back to
// the cached image with a warning rather than hard-failing.
let oci_digest_changed = if cache_path.exists() {
if let ImageSource::OciImage {
registry,
repository,
tag,
..
} = &source
{
if tag == "latest" {
match fetch_oci_digest(registry, repository, tag) {
Ok(current) => {
let stored = std::fs::read_to_string(digest_sidecar(&cache_path)).ok();
stored.as_deref() != Some(current.trim())
}
Err(e) => {
eprintln!(
"Warning: could not check image freshness ({}); using cache",
e
);
false
}
}
} else {
false // pinned tags are assumed immutable
}
} else {
false
}
} else {
false // cache absent — download triggered by !cache_path.exists() below
};
// Download when not cached, explicitly requested, or the remote digest
// has moved
if req.no_cache || !cache_path.exists() || oci_digest_changed {
std::fs::create_dir_all(&self.dir).with_context(|| {
format!("Failed to create cache directory {}", self.dir.display())
})?;
download_image(&source, &cache_path, &arch)?;
} else {
veprintln!("Using cached tarball: {}", cache_path.display());
}
extract_prepared(cache_path, arch)
}
/// Prepare a provisioned rootfs with cache amortization: when the cache
/// entry is already marked provisioned it is extracted as-is; otherwise
/// the rootfs is prepared from the pristine image, `provision` runs
/// once, and the result is persisted into the cache so subsequent calls
/// (including from other processes sharing this cache) skip both the
/// download and the provisioning step.
///
/// A provisioned entry no longer tracks the upstream image: for
/// `:latest` OCI images the freshness check is skipped on a marker hit.
/// Re-provision by removing the entry and its `.provisioned` sidecar, or
/// by passing `no_cache: true` (which re-downloads, re-provisions and
/// re-persists).
pub fn prepare_provisioned<F>(
&self,
req: &PrepareRequest,
provision: F,
) -> Result<PreparedRootfs>
where
F: FnOnce(&PreparedRootfs) -> Result<()>,
{
let arch = req.resolved_arch();
let source = resolve_source(&req.image, &arch)?;
let cache_path = self.tarball_path(&source, &arch);
let marker = sidecar(&cache_path, PROVISIONED_SUFFIX);
if !req.no_cache && cache_path.exists() && marker.exists() {
veprintln!("Using provisioned cache entry: {}", cache_path.display());
return extract_prepared(cache_path, arch);
}
let rootfs = self.prepare(req)?;
provision(&rootfs)?;
rootfs.persist().with_context(|| {
format!(
"Failed to persist provisioned rootfs to {}",
cache_path.display()
)
})?;
Ok(rootfs)
}
}
/// Request for [`RootfsCache::prepare`] / [`RootfsCache::prepare_provisioned`].
#[derive(Debug, Clone)]
pub struct PrepareRequest {
/// Image reference: a distro name ("ubuntu", "ubuntu:noble",
/// "alpine:3.23") or an OCI reference ("debian",
/// "ghcr.io/org/image:tag", "docker://…").
pub image: String,
/// Target architecture (e.g. "amd64", "arm64"). Empty selects the host
/// architecture.
pub arch: String,
/// Ignore the cache and download a fresh image.
pub no_cache: bool,
}
impl PrepareRequest {
pub fn new(image: impl Into<String>) -> Self {
PrepareRequest {
image: image.into(),
arch: String::new(),
no_cache: false,
}
}
/// The effective architecture: the request value, or the host arch.
pub fn resolved_arch(&self) -> String {
if self.arch.is_empty() {
crate::utils::get_host_arch().debian_name().to_string()
} else {
self.arch.clone()
}
}
}
/// A rootfs extracted into a scratch directory, ready for [`ExecOptions`] /
/// [`crate::exec`] or QEMU. The directory is removed when this value is
/// dropped, unless [`PreparedRootfs::persist`] ran first.
#[derive(Debug)]
pub struct PreparedRootfs {
rootfs: TempDir,
cache_path: PathBuf,
arch: String,
}
impl PreparedRootfs {
/// The extracted rootfs directory.
pub fn dir(&self) -> &Path {
self.rootfs.path()
}
/// Architecture this rootfs was prepared for.
pub fn arch(&self) -> &str {
&self.arch
}
/// The cache entry this rootfs was prepared from.
pub fn cache_entry(&self) -> &Path {
&self.cache_path
}
/// Run a command inside the rootfs (see [`crate::exec`]). When
/// `opts.arch` is empty, this rootfs's architecture is used.
pub fn exec(&self, opts: &ExecOptions) -> Result<i32> {
if opts.arch.is_empty() {
let mut opts = opts.clone();
opts.arch = self.arch.clone();
return crate::exec::exec(self.dir(), &opts);
}
crate::exec::exec(self.dir(), opts)
}
/// Persist the current contents of the rootfs into the cache entry it
/// was prepared from, replacing the pristine image tarball, and mark
/// the entry provisioned. Later preparations for the same image and
/// architecture hit this entry directly; note that plain
/// [`RootfsCache::prepare`] also returns the provisioned contents
/// (indistinguishable from a pristine hit) — only
/// [`RootfsCache::prepare_provisioned`] interprets the marker.
pub fn persist(&self) -> Result<()> {
pack_rootfs(self.dir(), &self.cache_path)?;
std::fs::write(sidecar(&self.cache_path, PROVISIONED_SUFFIX), b"").with_context(|| {
format!(
"Failed to write provisioned marker for {}",
self.cache_path.display()
)
})?;
veprintln!("Persisted rootfs to cache: {}", self.cache_path.display());
Ok(())
}
}
/// Parse an image reference and resolve floating version aliases ("latest",
/// "lts", "edge", …) to a concrete version, so the cache key is stable
/// (e.g. "ubuntu-noble-amd64", not "ubuntu-latest-amd64") and a future
/// release automatically gets its own cache entry.
fn resolve_source(image: &str, arch: &str) -> Result<ImageSource> {
let source = parse_image_ref(image, arch)?;
match source {
ImageSource::DirectTarball { distro, version } => {
let resolved = resolve_distro_version(&distro, version.as_deref(), arch)?;
Ok(ImageSource::DirectTarball {
distro,
version: Some(resolved),
})
}
other => Ok(other),
}
}
fn extract_prepared(cache_path: PathBuf, arch: String) -> Result<PreparedRootfs> {
let temp = tempfile::tempdir().context("Failed to create temporary directory")?;
let rootfs = temp.path().to_path_buf();
veprintln!("Extracting to: {}", rootfs.display());
extract_tarball(&cache_path, &rootfs)?;
Ok(PreparedRootfs {
rootfs: temp,
cache_path,
arch,
})
}
/// Write a tarball of `rootfs` at `dest`, compressed according to dest's
/// extension (.tar.gz, .tar.xz, .tar.zst, plain .tar), atomically via a
/// `.partial` file.
fn pack_rootfs(rootfs: &Path, dest: &Path) -> Result<()> {
// Packing the tree into a tarball placed inside it would recurse
// forever: the walker keeps seeing the archive grow.
let rootfs_canonical = rootfs
.canonicalize()
.with_context(|| format!("Failed to resolve rootfs directory {}", rootfs.display()))?;
if let Some(parent) = dest.parent() {
if let Ok(parent_canonical) = parent.canonicalize() {
if parent_canonical.starts_with(&rootfs_canonical) {
return Err(anyhow!(
"Cache entry {} must live outside the rootfs",
dest.display()
));
}
}
}
let filename = dest.file_name().and_then(|n| n.to_str()).unwrap_or("");
let partial = dest.with_extension("partial");
let file = std::fs::File::create(&partial)
.with_context(|| format!("Failed to create {}", partial.display()))?;
let result: Result<()> = (|| {
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
let encoder = write_rootfs_tar(rootfs, encoder)?;
encoder.finish().context("Failed to finalise gzip stream")?;
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
let encoder = xz2::write::XzEncoder::new(file, 6);
let encoder = write_rootfs_tar(rootfs, encoder)?;
encoder.finish().context("Failed to finalise xz stream")?;
} else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") {
let encoder = zstd::stream::write::Encoder::new(file, 0)
.context("Failed to create zstd encoder")?;
let encoder = write_rootfs_tar(rootfs, encoder)?;
encoder.finish().context("Failed to finalise zstd stream")?;
} else {
write_rootfs_tar(rootfs, file)?;
}
Ok(())
})();
if result.is_err() {
// Best-effort cleanup; ignore errors (file may not exist if creation failed).
let _ = std::fs::remove_file(&partial);
return result;
}
std::fs::rename(&partial, dest)
.with_context(|| format!("Failed to move packed rootfs to {}", dest.display()))
}
/// Write the rootfs tree into a tar archive streamed to `writer`, returning
/// the writer so the caller can finalise the compression stream.
fn write_rootfs_tar<W: std::io::Write>(rootfs: &Path, writer: W) -> Result<W> {
let mut builder = tar::Builder::new(writer);
// Store symlinks as symlinks; following them would duplicate the target's
// contents into the archive (and recurse into symlinked directories).
builder.follow_symlinks(false);
let entries = std::fs::read_dir(rootfs)
.with_context(|| format!("Failed to read {}", rootfs.display()))?;
for entry in entries {
let entry =
entry.with_context(|| format!("Failed to read entry in {}", rootfs.display()))?;
let path = entry.path();
let name = entry.file_name();
// symlink_metadata: never follow symlinks — a top-level symlink to a
// directory must be stored as a link, not recursed into.
let meta = std::fs::symlink_metadata(&path)
.with_context(|| format!("Failed to stat {}", path.display()))?;
if meta.is_dir() {
builder
.append_dir_all(&name, &path)
.with_context(|| format!("Failed to archive {}", path.display()))?;
} else {
builder
.append_path_with_name(&path, &name)
.with_context(|| format!("Failed to archive {}", path.display()))?;
}
}
builder
.into_inner()
.context("Failed to finalise tar archive")
}
/// Generate a cache filename based on the image source
fn generate_cache_filename(source: &ImageSource, arch: &str) -> String {
match source {
ImageSource::DirectTarball { distro, version } => {
let distro_name = match distro {
Distro::Ubuntu => "ubuntu",
Distro::Alpine => "alpine",
};
let distro_arch = map_arch(*distro, arch);
// Get extension from URL
let url = resolve_distro_url(distro, version.as_deref(), arch).unwrap_or_default();
let ext = get_tarball_extension(&url);
format!(
"{}-{}-{}.{}",
distro_name,
version.as_deref().unwrap_or("latest"),
distro_arch,
ext
)
}
ImageSource::OciImage {
registry,
repository,
tag,
architecture,
} => {
// Sanitize for filename
let safe_registry = registry.replace(['.', ':'], "_");
let safe_repo = repository.replace(['/', ':'], "_");
format!(
"oci-{}-{}-{}-{}.tar.gz",
safe_registry, safe_repo, tag, architecture
)
}
}
}
fn get_tarball_extension(url: &str) -> &str {
// Extract extension from URL (e.g., .tar.gz, .tar.xz, .tar.zst)
if url.ends_with(".tar.zst") {
"tar.zst"
} else if url.ends_with(".tar.xz") {
"tar.xz"
} else if url.ends_with(".tar.gz") {
"tar.gz"
} else if url.ends_with(".tar.bz2") {
"tar.bz2"
} else {
"tar.gz" // default
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn direct_tarball(distro: Distro, version: &str) -> ImageSource {
ImageSource::DirectTarball {
distro,
version: Some(version.to_string()),
}
}
#[test]
fn cache_filename_direct_tarball() {
// Explicit codename/version: no network lookups
let cache = RootfsCache::new("/cache");
assert_eq!(
cache.tarball_path(&direct_tarball(Distro::Ubuntu, "noble"), "amd64"),
PathBuf::from("/cache/ubuntu-noble-amd64.tar.gz")
);
assert_eq!(
cache.tarball_path(&direct_tarball(Distro::Alpine, "3.23.1"), "amd64"),
PathBuf::from("/cache/alpine-3.23.1-x86_64.tar.gz")
);
}
#[test]
fn cache_filename_oci_sanitized() {
let cache = RootfsCache::new("/cache");
let source = ImageSource::OciImage {
registry: "docker.io".to_string(),
repository: "library/ubuntu".to_string(),
tag: "latest".to_string(),
architecture: "amd64".to_string(),
};
assert_eq!(
cache.tarball_path(&source, "amd64"),
PathBuf::from("/cache/oci-docker_io-library_ubuntu-latest-amd64.tar.gz")
);
}
#[test]
fn resolved_arch_defaults_to_host() {
let req = PrepareRequest::new("alpine");
assert_eq!(
req.resolved_arch(),
crate::utils::get_host_arch().debian_name()
);
let mut req = PrepareRequest::new("alpine");
req.arch = "arm64".to_string();
assert_eq!(req.resolved_arch(), "arm64");
}
#[test]
fn sidecar_appends_suffix() {
assert_eq!(
sidecar(Path::new("/cache/foo.tar.gz"), PROVISIONED_SUFFIX),
PathBuf::from("/cache/foo.tar.gz.provisioned")
);
}
/// Build a minimal rootfs tree with a nested executable and a symlink,
/// pack it, and extract it back through the public extract_tarball path.
fn roundtrip(dest_name: &str) {
let src = tempfile::tempdir().unwrap();
std::fs::write(src.path().join("hello.txt"), b"hello").unwrap();
std::fs::create_dir_all(src.path().join("usr/bin")).unwrap();
std::fs::write(src.path().join("usr/bin/tool"), b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(
src.path().join("usr/bin/tool"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
std::os::unix::fs::symlink("hello.txt", src.path().join("link.txt")).unwrap();
let cache = tempfile::tempdir().unwrap();
let dest = cache.path().join(dest_name);
pack_rootfs(src.path(), &dest).unwrap();
let out = tempfile::tempdir().unwrap();
crate::extract::extract_tarball(&dest, out.path()).unwrap();
assert_eq!(
std::fs::read(out.path().join("hello.txt")).unwrap(),
b"hello"
);
let mode = std::fs::metadata(out.path().join("usr/bin/tool"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o755, "executable bit must survive");
assert_eq!(
std::fs::read_link(out.path().join("link.txt")).unwrap(),
Path::new("hello.txt")
);
}
#[test]
fn persist_roundtrip_gzip() {
roundtrip("rootfs.tar.gz");
}
#[test]
fn persist_roundtrip_xz() {
roundtrip("rootfs.tar.xz");
}
#[test]
fn persist_roundtrip_zstd() {
roundtrip("rootfs.tar.zst");
}
#[test]
fn persist_roundtrip_plain_tar() {
roundtrip("rootfs.tar");
}
#[test]
fn persist_writes_entry_and_provisioned_marker() {
let rootfs_dir = tempfile::tempdir().unwrap();
std::fs::write(rootfs_dir.path().join("file.txt"), b"data").unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let cache_path = cache_dir.path().join("ubuntu-noble-amd64.tar.gz");
let prepared = PreparedRootfs {
rootfs: rootfs_dir,
cache_path: cache_path.clone(),
arch: "amd64".to_string(),
};
prepared.persist().unwrap();
assert!(cache_path.exists());
assert!(sidecar(&cache_path, PROVISIONED_SUFFIX).exists());
// The persisted entry extracts back to the original contents
let out = tempfile::tempdir().unwrap();
crate::extract::extract_tarball(&cache_path, out.path()).unwrap();
assert_eq!(std::fs::read(out.path().join("file.txt")).unwrap(), b"data");
}
#[test]
fn pack_failure_leaves_no_partial_file() {
// A nonexistent rootfs must fail cleanly and clean up the .partial
let cache = tempfile::tempdir().unwrap();
let dest = cache.path().join("x.tar.gz");
assert!(pack_rootfs(Path::new("/nonexistent-rootfs"), &dest).is_err());
assert!(!dest.exists());
assert!(!dest.with_extension("partial").exists());
}
}
+288
View File
@@ -0,0 +1,288 @@
use anyhow::{anyhow, Result};
use std::path::Path;
// ============================================================================
// Constants
// ============================================================================
/// Stack size for child processes in namespace cloning (1 MiB)
pub const CHILD_STACK_SIZE: usize = 1024 * 1024;
/// Buffer size for reading error messages from pipes (4 KiB, typical page size)
pub const ERROR_BUFFER_SIZE: usize = 4096;
/// Maximum entropy bits for hostname suffix (24 bits = 6 hex chars)
pub const HOSTNAME_SUFFIX_BITS: u64 = 0x1000000;
/// Maximum entropy bits for VM hostname suffix (24 bits = 6 hex chars)
pub const VM_HOSTNAME_SUFFIX_BITS: u64 = 0x1000000;
// ============================================================================
// Architecture handling
// ============================================================================
/// Architecture representation in different naming conventions
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Arch {
/// x86-64 (AMD64, x86_64)
Amd64,
/// ARM 64-bit (AArch64)
Arm64,
/// ARM 32-bit hard-float
Armhf,
/// RISC-V 64-bit
Riscv64,
/// PowerPC 64-bit little-endian
Ppc64el,
/// IBM s390x
S390x,
/// Unknown architecture
Unknown,
}
impl Arch {
/// Get the architecture from a string (any common naming convention)
/// Not a FromStr impl: unrecognized names map to Arch::Unknown rather
/// than an error, by design.
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s {
"amd64" | "x86_64" | "x64" => Arch::Amd64,
"arm64" | "aarch64" | "arm64v8" => Arch::Arm64,
"armhf" | "armv7" | "armv7l" | "arm" => Arch::Armhf,
"riscv64" => Arch::Riscv64,
"ppc64el" | "ppc64le" => Arch::Ppc64el,
"s390x" => Arch::S390x,
_ => Arch::Unknown,
}
}
/// Get the Debian/Ubuntu style name
pub fn debian_name(&self) -> &'static str {
match self {
Arch::Amd64 => "amd64",
Arch::Arm64 => "arm64",
Arch::Armhf => "armhf",
Arch::Riscv64 => "riscv64",
Arch::Ppc64el => "ppc64el",
Arch::S390x => "s390x",
Arch::Unknown => "unknown",
}
}
/// Get the OCI/Docker registry style name
pub fn oci_name(&self) -> &'static str {
match self {
Arch::Amd64 => "amd64",
Arch::Arm64 => "arm64",
// OCI uses "arm" for 32-bit ARM with variant field
Arch::Armhf => "arm",
Arch::Riscv64 => "riscv64",
Arch::Ppc64el => "ppc64le",
Arch::S390x => "s390x",
Arch::Unknown => "unknown",
}
}
/// Get the Alpine style name
pub fn alpine_name(&self) -> &'static str {
match self {
Arch::Amd64 => "x86_64",
Arch::Arm64 => "aarch64",
Arch::Armhf => "armv7",
Arch::Riscv64 => "riscv64",
Arch::Ppc64el => "ppc64le",
Arch::S390x => "s390x",
Arch::Unknown => "unknown",
}
}
/// Get the QEMU binary suffix (e.g., "qemu-system-x86_64")
pub fn qemu_system_name(&self) -> &'static str {
match self {
Arch::Amd64 => "x86_64",
Arch::Arm64 => "aarch64",
Arch::Armhf => "arm",
Arch::Riscv64 => "riscv64",
Arch::Ppc64el => "ppc64",
Arch::S390x => "s390x",
Arch::Unknown => "unknown",
}
}
/// Get the QEMU binfmt_misc name
pub fn qemu_binfmt_name(&self) -> &'static str {
match self {
Arch::Amd64 => "x86_64",
Arch::Arm64 => "aarch64",
Arch::Armhf => "arm",
Arch::Riscv64 => "riscv64",
Arch::Ppc64el => "ppc64le",
Arch::S390x => "s390x",
Arch::Unknown => "unknown",
}
}
/// Get the package suffix for QEMU system emulator
pub fn qemu_package_suffix(&self) -> &'static str {
match self {
Arch::Amd64 => "x86",
Arch::Arm64 => "aarch64",
Arch::Armhf => "arm",
Arch::Riscv64 => "riscv64",
Arch::Ppc64el => "ppc",
Arch::S390x => "s390x",
Arch::Unknown => "unknown",
}
}
}
/// Get the host system architecture using uname(2) syscall
/// This returns the runtime machine string, which is correct even when
/// the binary itself is running under emulation.
pub fn get_host_arch() -> Arch {
let utsname = nix::sys::utsname::uname()
.expect("uname(2) syscall failed — cannot determine host architecture");
let machine = utsname.machine().to_string_lossy();
Arch::from_str(machine.as_ref())
}
/// Map ecr architecture names to distro-specific names
pub fn map_arch_for_distro(distro: &str, arch: &str) -> String {
let arch_enum = Arch::from_str(arch);
match distro.to_lowercase().as_str() {
"ubuntu" => arch_enum.debian_name().to_string(),
"alpine" => arch_enum.alpine_name().to_string(),
_ => arch_enum.oci_name().to_string(),
}
}
/// Map architecture to OCI registry standard names
pub fn map_oci_arch(arch: &str) -> String {
Arch::from_str(arch).oci_name().to_string()
}
// ============================================================================
// Shell detection
// ============================================================================
/// Detect the best available shell in a rootfs
/// Checks for bash first, falls back to sh
/// Returns the path relative to the rootfs (e.g., "/bin/bash")
pub fn detect_shell(rootfs: &Path) -> &'static str {
// Check for bash first (preferred)
if rootfs.join("bin/bash").exists() {
"/bin/bash"
} else if rootfs.join("bin/sh").exists() {
"/bin/sh"
} else if rootfs.join("usr/bin/bash").exists() {
"/usr/bin/bash"
} else if rootfs.join("usr/bin/sh").exists() {
"/usr/bin/sh"
} else {
"/bin/sh" // Will fail with clear error if not present
}
}
// ============================================================================
// Memory string validation
// ============================================================================
/// Validate a QEMU memory size string (e.g., "512M", "2G")
/// Returns an error if the format is invalid
pub fn validate_memory_string(s: &str) -> Result<()> {
if s.is_empty() {
return Err(anyhow!("Memory size cannot be empty"));
}
// Must end with a valid suffix or be a plain number
let suffix = s.chars().last().unwrap();
let has_suffix = suffix.is_ascii_alphabetic();
let numeric_part = if has_suffix { &s[..s.len() - 1] } else { s };
// Check for negative numbers
if numeric_part.starts_with('-') {
return Err(anyhow!("Memory size cannot be negative: {}", s));
}
// Must be a valid positive number
if numeric_part.is_empty() {
return Err(anyhow!("Memory size must have a numeric value: {}", s));
}
// Check if it's a valid integer
if !numeric_part.chars().all(|c| c.is_ascii_digit()) {
return Err(anyhow!(
"Memory size must be a positive integer with optional suffix: {}",
s
));
}
// Check suffix is valid
if has_suffix {
let valid_suffixes = ['K', 'M', 'G', 'T'];
let suffix_upper = suffix.to_ascii_uppercase();
if !valid_suffixes.contains(&suffix_upper) {
return Err(anyhow!(
"Invalid memory suffix '{}'. Valid suffixes: K, M, G, T",
suffix
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_arch_from_str() {
assert_eq!(Arch::from_str("amd64"), Arch::Amd64);
assert_eq!(Arch::from_str("x86_64"), Arch::Amd64);
assert_eq!(Arch::from_str("arm64"), Arch::Arm64);
assert_eq!(Arch::from_str("aarch64"), Arch::Arm64);
assert_eq!(Arch::from_str("armhf"), Arch::Armhf);
assert_eq!(Arch::from_str("armv7"), Arch::Armhf);
assert_eq!(Arch::from_str("riscv64"), Arch::Riscv64);
assert_eq!(Arch::from_str("ppc64el"), Arch::Ppc64el);
assert_eq!(Arch::from_str("ppc64le"), Arch::Ppc64el);
assert_eq!(Arch::from_str("s390x"), Arch::S390x);
}
#[test]
fn test_arch_alpine_name() {
assert_eq!(Arch::Amd64.alpine_name(), "x86_64");
assert_eq!(Arch::Arm64.alpine_name(), "aarch64");
assert_eq!(Arch::Armhf.alpine_name(), "armv7");
}
#[test]
fn test_arch_oci_name() {
assert_eq!(Arch::Amd64.oci_name(), "amd64");
assert_eq!(Arch::Arm64.oci_name(), "arm64");
assert_eq!(Arch::Armhf.oci_name(), "arm");
}
#[test]
fn test_validate_memory_string_valid() {
assert!(validate_memory_string("512M").is_ok());
assert!(validate_memory_string("2G").is_ok());
assert!(validate_memory_string("1024").is_ok());
assert!(validate_memory_string("1T").is_ok());
assert!(validate_memory_string("256K").is_ok());
assert!(validate_memory_string("2g").is_ok()); // lowercase
}
#[test]
fn test_validate_memory_string_invalid() {
assert!(validate_memory_string("").is_err());
assert!(validate_memory_string("-1G").is_err());
assert!(validate_memory_string("2X").is_err());
assert!(validate_memory_string("abc").is_err());
assert!(validate_memory_string("G").is_err());
assert!(validate_memory_string("1.5G").is_err());
}
}
-144
View File
@@ -1,144 +0,0 @@
use crate::veprintln;
use anyhow::{anyhow, Context, Result};
use nix::unistd::{chroot, execve};
use std::collections::HashMap;
use std::path::Path;
/// Run a command in the chroot environment
pub fn run_chroot(
rootfs: &Path,
command: Option<Vec<String>>,
bind_rw_paths: &[std::path::PathBuf],
) -> Result<()> {
// Get TERM from host before chroot
let host_term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string());
// Set hostname in UTS namespace
if let Err(e) = crate::namespace::set_hostname("chroot") {
eprintln!("Warning: Failed to set hostname: {}", e);
}
// Change to root directory in chroot
chroot(rootfs).context("Failed to chroot")?;
// Now we're inside the chroot - set up environment based on chroot filesystem
// Determine shell path (check inside chroot, not host)
let shell = if Path::new("/bin/bash").exists() {
"/bin/bash"
} else if Path::new("/bin/sh").exists() {
"/bin/sh"
} else if Path::new("/usr/bin/bash").exists() {
"/usr/bin/bash"
} else if Path::new("/usr/bin/sh").exists() {
"/usr/bin/sh"
} else {
"/bin/sh" // Will fail with clear error if not present
};
// Set up environment variables (after chroot, so paths are correct)
let env = setup_environment(shell, &host_term);
// Determine the command to run
let (program, args) = match command {
Some(cmd) if !cmd.is_empty() => {
let program = cmd[0].clone();
let args = cmd
.iter()
.map(|s| {
std::ffi::CString::new(s.as_str())
.with_context(|| format!("Argument contains a null byte: {:?}", s))
})
.collect::<Result<Vec<_>>>()?;
(program, args)
}
_ => {
// Run shell (already determined above based on chroot filesystem)
let program = shell.to_string();
let args =
vec![std::ffi::CString::new(shell).context("Shell path contains a null byte")?];
(program, args)
}
};
// Build an explicit envp from setup_environment so the host environment
// is never inherited. execve takes this array directly; the host process
// environment is not touched at all.
let env_cstrings = env
.iter()
.map(|(k, v)| {
std::ffi::CString::new(format!("{}={}", k, v))
.with_context(|| format!("Environment variable contains a null byte: {}={}", k, v))
})
.collect::<Result<Vec<_>>>()?;
// Change to first bind_rw directory if available, otherwise /root, otherwise /
// bind_rw paths are mounted at /mnt/<basename> (see mount.rs setup_bind_rw)
let working_dir = if let Some(first_bind_rw) = bind_rw_paths.first() {
let dest_dir = Path::new("/mnt").join(first_bind_rw.file_name().unwrap_or_default());
if dest_dir.exists() {
dest_dir
} else if Path::new("/root").exists() {
Path::new("/root").to_path_buf()
} else {
Path::new("/").to_path_buf()
}
} else if Path::new("/root").exists() {
Path::new("/root").to_path_buf()
} else {
Path::new("/").to_path_buf()
};
std::env::set_current_dir(&working_dir).context("Failed to change to working directory")?;
// Print welcome message
veprintln!("Entering chroot at {}", rootfs.display());
for path in bind_rw_paths {
let basename = path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
veprintln!("Read-write mount: /mnt/{}", basename);
}
veprintln!("Working directory: {}", working_dir.display());
// Check if the program exists
if !Path::new(&program).exists() {
// Try to find it in PATH
let found = env.get("PATH").and_then(|path| {
path.split(':')
.map(|p| std::path::PathBuf::from(p).join(&program))
.find(|p| p.exists())
});
if found.is_none() {
return Err(anyhow!("Program not found: {}", program));
}
}
// Exec the program directly with an explicit, isolated environment.
// execve never returns on success.
let program_cstr = std::ffi::CString::new(program.as_str()).context("Invalid program name")?;
let result = execve(&program_cstr, &args, &env_cstrings);
match result {
Ok(_) => Ok(()), // Never reached
Err(e) => Err(anyhow!("Failed to exec {}: {}", program, e)),
}
}
/// Setup default environment variables for chroot
/// Must be called AFTER chroot so paths are resolved inside the chroot
fn setup_environment(shell: &str, term: &str) -> HashMap<&'static str, String> {
let mut env = HashMap::new();
env.insert("HOME", "/root".to_string());
env.insert("USER", "root".to_string());
env.insert("SHELL", shell.to_string());
env.insert("TERM", term.to_string());
env.insert(
"PATH",
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
);
env
}
-300
View File
@@ -1,300 +0,0 @@
mod chroot;
mod cli;
mod config;
mod distro;
mod download;
mod extract;
mod mount;
mod namespace;
mod qemu;
mod verbose;
/// Print to stderr only when --verbose / -v is active.
#[macro_export]
macro_rules! veprintln {
($($arg:tt)*) => {
if $crate::verbose::is_verbose() {
eprintln!($($arg)*);
}
};
}
use anyhow::Result;
use clap::Parser;
use cli::Args;
use config::Config;
use distro::{
map_arch, parse_image_ref, resolve_distro_url, resolve_distro_version, Distro, ImageSource,
};
use download::{digest_sidecar, download_image, fetch_oci_digest};
use extract::extract_tarball;
fn main() -> Result<()> {
let args = Args::parse();
// Initialise verbosity before anything else so all downstream code can use veprintln!.
verbose::set(args.verbose);
// Load config file
let config = Config::load()?;
// Get architecture
let host_arch = get_host_arch();
let arch = args.arch.clone().unwrap_or_else(|| host_arch.clone());
// Parse image reference
let image_source = parse_image_ref(&args.distro, &arch)?;
// For DirectTarball, resolve floating aliases ("latest", "lts") to a concrete
// version string *before* computing the cache key. This ensures we cache as
// e.g. "ubuntu-noble-amd64" rather than "ubuntu-latest-amd64", so a future
// release automatically gets its own cache entry.
let image_source = match image_source {
ImageSource::DirectTarball { distro, version } => {
let resolved = resolve_distro_version(&distro, version.as_deref(), &arch)?;
ImageSource::DirectTarball {
distro,
version: Some(resolved),
}
}
other => other,
};
// Determine cache directory and filename
let cache_dir = dirs::cache_dir()
.expect("Could not determine cache directory")
.join("ecr");
let cache_filename = generate_cache_filename(&image_source, &arch);
let cache_path = cache_dir.join(&cache_filename);
// OCI images with a floating tag (":latest") need a freshness check:
// fetch the current manifest digest from the registry and compare it
// against the digest stored from the last download. Only re-pull when
// the digest has actually changed. On a network error we fall back to
// the cached image with a warning rather than hard-failing.
let oci_digest_changed = if cache_path.exists() {
if let ImageSource::OciImage {
registry,
repository,
tag,
..
} = &image_source
{
if tag == "latest" {
match fetch_oci_digest(registry, repository, tag) {
Ok(current) => {
let stored = std::fs::read_to_string(digest_sidecar(&cache_path)).ok();
stored.as_deref() != Some(current.trim())
}
Err(e) => {
eprintln!(
"Warning: could not check image freshness ({}); using cache",
e
);
false
}
}
} else {
false // pinned tags are assumed immutable
}
} else {
false
}
} else {
false // cache absent — download triggered by !cache_path.exists() below
};
// Download if not cached, --no-cache, or the remote digest has moved
if args.no_cache || !cache_path.exists() || oci_digest_changed {
std::fs::create_dir_all(&cache_dir)?;
download_image(&image_source, &cache_path, &arch)?;
} else {
veprintln!("Using cached tarball: {}", cache_path.display());
}
// Check QEMU if foreign architecture
if arch != host_arch {
qemu::check_binfmt(&arch)?;
}
// Check user namespace availability
namespace::check_user_namespace()?;
// Process bind paths - use current directory if none specified
let cwd = std::env::current_dir().expect("Could not get current directory");
let bind_paths: Vec<std::path::PathBuf> = if args.bind.is_empty() && !args.no_bind {
vec![cwd.clone()]
} else {
args.bind.clone()
};
// --no-bind means "skip mounting any directory". Combining it with an
// explicit --bind-rw is contradictory; error rather than silently ignoring
// the flag the user asked for.
if args.no_bind && !args.bind_rw.is_empty() {
return Err(anyhow::anyhow!(
"--no-bind and --bind-rw cannot be used together: \
--no-bind skips all mounts, including read-write ones"
));
}
let bind_rw_paths: Vec<std::path::PathBuf> = args.bind_rw.clone();
// Create temp directory for extraction
let temp_dir = tempfile::tempdir()?;
let rootfs = temp_dir.path().to_path_buf();
veprintln!("Extracting to: {}", rootfs.display());
extract_tarball(&cache_path, &rootfs)?;
// Prepare data for the closure
let bind_paths_clone = bind_paths.clone();
let bind_rw_paths_clone = bind_rw_paths.clone();
let args_clone = args.clone();
let rootfs_clone = rootfs.clone();
let dns_clone = config.dns.clone();
// Run in namespace
let result = namespace::setup_namespaces(move || -> Result<()> {
// Setup mounts - overlay_temps must be kept alive for overlay to work
let overlay_temps = mount::setup_mounts(
&rootfs_clone,
&bind_paths_clone,
&bind_rw_paths_clone,
&args_clone,
)?;
// Write resolv.conf with DNS from config
write_resolv_conf(&rootfs_clone, &dns_clone)?;
// Run chroot
let command = if args_clone.command.is_empty() {
None
} else {
Some(args_clone.command.clone())
};
let result = chroot::run_chroot(&rootfs_clone, command, &bind_rw_paths_clone);
// Keep overlay_temps alive until chroot exits
drop(overlay_temps);
result
});
// Cleanup happens automatically via tempfile
if result.is_ok() {
veprintln!("Cleanup complete.");
}
result
}
/// Generate a cache filename based on the image source
fn generate_cache_filename(source: &ImageSource, arch: &str) -> String {
match source {
ImageSource::DirectTarball { distro, version } => {
let distro_name = match distro {
Distro::Ubuntu => "ubuntu",
Distro::Alpine => "alpine",
};
let distro_arch = map_arch(*distro, arch);
// Get extension from URL
let url = resolve_distro_url(distro, version.as_deref(), arch).unwrap_or_default();
let ext = get_tarball_extension(&url);
format!(
"{}-{}-{}.{}",
distro_name,
version.as_deref().unwrap_or("latest"),
distro_arch,
ext
)
}
ImageSource::OciImage {
registry,
repository,
tag,
architecture,
} => {
// Sanitize for filename
let safe_registry = registry.replace(['.', ':'], "_");
let safe_repo = repository.replace(['/', ':'], "_");
format!(
"oci-{}-{}-{}-{}.tar.gz",
safe_registry, safe_repo, tag, architecture
)
}
}
}
fn get_tarball_extension(url: &str) -> &str {
// Extract extension from URL (e.g., .tar.gz, .tar.xz, .tar.zst)
if url.ends_with(".tar.zst") {
"tar.zst"
} else if url.ends_with(".tar.xz") {
"tar.xz"
} else if url.ends_with(".tar.gz") {
"tar.gz"
} else if url.ends_with(".tar.bz2") {
"tar.bz2"
} else {
"tar.gz" // default
}
}
fn get_host_arch() -> String {
// Use the uname(2) syscall directly — no subprocess, no PATH dependency,
// no panic-on-missing-binary. This gives the runtime machine string
// (e.g. "x86_64", "aarch64") exactly as `uname -m` would, which is what
// we need for the QEMU check. std::env::consts::ARCH is compile-time and
// would be wrong if the binary itself is running under emulation.
let utsname = nix::sys::utsname::uname()
.expect("uname(2) syscall failed — cannot determine host architecture");
let machine = utsname.machine().to_string_lossy();
match machine.as_ref() {
"x86_64" => "amd64".to_string(),
"aarch64" => "arm64".to_string(),
"armv7l" | "armv7" => "armhf".to_string(),
"riscv64" => "riscv64".to_string(),
"ppc64le" => "ppc64el".to_string(),
"s390x" => "s390x".to_string(),
other => other.to_string(),
}
}
fn write_resolv_conf(rootfs: &std::path::Path, dns: &[String]) -> Result<()> {
let resolv_conf = rootfs.join("etc/resolv.conf");
// Create /etc if it doesn't exist
if let Some(parent) = resolv_conf.parent() {
std::fs::create_dir_all(parent)?;
}
// Copy host's resolv.conf if dns is empty, otherwise use provided DNS
let content = if dns.is_empty() {
// Try to copy from host
match std::fs::read_to_string("/etc/resolv.conf") {
Ok(host_resolv) => host_resolv,
Err(_) => "nameserver 1.1.1.1\nnameserver 8.8.8.8\n".to_string(),
}
} else {
let mut c = dns
.iter()
.map(|s| format!("nameserver {}", s))
.collect::<Vec<_>>()
.join("\n");
c.push('\n');
c
};
// Remove any existing file or symlink before writing so that std::fs::write
// always creates a plain file. Without this, an absolute symlink such as
// /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf would cause the
// write to follow the symlink through the *host* root (chroot() has not been
// called yet) and corrupt the host's DNS configuration.
let _ = std::fs::remove_file(&resolv_conf); // ignore ENOENT
std::fs::write(&resolv_conf, content)?;
Ok(())
}