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