Compare commits

...
11 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
26 changed files with 1994 additions and 694 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
+9 -1
View File
@@ -302,7 +302,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
"clap",
"cpio", "cpio",
"dirs", "dirs",
"flate2", "flate2",
@@ -323,6 +322,15 @@ dependencies = [
"zstd", "zstd",
] ]
[[package]]
name = "ecr-cli"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"ecr",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.16.0" version = "1.16.0"
+5 -41
View File
@@ -1,50 +1,14 @@
[package] [workspace]
name = "ecr" resolver = "2"
members = ["crates/ecr", "crates/ecr-cli"]
[workspace.package]
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
rust-version = "1.77" rust-version = "1.77"
description = "Enter chroot environments with Linux namespaces"
license = "MIT" license = "MIT"
authors = ["Valentin Haudiquet"] 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"
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"
[profile.release] [profile.release]
strip = true strip = true
opt-level = "z" opt-level = "z"
+55 -4
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. `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 ## Usage
``` ```
@@ -38,7 +88,7 @@ ecr [OPTIONS] <DISTRO[:VERSION]> [-- COMMAND...]
| `--no-cache` | Force a fresh download, bypassing the cache | | `--no-cache` | Force a fresh download, bypassing the cache |
| `-v, --verbose` | Print diagnostic output (URLs, layer info, extraction steps) | | `-v, --verbose` | Print diagnostic output (URLs, layer info, extraction steps) |
| `-a, --arch <ARCH>` | Target architecture (`amd64`, `arm64`, `armhf`, `riscv64`, …) | | `-a, --arch <ARCH>` | Target architecture (`amd64`, `arm64`, `armhf`, `riscv64`, …) |
| `--kernel[=PATH]` | Boot with QEMU system emulation. Downloads Alpine's `linux-virt` kernel if no `=PATH` given | | `--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`) | | `-m, --memory <SIZE>` | Memory for QEMU VM (default: 2G, only with `--kernel`) |
## Examples ## Examples
@@ -74,7 +124,7 @@ ecr --kernel --memory 4G alpine
When `--kernel` is specified, ecr boots the rootfs in a full QEMU virtual machine instead of using namespaces: When `--kernel` is specified, ecr boots the rootfs in a full QEMU virtual machine instead of using namespaces:
```sh ```sh
# Auto-download Alpine's linux-virt kernel (recommended) # Auto-download Alpine's default kernel (recommended)
ecr --kernel alpine ecr --kernel alpine
# Use your own kernel # Use your own kernel
@@ -83,7 +133,7 @@ ecr --kernel=/boot/vmlinuz ubuntu
This mode: This mode:
- Creates an uncompressed CPIO initramfs from the rootfs (streamed to disk) - Creates an uncompressed CPIO initramfs from the rootfs (streamed to disk)
- Boots QEMU with your kernel (or auto-downloads Alpine's `linux-virt` kernel) - 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 - Provides full VM isolation
- Works for any architecture (no binfmt_misc needed) - Works for any architecture (no binfmt_misc needed)
- Caches the default kernel in `~/.cache/ecr/` - Caches the default kernel in `~/.cache/ecr/`
@@ -93,6 +143,7 @@ Host bind mounts (`--bind`, `--bind-rw`) are not applied in this mode.
Requirements: Requirements:
- `qemu-system-<arch>` installed - `qemu-system-<arch>` installed
- For custom kernels: kernel must have serial console support - 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 ## Supported distributions
@@ -105,7 +156,7 @@ Requirements:
## Cache ## 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 ## Requirements
+90 -15
View File
@@ -1,5 +1,63 @@
# ecr - implementation specification # 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 ## Synopsis
``` ```
@@ -22,7 +80,7 @@ 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) | | `--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-cache` | false | Download fresh tarball, ignore cache |
| `--no-bind` | false | Skip mounting any directory | | `--no-bind` | false | Skip mounting any directory |
| `--kernel[=PATH]` | none | Boot with QEMU system emulation; downloads the default Alpine `linux-virt` kernel when no `=PATH` is given | | `--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`) | | `-m, --memory <size>` | 2G | Memory size for QEMU VM (only used with `--kernel`) |
| `-v, --verbose` | false | Print diagnostic messages | | `-v, --verbose` | false | Print diagnostic messages |
| `-h, --help` | - | Show help | | `-h, --help` | - | Show help |
@@ -36,11 +94,21 @@ ecr [OPTIONS] <DISTRO[:VERSION]> -- [COMMAND]...
~/.cache/ecr/ ~/.cache/ecr/
├── ubuntu-noble-amd64.tar.gz ├── ubuntu-noble-amd64.tar.gz
├── alpine-latest-x86_64.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 ### Config File
@@ -111,20 +179,20 @@ Error: No manifest found for architecture 'riscv64'. Available: amd64, arm64, pp
## Execution Flow ## Execution Flow
1. Parse CLI arguments The CLI delegates to the library; the namespace-mode flow is:
2. Resolve distro/version/arch to image source
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 3. Check cache for existing tarball
4. If not cached, download tarball (direct or OCI) 4. If not cached, download tarball (direct or OCI)
5. Create temp directory for extraction 5. Extract tarball to a temporary directory
6. Extract tarball to temp directory 6. `ecr::exec`: create namespaces: user, pid, mount, uts
7. Create namespaces: user, pid, mount, uts 7. Set up mounts: /proc, /sys (ro), /dev, /dev/pts
8. 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 9. Write /etc/resolv.conf with DNS servers
10. Set up overlay mounts for bind paths 10. Set the working directory
11. Set up read-write bind mounts 11. Exec shell or command in chroot with the composed envp
12. Set environment variables 12. On exit, clean up the temporary directory; propagate the exit code
13. Exec shell or command in chroot
14. On exit, clean up temp directory
## Namespace Setup ## Namespace Setup
@@ -206,6 +274,7 @@ ecr --kernel=/boot/vmlinuz debian -- /bin/sh -c "echo hello"
2. Extract tarball to temporary directory 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 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: 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 - `-kernel <path>` - provided (or downloaded) kernel
- `-initrd initramfs.cpio` - rootfs as initramfs - `-initrd initramfs.cpio` - rootfs as initramfs
- `-append "console=ttyS0 [quiet] ECR_SHELL=... [ECR_ARGV=...] ECR_HOSTNAME=..."` - kernel command line (`quiet` unless `-v`) - `-append "console=ttyS0 [quiet] ECR_SHELL=... [ECR_ARGV=...] ECR_HOSTNAME=..."` - kernel command line (`quiet` unless `-v`)
@@ -213,9 +282,14 @@ ecr --kernel=/boot/vmlinuz debian -- /bin/sh -c "echo hello"
- `-display none -serial mon:stdio` - console on stdio - `-display none -serial mon:stdio` - console on stdio
- `-netdev user,id=net0 -device virtio-net-pci,netdev=net0` - network NIC - `-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 - `-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) 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 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 ### 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. 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.
@@ -300,7 +374,8 @@ dns:
## Environment Variables ## 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 - HOME=/root
- USER=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"
+2 -1
View File
@@ -36,7 +36,8 @@ pub struct Args {
/// Boot with QEMU system emulation (optionally specify kernel path with =PATH, or omit to download default) /// Boot with QEMU system emulation (optionally specify kernel path with =PATH, or omit to download default)
/// ///
/// Examples: /// Examples:
/// --kernel Download and use the default Alpine linux-virt kernel /// --kernel Download and use the default Alpine kernel
/// (linux-virt, or linux-lts on riscv64)
/// --kernel=./vmlinuz Use a specific kernel file /// --kernel=./vmlinuz Use a specific kernel file
/// ///
/// The kernel path must use `=` syntax: with `--kernel ./vmlinuz` the /// The kernel path must use `=` syntax: with `--kernel ./vmlinuz` the
+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"));
}
}
+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");
}
}
+19 -2
View File
@@ -18,6 +18,14 @@ pub fn extract_tarball(tarball: &Path, dest: &Path) -> Result<()> {
return extract_multi_layer_oci(tarball, dest); 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) let file = File::open(tarball)
.with_context(|| format!("Failed to open tarball: {}", tarball.display()))?; .with_context(|| format!("Failed to open tarball: {}", tarball.display()))?;
@@ -100,9 +108,18 @@ fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> {
.context("Failed to unpack OCI bundle")?; .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 // Read the layers manifest
let manifest = std::fs::read_to_string(temp_dir.path().join("layers.manifest")) let manifest =
.context("Failed to read layers.manifest")?; std::fs::read_to_string(&manifest_path).context("Failed to read layers.manifest")?;
// Apply each layer in order with full whiteout handling. // Apply each layer in order with full whiteout handling.
for layer_name in manifest.lines() { for layer_name in manifest.lines() {
+126 -27
View File
@@ -1,22 +1,32 @@
//! Default kernel download for QEMU VM mode. //! Default kernel download for QEMU VM mode.
//! //!
//! When `--kernel` is specified without a path, we download a default kernel //! When `--kernel` is specified without a path, we download a default kernel
//! suitable for VM booting. We use Alpine's `linux-virt` package because: //! suitable for VM booting from Alpine's kernel packages:
//! //!
//! - Small size (~10-15MB compressed) //! - Small size (~10-15MB compressed)
//! - VM-optimized configuration //! - VM-optimized configuration (`linux-virt`)
//! - Multi-architecture support //! - Multi-architecture support
//! - Simple direct download URLs //! - Simple direct download URLs
//! //!
//! The kernel is cached in the same cache directory as rootfs images. //! `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 crate::veprintln;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use std::io::Read; use std::io::{Read, Seek};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; 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 /// Alpine architecture mapping for kernel packages
fn alpine_kernel_arch(arch: &str) -> &'static str { fn alpine_kernel_arch(arch: &str) -> &'static str {
match arch { match arch {
@@ -91,8 +101,11 @@ fn fetch_alpine_branch_from_yaml() -> Result<String> {
Err(anyhow!("Could not determine Alpine version from releases")) Err(anyhow!("Could not determine Alpine version from releases"))
} }
/// Fetch the latest linux-virt package version from Alpine's package index /// Find the newest kernel package among `KERNEL_PACKAGES` in Alpine's
fn get_linux_virt_version(branch: &str, arch: &str) -> Result<String> { /// 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 // Alpine package index URL
let url = format!( let url = format!(
"https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/APKINDEX.tar.gz", "https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/APKINDEX.tar.gz",
@@ -173,25 +186,45 @@ fn get_linux_virt_version(branch: &str, arch: &str) -> Result<String> {
veprintln!(" APKINDEX size: {} bytes", contents.len()); veprintln!(" APKINDEX size: {} bytes", contents.len());
// Parse the APKINDEX to find linux-virt // Parse the APKINDEX to find the best kernel package
// Format: // Format:
// P:linux-virt // P:linux-virt
// V:6.12.8-r0 // 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; 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() { for line in contents.lines() {
if let Some(name) = line.strip_prefix("P:") { if let Some(name) = line.strip_prefix("P:") {
pkg_name = Some(name.trim().to_string()); pkg_name = Some(name.trim().to_string());
} else if let Some(version) = line.strip_prefix("V:") { } else if let Some(version) = line.strip_prefix("V:") {
if pkg_name.as_deref() == Some("linux-virt") { if let Some(name) = &pkg_name {
return Ok(version.trim().to_string()); 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()));
}
}
} }
} }
} }
// If we got here, we found APKINDEX but not linux-virt match best {
return Err(anyhow!("linux-virt package not found in APKINDEX. Available packages may vary by architecture.")); 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(", ")
))
}
}
} }
} }
@@ -203,16 +236,16 @@ fn get_linux_virt_version(branch: &str, arch: &str) -> Result<String> {
)) ))
} }
/// Download and extract the linux-virt kernel from Alpine's package repository /// Download and extract the kernel from an Alpine package repository
fn download_alpine_kernel(branch: &str, arch: &str, dest: &Path) -> Result<()> { fn download_alpine_kernel(branch: &str, arch: &str, dest: &Path) -> Result<()> {
let version = get_linux_virt_version(branch, arch)?; let (package, version) = find_kernel_package(branch, arch)?;
veprintln!("Found linux-virt version: {}", version); veprintln!("Found {} version: {}", package, version);
// Construct the download URL for the linux-virt .apk // 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 // Format: https://dl-cdn.alpinelinux.org/alpine/v3.23/main/x86_64/linux-virt-6.12.8-r0.apk
let url = format!( let url = format!(
"https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/linux-virt-{}.apk", "https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/{}-{}.apk",
branch, arch, version branch, arch, package, version
); );
veprintln!("Downloading kernel: {}", url); veprintln!("Downloading kernel: {}", url);
@@ -221,12 +254,12 @@ fn download_alpine_kernel(branch: &str, arch: &str, dest: &Path) -> Result<()> {
let rt = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; let rt = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?;
rt.block_on(download_kernel_async(&url, dest))?; rt.block_on(download_kernel_async(&url, dest))?;
// Extract vmlinuz-virt from the APK // Extract the kernel image from the APK, then store it decompressed.
// APK files are gzip-compressed tar archives // 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 temp_apk = dest.with_extension("apk");
// Remove the ~45MB APK whatever the extraction outcome — don't leave let result = extract_kernel_from_apk(&temp_apk, dest, package)
// it behind in the cache directory on failure. .and_then(|()| decompress_kernel_if_gzipped(dest));
let result = extract_kernel_from_apk(&temp_apk, dest);
std::fs::remove_file(&temp_apk).ok(); std::fs::remove_file(&temp_apk).ok();
result result
} }
@@ -294,8 +327,15 @@ async fn download_kernel_async(url: &str, dest: &Path) -> Result<()> {
Ok(()) Ok(())
} }
/// Extract vmlinuz-virt from an Alpine APK file /// Extract the kernel image from an Alpine APK file
fn extract_kernel_from_apk(apk_path: &Path, dest: &Path) -> Result<()> { 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..."); veprintln!("Extracting kernel from APK...");
let file = std::fs::File::open(apk_path).context("Failed to open APK file")?; let file = std::fs::File::open(apk_path).context("Failed to open APK file")?;
@@ -310,8 +350,7 @@ fn extract_kernel_from_apk(apk_path: &Path, dest: &Path) -> Result<()> {
veprintln!(" APK entry: {}", path_str); veprintln!(" APK entry: {}", path_str);
// Look for the kernel file: boot/vmlinuz-virt if path_str == kernel_name || path_str == format!("./{}", kernel_name) {
if path_str == "boot/vmlinuz-virt" || path_str == "./boot/vmlinuz-virt" {
// Extract to destination // Extract to destination
entry.unpack(dest).context("Failed to extract kernel")?; entry.unpack(dest).context("Failed to extract kernel")?;
veprintln!(" Extracted kernel to: {}", dest.display()); veprintln!(" Extracted kernel to: {}", dest.display());
@@ -319,7 +358,37 @@ fn extract_kernel_from_apk(apk_path: &Path, dest: &Path) -> Result<()> {
} }
} }
Err(anyhow!("vmlinuz-virt not found in APK package")) 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. /// Get the path to the cached default kernel for the given architecture.
@@ -365,4 +434,34 @@ mod tests {
assert_eq!(alpine_kernel_arch("ppc64le"), "ppc64le"); assert_eq!(alpine_kernel_arch("ppc64le"), "ppc64le");
assert_eq!(alpine_kernel_arch("s390x"), "s390x"); 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(',', "\\,")) 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 /// Setup all required mounts inside the chroot
/// Returns a TempDir that must be kept alive for the duration of the chroot /// Returns a TempDir that must be kept alive for the duration of the chroot
pub fn setup_mounts( pub fn setup_mounts(rootfs: &Path, binds: &[BindTarget]) -> Result<Vec<TempDir>> {
rootfs: &Path,
bind_paths: &[std::path::PathBuf],
bind_rw_paths: &[std::path::PathBuf],
args: &Args,
) -> Result<Vec<TempDir>> {
// Keep all overlay temp dirs alive // Keep all overlay temp dirs alive
let mut overlay_temps: Vec<TempDir> = Vec::new(); let mut overlay_temps: Vec<TempDir> = Vec::new();
@@ -59,22 +84,15 @@ pub fn setup_mounts(
eprintln!("Warning: Could not mount /sys: {}", e); eprintln!("Warning: Could not mount /sys: {}", e);
} }
// Setup overlay mounts for bind paths (read-only via overlay) for bind in binds {
if !args.no_bind { let mount_point = in_rootfs(rootfs, &bind.target)?;
for bind_path in bind_paths { if bind.read_only {
// Skip if this path is also in bind_rw (bind_rw takes precedence) overlay_temps.push(setup_overlay(&bind.source, &mount_point)?);
if !bind_rw_paths.contains(bind_path) { } else {
let temp = setup_overlay(rootfs, bind_path)?; setup_bind_rw(&bind.source, &mount_point)?;
overlay_temps.push(temp);
}
} }
} }
// 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) Ok(overlay_temps)
} }
@@ -164,16 +182,10 @@ fn mount_devpts(rootfs: &Path) -> Result<()> {
Ok(()) 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 /// Returns a TempDir that must be kept alive for the overlay to work
fn setup_overlay(rootfs: &Path, source: &Path) -> Result<TempDir> { fn setup_overlay(source: &Path, mount_point: &Path) -> Result<TempDir> {
let basename = source std::fs::create_dir_all(mount_point)?;
.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)?;
// Create temp directories for overlay // Create temp directories for overlay
let temp_dir = tempfile::tempdir()?; let temp_dir = tempfile::tempdir()?;
@@ -196,7 +208,7 @@ fn setup_overlay(rootfs: &Path, source: &Path) -> Result<TempDir> {
mount( mount(
Some("overlay"), Some("overlay"),
&mount_point, mount_point,
Some("overlay"), Some("overlay"),
MsFlags::empty(), MsFlags::empty(),
Some(options.as_str()), Some(options.as_str()),
@@ -208,20 +220,14 @@ fn setup_overlay(rootfs: &Path, source: &Path) -> Result<TempDir> {
} }
/// Setup read-write bind mount /// Setup read-write bind mount
fn setup_bind_rw(rootfs: &Path, source: &Path) -> Result<()> { fn setup_bind_rw(source: &Path, mount_point: &Path) -> Result<()> {
let basename = source std::fs::create_dir_all(mount_point)?;
.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)?;
let source = source.canonicalize()?; let source = source.canonicalize()?;
mount( mount(
Some(&source), Some(&source),
&mount_point, mount_point,
None::<&str>, None::<&str>,
MsFlags::MS_BIND | MsFlags::MS_REC, MsFlags::MS_BIND | MsFlags::MS_REC,
None::<&str>, None::<&str>,
@@ -239,8 +245,42 @@ fn setup_bind_rw(rootfs: &Path, source: &Path) -> Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::escape_overlay_path; use super::{escape_overlay_path, in_rootfs, BindTarget};
use std::path::Path; 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] #[test]
fn plain_path_unchanged() { fn plain_path_unchanged() {
+67 -2
View File
@@ -125,10 +125,16 @@ pub fn launch_qemu(config: QemuConfig) -> Result<()> {
veprintln!(" Kernel append: {}", kernel_append); veprintln!(" Kernel append: {}", kernel_append);
// Build QEMU arguments // Build QEMU arguments
// -machine virt is selected explicitly on riscv64 (see machine_for_arch)
// -display none suppresses VGA/BIOS output // -display none suppresses VGA/BIOS output
// -serial mon:stdio connects serial console to terminal with QEMU monitor muxed // -serial mon:stdio connects serial console to terminal with QEMU monitor muxed
// -no-reboot makes QEMU exit when the guest requests poweroff/reboot // -no-reboot makes QEMU exit when the guest requests poweroff/reboot
let mut args = vec![ 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(), "-kernel".to_string(),
config.kernel_path.to_string_lossy().to_string(), config.kernel_path.to_string_lossy().to_string(),
"-initrd".to_string(), "-initrd".to_string(),
@@ -146,13 +152,16 @@ pub fn launch_qemu(config: QemuConfig) -> Result<()> {
"user,id=net0".to_string(), "user,id=net0".to_string(),
"-device".to_string(), "-device".to_string(),
"virtio-net-pci,netdev=net0".to_string(), "virtio-net-pci,netdev=net0".to_string(),
]; ]);
// Add KVM acceleration if available // Add KVM acceleration if available
if use_kvm { if use_kvm {
args.push("-enable-kvm".to_string()); args.push("-enable-kvm".to_string());
args.push("-cpu".to_string()); args.push("-cpu".to_string());
args.push("host".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 // Execute QEMU
@@ -182,6 +191,35 @@ fn qemu_binary_for_arch(arch: &str) -> String {
format!("qemu-system-{}", arch_enum.qemu_system_name()) 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 /// Get architecture suffix for package names
fn get_arch_package_suffix(arch: &str) -> &'static str { fn get_arch_package_suffix(arch: &str) -> &'static str {
crate::utils::Arch::from_str(arch).qemu_package_suffix() crate::utils::Arch::from_str(arch).qemu_package_suffix()
@@ -601,6 +639,33 @@ mod tests {
use super::*; use super::*;
use std::io::Read as _; 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 /// 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>)> { fn parse_cpio(archive: Vec<u8>) -> Vec<(String, u32, u32, u32, u32, Vec<u8>)> {
let mut cursor = std::io::Cursor::new(archive); let mut cursor = std::io::Cursor::new(archive);
+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());
}
}
+3
View File
@@ -42,6 +42,9 @@ pub enum Arch {
impl Arch { impl Arch {
/// Get the architecture from a string (any common naming convention) /// 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 { pub fn from_str(s: &str) -> Self {
match s { match s {
"amd64" | "x86_64" | "x64" => Arch::Amd64, "amd64" | "x86_64" | "x64" => Arch::Amd64,
-189
View File
@@ -1,189 +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);
}
// 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 - set up environment based on chroot filesystem
// 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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_setup_environment_defaults() {
let env = setup_environment("/bin/bash", "xterm-256color");
assert_eq!(env.get("HOME"), Some(&"/root".to_string()));
assert_eq!(env.get("USER"), Some(&"root".to_string()));
assert_eq!(env.get("SHELL"), Some(&"/bin/bash".to_string()));
assert_eq!(env.get("TERM"), Some(&"xterm-256color".to_string()));
assert_eq!(
env.get("PATH"),
Some(&"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string())
);
}
#[test]
fn test_setup_environment_custom_shell() {
let env = setup_environment("/usr/bin/zsh", "screen");
assert_eq!(env.get("SHELL"), Some(&"/usr/bin/zsh".to_string()));
assert_eq!(env.get("TERM"), Some(&"screen".to_string()));
}
#[test]
fn test_environment_isolation() {
// Verify that setup_environment creates a clean environment
// without inheriting from the host
let env = setup_environment("/bin/sh", "dumb");
// Should have exactly 5 environment variables
assert_eq!(env.len(), 5);
// Should NOT have any host-specific variables
assert!(!env.contains_key("LANG"));
assert!(!env.contains_key("DISPLAY"));
assert!(!env.contains_key("PWD"));
}
#[test]
fn test_path_contains_standard_directories() {
let env = setup_environment("/bin/bash", "xterm");
let path = env.get("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"));
}
}
-371
View File
@@ -1,371 +0,0 @@
mod chroot;
mod cli;
mod config;
mod distro;
mod download;
mod extract;
mod kernel;
mod mount;
mod namespace;
mod qemu;
mod qemu_vm;
mod utils;
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::{Context, 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 (for namespace mode)
// For VM mode, we don't need binfmt_misc since we're using system emulation
if args.kernel.is_none() && arch != host_arch {
qemu::check_binfmt(&arch)?;
}
// 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)?;
// 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,
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, config)?;
// Propagate the command's exit code, cleaning up the extracted rootfs
// first: process::exit does not run destructors.
drop(temp_dir);
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: std::path::PathBuf, config: Config) -> Result<i32> {
// 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();
// 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 consolidated architecture detection from utils
utils::get_host_arch().debian_name().to_string()
}
fn write_resolv_conf(rootfs: &std::path::Path, dns: &[String]) -> Result<()> {
use std::io::Write;
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(())
}