Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de507682c1 | ||
|
|
af06264e60 | ||
|
|
b6ddd85525 | ||
|
|
4f669fb5ec | ||
|
|
b6e5b4f006 | ||
|
|
7137aa15c5 |
@@ -0,0 +1,89 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Conventions for working in this tree. They apply to every commit; the
|
||||||
|
whole history follows them.
|
||||||
|
|
||||||
|
## Before every commit
|
||||||
|
|
||||||
|
Run, in order, and make sure they are clean before committing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt --all
|
||||||
|
cargo clippy --all-targets # no new warnings
|
||||||
|
cargo test # keep green when touching behavior
|
||||||
|
```
|
||||||
|
|
||||||
|
These mirror CI (`.github/workflows/ci.yml` runs check, `fmt --check`,
|
||||||
|
clippy, and `cargo test --all-features`). `cargo fmt` may amend files
|
||||||
|
you did not touch — include those changes in the commit (or in a
|
||||||
|
separate `chore:` commit) rather than leaving the tree dirty.
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Follow Conventional Commits with a component scope:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <short summary>
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Types: `feat`, `fix`, `refactor`, `perf`, `test`, `docs`, `build`,
|
||||||
|
`ci`, `chore`.
|
||||||
|
- Scopes match the component touched — the module name under
|
||||||
|
`crates/ecr/src/` (library) or `crates/ecr-cli/src/` (CLI):
|
||||||
|
- `cli` — entry point and flags (`crates/ecr-cli/src/main.rs`,
|
||||||
|
`crates/ecr-cli/src/cli.rs`)
|
||||||
|
- `rootfs` — cache-aware preparation and persistence
|
||||||
|
(`crates/ecr/src/rootfs.rs`)
|
||||||
|
- `exec` — namespace-mode execution API (`crates/ecr/src/exec.rs`)
|
||||||
|
- `config` — config file (`crates/ecr/src/config.rs`)
|
||||||
|
- `distro` — distro definitions and mirrors (`crates/ecr/src/distro.rs`)
|
||||||
|
- `download` — image download (`crates/ecr/src/download.rs`)
|
||||||
|
- `extract` — rootfs extraction (`crates/ecr/src/extract.rs`)
|
||||||
|
- `chroot` — chroot setup (`crates/ecr/src/chroot.rs`)
|
||||||
|
- `namespace` — Linux namespaces (`crates/ecr/src/namespace.rs`)
|
||||||
|
- `mount` — bind mounts and mount table (`crates/ecr/src/mount.rs`)
|
||||||
|
- `kernel` — kernel/initramfs handling for `--kernel`
|
||||||
|
(`crates/ecr/src/kernel.rs`)
|
||||||
|
- `qemu` — QEMU VM mode (`crates/ecr/src/qemu.rs`,
|
||||||
|
`crates/ecr/src/qemu_vm.rs`)
|
||||||
|
- `utils`, `verbose` — shared helpers (`crates/ecr/src/utils.rs`,
|
||||||
|
`crates/ecr/src/verbose.rs`)
|
||||||
|
- `deps` — dependency additions/bumps (manifests, lockfile)
|
||||||
|
- Omit the scope entirely for repo-wide changes that do not belong to a
|
||||||
|
single component (README.md, SPEC.md, root config).
|
||||||
|
- Summary: imperative mood ("add", never "added" or "adds"), lowercase
|
||||||
|
first letter, no trailing period, max ~72 characters.
|
||||||
|
- Body (optional): separated by a blank line, wrapped at 72 columns;
|
||||||
|
explain why rather than what. Reference issues as `#123`.
|
||||||
|
- A commit touching several components should be split into one commit
|
||||||
|
per component when practical; otherwise use the dominant scope.
|
||||||
|
- **No merge commits.** Integrate work by rebasing onto the target
|
||||||
|
branch (`git rebase`, `git pull --rebase`, `git cherry-pick`); the
|
||||||
|
history stays linear. When several work streams run in parallel, land
|
||||||
|
them one rebase at a time.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(namespace): set unique hostname per run
|
||||||
|
fix(extract): handle hard links in rootfs archives
|
||||||
|
fix(kernel): parse =PATH syntax for --kernel
|
||||||
|
test(chroot): cover path resolution helpers
|
||||||
|
feat(qemu): enable KVM when available
|
||||||
|
deps: bump nix to 0.31
|
||||||
|
docs: document QEMU system mode and =PATH syntax
|
||||||
|
chore: apply cargo fmt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
- Tests live inline as `#[cfg(test)]` modules at the bottom of each
|
||||||
|
`src/` module; there is no `tests/` directory. Scope test commits to
|
||||||
|
the module under test.
|
||||||
|
- Comments state constraints the code cannot show; no narration.
|
||||||
|
- Anything user-facing (CLI flags, config keys, error text users act
|
||||||
|
on) is reflected in `README.md` before commit.
|
||||||
|
- Design decisions that outlive the session go to `SPEC.md`, not to
|
||||||
|
new docs or repos.
|
||||||
Generated
+9
-1
@@ -302,7 +302,6 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
"clap",
|
|
||||||
"cpio",
|
"cpio",
|
||||||
"dirs",
|
"dirs",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -323,6 +322,15 @@ dependencies = [
|
|||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ecr-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"ecr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
|
|||||||
+5
-41
@@ -1,50 +1,14 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "ecr"
|
resolver = "2"
|
||||||
|
members = ["crates/ecr", "crates/ecr-cli"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77"
|
rust-version = "1.77"
|
||||||
description = "Enter chroot environments with Linux namespaces"
|
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Valentin Haudiquet"]
|
authors = ["Valentin Haudiquet"]
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# CLI parsing
|
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
|
||||||
|
|
||||||
# Config parsing
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_yaml = "0.9"
|
|
||||||
|
|
||||||
# HTTP downloads
|
|
||||||
reqwest = { version = "0.13", features = ["blocking", "stream"] }
|
|
||||||
|
|
||||||
# Tarball extraction
|
|
||||||
tar = "0.4"
|
|
||||||
flate2 = "1"
|
|
||||||
xz2 = "0.1"
|
|
||||||
zstd = "0.13"
|
|
||||||
|
|
||||||
# Unix syscall bindings
|
|
||||||
nix = { version = "0.31", features = ["fs", "mount", "sched", "signal", "user", "process", "hostname"] }
|
|
||||||
|
|
||||||
# Temp directories
|
|
||||||
tempfile = "3"
|
|
||||||
|
|
||||||
# Error handling
|
|
||||||
anyhow = "1"
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
dirs = "6"
|
|
||||||
which = "7"
|
|
||||||
cpio = "0.4"
|
|
||||||
base64 = "0.22"
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] }
|
|
||||||
futures-util = "0.3"
|
|
||||||
indicatif = "0.18"
|
|
||||||
serde_json = "1"
|
|
||||||
libc = "0.2"
|
|
||||||
users = "0.11"
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -105,7 +155,7 @@ Requirements:
|
|||||||
|
|
||||||
## Cache
|
## Cache
|
||||||
|
|
||||||
Downloaded images are cached in `~/.cache/ecr/`. For `latest` OCI tags the registry manifest digest is checked on each run — the image is only re-downloaded when it has actually changed. Use `--no-cache` to force a fresh pull regardless.
|
Downloaded images are cached in `~/.cache/ecr/`. For `latest` OCI tags the registry manifest digest is checked on each run — the image is only re-downloaded when it has actually changed. Use `--no-cache` to force a fresh pull regardless. The library's `PreparedRootfs::persist` can replace a cache entry with a provisioned rootfs (see *Provisioned-rootfs caching* above).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -36,11 +94,21 @@ ecr [OPTIONS] <DISTRO[:VERSION]> -- [COMMAND]...
|
|||||||
~/.cache/ecr/
|
~/.cache/ecr/
|
||||||
├── ubuntu-noble-amd64.tar.gz
|
├── ubuntu-noble-amd64.tar.gz
|
||||||
├── alpine-latest-x86_64.tar.gz
|
├── alpine-latest-x86_64.tar.gz
|
||||||
├── debian-bookworm-amd64.tar.gz
|
├── oci-docker_io-library_archlinux-latest-amd64.tar.gz
|
||||||
|
├── oci-docker_io-library_archlinux-latest-amd64.tar.gz.digest
|
||||||
└── ...
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
No metadata files. Tarballs are downloaded once and never redownloaded. Users can delete files manually or use `--no-cache` to fetch fresh.
|
Sidecar files, never counted as image entries:
|
||||||
|
|
||||||
|
- `<entry>.digest` — manifest digest of the last OCI download, used by the
|
||||||
|
`:latest` freshness check.
|
||||||
|
- `<entry>.provisioned` — marker written by `PreparedRootfs::persist`;
|
||||||
|
`prepare_provisioned` treats an entry with this marker as provisioned.
|
||||||
|
|
||||||
|
Tarballs are downloaded once and never redownloaded (unless the digest
|
||||||
|
moves, `--no-cache` is passed, or a provisioned entry is deleted). Users
|
||||||
|
can delete files manually.
|
||||||
|
|
||||||
### Config File
|
### Config File
|
||||||
|
|
||||||
@@ -111,20 +179,20 @@ Error: No manifest found for architecture 'riscv64'. Available: amd64, arm64, pp
|
|||||||
|
|
||||||
## Execution Flow
|
## Execution Flow
|
||||||
|
|
||||||
1. Parse CLI arguments
|
The CLI delegates to the library; the namespace-mode flow is:
|
||||||
2. Resolve distro/version/arch to image source
|
|
||||||
|
1. Parse CLI arguments, map flags onto library requests
|
||||||
|
2. `cache.prepare`: resolve distro/version/arch to image source
|
||||||
3. Check cache for existing tarball
|
3. Check cache for existing tarball
|
||||||
4. If not cached, download tarball (direct or OCI)
|
4. If not cached, download tarball (direct or OCI)
|
||||||
5. Create temp directory for extraction
|
5. Extract tarball to a temporary directory
|
||||||
6. Extract tarball to temp directory
|
6. `ecr::exec`: create namespaces: user, pid, mount, uts
|
||||||
7. Create namespaces: user, pid, mount, uts
|
7. Set up mounts: /proc, /sys (ro), /dev, /dev/pts
|
||||||
8. Set up mounts: /proc, /sys (ro), /dev, /dev/pts
|
8. Apply bind targets: overlays (ro) and bind mounts (rw)
|
||||||
9. Write /etc/resolv.conf with DNS servers
|
9. Write /etc/resolv.conf with DNS servers
|
||||||
10. Set up overlay mounts for bind paths
|
10. Set the working directory
|
||||||
11. Set up read-write bind mounts
|
11. Exec shell or command in chroot with the composed envp
|
||||||
12. Set environment variables
|
12. On exit, clean up the temporary directory; propagate the exit code
|
||||||
13. Exec shell or command in chroot
|
|
||||||
14. On exit, clean up temp directory
|
|
||||||
|
|
||||||
## Namespace Setup
|
## Namespace Setup
|
||||||
|
|
||||||
@@ -300,7 +368,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"
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,14 @@ pub fn extract_tarball(tarball: &Path, dest: &Path) -> Result<()> {
|
|||||||
return extract_multi_layer_oci(tarball, dest);
|
return extract_multi_layer_oci(tarball, dest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extract_single_archive(tarball, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a plain rootfs tarball, detecting the compression format from
|
||||||
|
/// the filename with a magic-byte fallback.
|
||||||
|
fn extract_single_archive(tarball: &Path, dest: &Path) -> Result<()> {
|
||||||
|
let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||||
|
|
||||||
let file = File::open(tarball)
|
let file = File::open(tarball)
|
||||||
.with_context(|| format!("Failed to open tarball: {}", tarball.display()))?;
|
.with_context(|| format!("Failed to open tarball: {}", tarball.display()))?;
|
||||||
|
|
||||||
@@ -100,9 +108,18 @@ fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> {
|
|||||||
.context("Failed to unpack OCI bundle")?;
|
.context("Failed to unpack OCI bundle")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let manifest_path = temp_dir.path().join("layers.manifest");
|
||||||
|
if !manifest_path.exists() {
|
||||||
|
// Not our layer-bundle layout: a provisioned rootfs persisted over
|
||||||
|
// an OCI cache entry (the dispatch above keys on the "oci-" filename
|
||||||
|
// prefix). The outer archive is a plain rootfs tarball.
|
||||||
|
veprintln!("No layers.manifest in bundle; extracting as plain rootfs tarball");
|
||||||
|
return extract_single_archive(tarball, dest);
|
||||||
|
}
|
||||||
|
|
||||||
// Read the layers manifest
|
// Read the layers manifest
|
||||||
let manifest = std::fs::read_to_string(temp_dir.path().join("layers.manifest"))
|
let manifest =
|
||||||
.context("Failed to read layers.manifest")?;
|
std::fs::read_to_string(&manifest_path).context("Failed to read layers.manifest")?;
|
||||||
|
|
||||||
// Apply each layer in order with full whiteout handling.
|
// Apply each layer in order with full whiteout handling.
|
||||||
for layer_name in manifest.lines() {
|
for layer_name in manifest.lines() {
|
||||||
@@ -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,21 +84,14 @@ 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() {
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ pub enum Arch {
|
|||||||
|
|
||||||
impl Arch {
|
impl Arch {
|
||||||
/// Get the architecture from a string (any common naming convention)
|
/// Get the architecture from a string (any common naming convention)
|
||||||
|
/// Not a FromStr impl: unrecognized names map to Arch::Unknown rather
|
||||||
|
/// than an error, by design.
|
||||||
|
#[allow(clippy::should_implement_trait)]
|
||||||
pub fn from_str(s: &str) -> Self {
|
pub fn from_str(s: &str) -> Self {
|
||||||
match s {
|
match s {
|
||||||
"amd64" | "x86_64" | "x64" => Arch::Amd64,
|
"amd64" | "x86_64" | "x64" => Arch::Amd64,
|
||||||
-189
@@ -1,189 +0,0 @@
|
|||||||
use crate::veprintln;
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
|
||||||
use nix::unistd::{chroot, execve};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// Run a command in the chroot environment
|
|
||||||
pub fn run_chroot(
|
|
||||||
rootfs: &Path,
|
|
||||||
command: Option<Vec<String>>,
|
|
||||||
bind_rw_paths: &[std::path::PathBuf],
|
|
||||||
) -> Result<()> {
|
|
||||||
// Get TERM from host before chroot
|
|
||||||
let host_term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string());
|
|
||||||
|
|
||||||
// Set hostname in UTS namespace
|
|
||||||
if let Err(e) = crate::namespace::set_hostname("chroot") {
|
|
||||||
eprintln!("Warning: Failed to set hostname: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect shell before chroot (we're still outside)
|
|
||||||
let shell = crate::utils::detect_shell(rootfs);
|
|
||||||
|
|
||||||
// Change to root directory in chroot
|
|
||||||
chroot(rootfs).context("Failed to chroot")?;
|
|
||||||
|
|
||||||
// Now we're inside the chroot - set up environment based on chroot filesystem
|
|
||||||
|
|
||||||
// Set up environment variables (after chroot, so paths are correct)
|
|
||||||
let env = setup_environment(shell, &host_term);
|
|
||||||
|
|
||||||
// Determine the command to run
|
|
||||||
let (program, args) = match command {
|
|
||||||
Some(cmd) if !cmd.is_empty() => {
|
|
||||||
let program = cmd[0].clone();
|
|
||||||
let args = cmd
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
std::ffi::CString::new(s.as_str())
|
|
||||||
.with_context(|| format!("Argument contains a null byte: {:?}", s))
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
(program, args)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Run shell (already determined above based on chroot filesystem)
|
|
||||||
let program = shell.to_string();
|
|
||||||
let args =
|
|
||||||
vec![std::ffi::CString::new(shell).context("Shell path contains a null byte")?];
|
|
||||||
(program, args)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build an explicit envp from setup_environment so the host environment
|
|
||||||
// is never inherited. execve takes this array directly; the host process
|
|
||||||
// environment is not touched at all.
|
|
||||||
let env_cstrings = env
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| {
|
|
||||||
std::ffi::CString::new(format!("{}={}", k, v))
|
|
||||||
.with_context(|| format!("Environment variable contains a null byte: {}={}", k, v))
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
|
|
||||||
// Change to first bind_rw directory if available, otherwise /root, otherwise /
|
|
||||||
// bind_rw paths are mounted at /mnt/<basename> (see mount.rs setup_bind_rw)
|
|
||||||
let working_dir = if let Some(first_bind_rw) = bind_rw_paths.first() {
|
|
||||||
let dest_dir = Path::new("/mnt").join(first_bind_rw.file_name().unwrap_or_default());
|
|
||||||
if dest_dir.exists() {
|
|
||||||
dest_dir
|
|
||||||
} else if Path::new("/root").exists() {
|
|
||||||
Path::new("/root").to_path_buf()
|
|
||||||
} else {
|
|
||||||
Path::new("/").to_path_buf()
|
|
||||||
}
|
|
||||||
} else if Path::new("/root").exists() {
|
|
||||||
Path::new("/root").to_path_buf()
|
|
||||||
} else {
|
|
||||||
Path::new("/").to_path_buf()
|
|
||||||
};
|
|
||||||
std::env::set_current_dir(&working_dir).context("Failed to change to working directory")?;
|
|
||||||
|
|
||||||
// Print welcome message
|
|
||||||
veprintln!("Entering chroot at {}", rootfs.display());
|
|
||||||
for path in bind_rw_paths {
|
|
||||||
let basename = path
|
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy())
|
|
||||||
.unwrap_or_default();
|
|
||||||
veprintln!("Read-write mount: /mnt/{}", basename);
|
|
||||||
}
|
|
||||||
veprintln!("Working directory: {}", working_dir.display());
|
|
||||||
|
|
||||||
// Check if the program exists
|
|
||||||
if !Path::new(&program).exists() {
|
|
||||||
// Try to find it in PATH
|
|
||||||
let found = env.get("PATH").and_then(|path| {
|
|
||||||
path.split(':')
|
|
||||||
.map(|p| std::path::PathBuf::from(p).join(&program))
|
|
||||||
.find(|p| p.exists())
|
|
||||||
});
|
|
||||||
|
|
||||||
if found.is_none() {
|
|
||||||
return Err(anyhow!("Program not found: {}", program));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exec the program directly with an explicit, isolated environment.
|
|
||||||
// execve never returns on success.
|
|
||||||
let program_cstr = std::ffi::CString::new(program.as_str()).context("Invalid program name")?;
|
|
||||||
|
|
||||||
let result = execve(&program_cstr, &args, &env_cstrings);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(_) => Ok(()), // Never reached
|
|
||||||
Err(e) => Err(anyhow!("Failed to exec {}: {}", program, e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Setup default environment variables for chroot
|
|
||||||
/// Must be called AFTER chroot so paths are resolved inside the chroot
|
|
||||||
fn setup_environment(shell: &str, term: &str) -> HashMap<&'static str, String> {
|
|
||||||
let mut env = HashMap::new();
|
|
||||||
|
|
||||||
env.insert("HOME", "/root".to_string());
|
|
||||||
env.insert("USER", "root".to_string());
|
|
||||||
env.insert("SHELL", shell.to_string());
|
|
||||||
env.insert("TERM", term.to_string());
|
|
||||||
env.insert(
|
|
||||||
"PATH",
|
|
||||||
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
env
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_setup_environment_defaults() {
|
|
||||||
let env = setup_environment("/bin/bash", "xterm-256color");
|
|
||||||
|
|
||||||
assert_eq!(env.get("HOME"), Some(&"/root".to_string()));
|
|
||||||
assert_eq!(env.get("USER"), Some(&"root".to_string()));
|
|
||||||
assert_eq!(env.get("SHELL"), Some(&"/bin/bash".to_string()));
|
|
||||||
assert_eq!(env.get("TERM"), Some(&"xterm-256color".to_string()));
|
|
||||||
assert_eq!(
|
|
||||||
env.get("PATH"),
|
|
||||||
Some(&"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_setup_environment_custom_shell() {
|
|
||||||
let env = setup_environment("/usr/bin/zsh", "screen");
|
|
||||||
|
|
||||||
assert_eq!(env.get("SHELL"), Some(&"/usr/bin/zsh".to_string()));
|
|
||||||
assert_eq!(env.get("TERM"), Some(&"screen".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_environment_isolation() {
|
|
||||||
// Verify that setup_environment creates a clean environment
|
|
||||||
// without inheriting from the host
|
|
||||||
let env = setup_environment("/bin/sh", "dumb");
|
|
||||||
|
|
||||||
// Should have exactly 5 environment variables
|
|
||||||
assert_eq!(env.len(), 5);
|
|
||||||
|
|
||||||
// Should NOT have any host-specific variables
|
|
||||||
assert!(!env.contains_key("LANG"));
|
|
||||||
assert!(!env.contains_key("DISPLAY"));
|
|
||||||
assert!(!env.contains_key("PWD"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_path_contains_standard_directories() {
|
|
||||||
let env = setup_environment("/bin/bash", "xterm");
|
|
||||||
let path = env.get("PATH").expect("PATH should be set");
|
|
||||||
|
|
||||||
// Verify essential directories are in PATH
|
|
||||||
assert!(path.contains("/bin"));
|
|
||||||
assert!(path.contains("/usr/bin"));
|
|
||||||
assert!(path.contains("/sbin"));
|
|
||||||
assert!(path.contains("/usr/sbin"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-371
@@ -1,371 +0,0 @@
|
|||||||
mod chroot;
|
|
||||||
mod cli;
|
|
||||||
mod config;
|
|
||||||
mod distro;
|
|
||||||
mod download;
|
|
||||||
mod extract;
|
|
||||||
mod kernel;
|
|
||||||
mod mount;
|
|
||||||
mod namespace;
|
|
||||||
mod qemu;
|
|
||||||
mod qemu_vm;
|
|
||||||
mod utils;
|
|
||||||
mod verbose;
|
|
||||||
|
|
||||||
/// Print to stderr only when --verbose / -v is active.
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! veprintln {
|
|
||||||
($($arg:tt)*) => {
|
|
||||||
if $crate::verbose::is_verbose() {
|
|
||||||
eprintln!($($arg)*);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use clap::Parser;
|
|
||||||
|
|
||||||
use cli::Args;
|
|
||||||
use config::Config;
|
|
||||||
use distro::{
|
|
||||||
map_arch, parse_image_ref, resolve_distro_url, resolve_distro_version, Distro, ImageSource,
|
|
||||||
};
|
|
||||||
use download::{digest_sidecar, download_image, fetch_oci_digest};
|
|
||||||
use extract::extract_tarball;
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
|
||||||
let args = Args::parse();
|
|
||||||
|
|
||||||
// Initialise verbosity before anything else so all downstream code can use veprintln!.
|
|
||||||
verbose::set(args.verbose);
|
|
||||||
|
|
||||||
// Load config file
|
|
||||||
let config = Config::load()?;
|
|
||||||
|
|
||||||
// Get architecture
|
|
||||||
let host_arch = get_host_arch();
|
|
||||||
let arch = args.arch.clone().unwrap_or_else(|| host_arch.clone());
|
|
||||||
|
|
||||||
// Parse image reference
|
|
||||||
let image_source = parse_image_ref(&args.distro, &arch)?;
|
|
||||||
|
|
||||||
// For DirectTarball, resolve floating aliases ("latest", "lts") to a concrete
|
|
||||||
// version string *before* computing the cache key. This ensures we cache as
|
|
||||||
// e.g. "ubuntu-noble-amd64" rather than "ubuntu-latest-amd64", so a future
|
|
||||||
// release automatically gets its own cache entry.
|
|
||||||
let image_source = match image_source {
|
|
||||||
ImageSource::DirectTarball { distro, version } => {
|
|
||||||
let resolved = resolve_distro_version(&distro, version.as_deref(), &arch)?;
|
|
||||||
ImageSource::DirectTarball {
|
|
||||||
distro,
|
|
||||||
version: Some(resolved),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
other => other,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine cache directory and filename
|
|
||||||
let cache_dir = dirs::cache_dir()
|
|
||||||
.expect("Could not determine cache directory")
|
|
||||||
.join("ecr");
|
|
||||||
let cache_filename = generate_cache_filename(&image_source, &arch);
|
|
||||||
let cache_path = cache_dir.join(&cache_filename);
|
|
||||||
|
|
||||||
// OCI images with a floating tag (":latest") need a freshness check:
|
|
||||||
// fetch the current manifest digest from the registry and compare it
|
|
||||||
// against the digest stored from the last download. Only re-pull when
|
|
||||||
// the digest has actually changed. On a network error we fall back to
|
|
||||||
// the cached image with a warning rather than hard-failing.
|
|
||||||
let oci_digest_changed = if cache_path.exists() {
|
|
||||||
if let ImageSource::OciImage {
|
|
||||||
registry,
|
|
||||||
repository,
|
|
||||||
tag,
|
|
||||||
..
|
|
||||||
} = &image_source
|
|
||||||
{
|
|
||||||
if tag == "latest" {
|
|
||||||
match fetch_oci_digest(registry, repository, tag) {
|
|
||||||
Ok(current) => {
|
|
||||||
let stored = std::fs::read_to_string(digest_sidecar(&cache_path)).ok();
|
|
||||||
stored.as_deref() != Some(current.trim())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(
|
|
||||||
"Warning: could not check image freshness ({}); using cache",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false // pinned tags are assumed immutable
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
false // cache absent — download triggered by !cache_path.exists() below
|
|
||||||
};
|
|
||||||
|
|
||||||
// Download if not cached, --no-cache, or the remote digest has moved
|
|
||||||
if args.no_cache || !cache_path.exists() || oci_digest_changed {
|
|
||||||
std::fs::create_dir_all(&cache_dir)?;
|
|
||||||
download_image(&image_source, &cache_path, &arch)?;
|
|
||||||
} else {
|
|
||||||
veprintln!("Using cached tarball: {}", cache_path.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check QEMU if foreign architecture (for namespace mode)
|
|
||||||
// For VM mode, we don't need binfmt_misc since we're using system emulation
|
|
||||||
if args.kernel.is_none() && arch != host_arch {
|
|
||||||
qemu::check_binfmt(&arch)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create temp directory for extraction
|
|
||||||
let temp_dir = tempfile::tempdir()?;
|
|
||||||
let rootfs = temp_dir.path().to_path_buf();
|
|
||||||
|
|
||||||
veprintln!("Extracting to: {}", rootfs.display());
|
|
||||||
extract_tarball(&cache_path, &rootfs)?;
|
|
||||||
|
|
||||||
// Branch based on --kernel flag
|
|
||||||
// Option<Option<PathBuf>> (require_equals: the value must use =PATH syntax
|
|
||||||
// so it can never swallow the DISTRO positional):
|
|
||||||
// None -> --kernel not specified, use namespace mode
|
|
||||||
// Some(None) -> --kernel without path, download default kernel
|
|
||||||
// Some(Some(path)) -> --kernel=/path/to/vmlinuz, use provided kernel
|
|
||||||
if let Some(kernel_opt) = &args.kernel {
|
|
||||||
// VM mode boots an initramfs: host bind mounts are never applied
|
|
||||||
if !args.bind.is_empty() || !args.bind_rw.is_empty() {
|
|
||||||
eprintln!(
|
|
||||||
"Warning: --bind/--bind-rw are ignored with --kernel \
|
|
||||||
(the VM boots from an initramfs, no host directories are mounted)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// QEMU system mode
|
|
||||||
let kernel_path = match kernel_opt {
|
|
||||||
Some(path) => {
|
|
||||||
veprintln!("QEMU mode: using provided kernel {}", path.display());
|
|
||||||
path.clone()
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
veprintln!("QEMU mode: downloading default kernel...");
|
|
||||||
kernel::get_default_kernel(&cache_dir, &arch)?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let command = if args.command.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(args.command.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = qemu_vm::launch_qemu(qemu_vm::QemuConfig {
|
|
||||||
kernel_path,
|
|
||||||
rootfs_path: rootfs,
|
|
||||||
memory: args.memory.clone(),
|
|
||||||
arch: arch.clone(),
|
|
||||||
command,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup happens automatically via tempfile
|
|
||||||
if result.is_ok() {
|
|
||||||
veprintln!("Cleanup complete.");
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
} else {
|
|
||||||
// Namespace/chroot mode
|
|
||||||
let exit_code = namespace_mode(args, rootfs, config)?;
|
|
||||||
// Propagate the command's exit code, cleaning up the extracted rootfs
|
|
||||||
// first: process::exit does not run destructors.
|
|
||||||
drop(temp_dir);
|
|
||||||
if exit_code != 0 {
|
|
||||||
std::process::exit(exit_code);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run in namespace/chroot mode, returning the command's exit code
|
|
||||||
fn namespace_mode(args: Args, rootfs: std::path::PathBuf, config: Config) -> Result<i32> {
|
|
||||||
// Check user namespace availability
|
|
||||||
namespace::check_user_namespace()?;
|
|
||||||
|
|
||||||
// Process bind paths - use current directory if none specified
|
|
||||||
let cwd = std::env::current_dir().expect("Could not get current directory");
|
|
||||||
let bind_paths: Vec<std::path::PathBuf> = if args.bind.is_empty() && !args.no_bind {
|
|
||||||
vec![cwd.clone()]
|
|
||||||
} else {
|
|
||||||
args.bind.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
// --no-bind means "skip mounting any directory". Combining it with an
|
|
||||||
// explicit --bind-rw is contradictory; error rather than silently ignoring
|
|
||||||
// the flag the user asked for.
|
|
||||||
if args.no_bind && !args.bind_rw.is_empty() {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"--no-bind and --bind-rw cannot be used together: \
|
|
||||||
--no-bind skips all mounts, including read-write ones"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let bind_rw_paths: Vec<std::path::PathBuf> = args.bind_rw.clone();
|
|
||||||
|
|
||||||
// Prepare data for the closure
|
|
||||||
let bind_paths_clone = bind_paths.clone();
|
|
||||||
let bind_rw_paths_clone = bind_rw_paths.clone();
|
|
||||||
let args_clone = args.clone();
|
|
||||||
let rootfs_clone = rootfs.clone();
|
|
||||||
let dns_clone = config.dns.clone();
|
|
||||||
|
|
||||||
// Run in namespace
|
|
||||||
let result = namespace::setup_namespaces(move || -> Result<()> {
|
|
||||||
// Setup mounts - overlay_temps must be kept alive for overlay to work
|
|
||||||
let overlay_temps = mount::setup_mounts(
|
|
||||||
&rootfs_clone,
|
|
||||||
&bind_paths_clone,
|
|
||||||
&bind_rw_paths_clone,
|
|
||||||
&args_clone,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Write resolv.conf with DNS from config
|
|
||||||
write_resolv_conf(&rootfs_clone, &dns_clone)?;
|
|
||||||
|
|
||||||
// Run chroot
|
|
||||||
let command = if args_clone.command.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(args_clone.command.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = chroot::run_chroot(&rootfs_clone, command, &bind_rw_paths_clone);
|
|
||||||
|
|
||||||
// Keep overlay_temps alive until chroot exits
|
|
||||||
drop(overlay_temps);
|
|
||||||
|
|
||||||
result
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup happens automatically via tempfile
|
|
||||||
if result.is_ok() {
|
|
||||||
veprintln!("Cleanup complete.");
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a cache filename based on the image source
|
|
||||||
fn generate_cache_filename(source: &ImageSource, arch: &str) -> String {
|
|
||||||
match source {
|
|
||||||
ImageSource::DirectTarball { distro, version } => {
|
|
||||||
let distro_name = match distro {
|
|
||||||
Distro::Ubuntu => "ubuntu",
|
|
||||||
Distro::Alpine => "alpine",
|
|
||||||
};
|
|
||||||
let distro_arch = map_arch(*distro, arch);
|
|
||||||
// Get extension from URL
|
|
||||||
let url = resolve_distro_url(distro, version.as_deref(), arch).unwrap_or_default();
|
|
||||||
let ext = get_tarball_extension(&url);
|
|
||||||
format!(
|
|
||||||
"{}-{}-{}.{}",
|
|
||||||
distro_name,
|
|
||||||
version.as_deref().unwrap_or("latest"),
|
|
||||||
distro_arch,
|
|
||||||
ext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ImageSource::OciImage {
|
|
||||||
registry,
|
|
||||||
repository,
|
|
||||||
tag,
|
|
||||||
architecture,
|
|
||||||
} => {
|
|
||||||
// Sanitize for filename
|
|
||||||
let safe_registry = registry.replace(['.', ':'], "_");
|
|
||||||
let safe_repo = repository.replace(['/', ':'], "_");
|
|
||||||
format!(
|
|
||||||
"oci-{}-{}-{}-{}.tar.gz",
|
|
||||||
safe_registry, safe_repo, tag, architecture
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_tarball_extension(url: &str) -> &str {
|
|
||||||
// Extract extension from URL (e.g., .tar.gz, .tar.xz, .tar.zst)
|
|
||||||
if url.ends_with(".tar.zst") {
|
|
||||||
"tar.zst"
|
|
||||||
} else if url.ends_with(".tar.xz") {
|
|
||||||
"tar.xz"
|
|
||||||
} else if url.ends_with(".tar.gz") {
|
|
||||||
"tar.gz"
|
|
||||||
} else if url.ends_with(".tar.bz2") {
|
|
||||||
"tar.bz2"
|
|
||||||
} else {
|
|
||||||
"tar.gz" // default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_host_arch() -> String {
|
|
||||||
// Use the consolidated architecture detection from utils
|
|
||||||
utils::get_host_arch().debian_name().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_resolv_conf(rootfs: &std::path::Path, dns: &[String]) -> Result<()> {
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
let resolv_conf = rootfs.join("etc/resolv.conf");
|
|
||||||
|
|
||||||
// Create /etc if it doesn't exist
|
|
||||||
if let Some(parent) = resolv_conf.parent() {
|
|
||||||
std::fs::create_dir_all(parent)
|
|
||||||
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy host's resolv.conf if dns is empty, otherwise use provided DNS
|
|
||||||
let content = if dns.is_empty() {
|
|
||||||
// Try to copy from host
|
|
||||||
match std::fs::read_to_string("/etc/resolv.conf") {
|
|
||||||
Ok(host_resolv) => host_resolv,
|
|
||||||
Err(_) => "nameserver 1.1.1.1\nnameserver 8.8.8.8\n".to_string(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let mut c = dns
|
|
||||||
.iter()
|
|
||||||
.map(|s| format!("nameserver {}", s))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
c.push('\n');
|
|
||||||
c
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove any existing file or symlink before writing so that we always
|
|
||||||
// create a plain file. Without this, an absolute symlink such as
|
|
||||||
// /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf would cause the
|
|
||||||
// write to follow the symlink through the *host* root (chroot() has not been
|
|
||||||
// called yet) and corrupt the host's DNS configuration.
|
|
||||||
//
|
|
||||||
// Use atomic file creation with O_CREAT | O_EXCL to prevent TOCTOU race:
|
|
||||||
// if an attacker creates a symlink between our remove_file and write, the
|
|
||||||
// exclusive create will fail rather than writing to the symlink target.
|
|
||||||
let _ = std::fs::remove_file(&resolv_conf); // ignore ENOENT
|
|
||||||
|
|
||||||
// Use OpenOptions with create_new(true) for atomic exclusive creation
|
|
||||||
let mut file = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(&resolv_conf)
|
|
||||||
.with_context(|| {
|
|
||||||
format!(
|
|
||||||
"Failed to create resolv.conf at {} (symlink attack prevented)",
|
|
||||||
resolv_conf.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
file.write_all(content.as_bytes())
|
|
||||||
.with_context(|| format!("Failed to write to {}", resolv_conf.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user