Compare commits

..
17 Commits
Author SHA1 Message Date
vhaudiquet 42d15face5 fix(release): pin the tap formula to the release tarball
The formula's url pointed at the tag's source archive while its
sha256 came from the darwin tarball — two files, one checksum, so
brew refused every install. The formula now pours the darwin tarball
itself: the release workflow attaches it to the GitHub release, the
tap formula pins that asset's real sha256, and installs stop
compiling rust (the CI-signed entitlement also survives, which a
source build would have stripped).
2026-10-31 19:29:00 -05:00
vhaudiquet 53dd20b7f0 fix(release): create tap/Formula and push an explicit branch
The tap repo starts empty: the clone has no Formula/ directory, so
the cp failed. Push HEAD to main explicitly so the tap's branch does
not depend on the runner's init.defaultBranch.
2026-10-31 19:10:33 -05:00
vhaudiquet ac08b106bc fix(ci): the macos runner has no Hypervisor.framework
Hosted macOS runners run without nested virtualization: kern
.hv_support is 0 and no binary can boot a VM there, entitlement or
not. The macos job keeps the full compile gate and asserts the probe
reports the known HVF absence with a working engine load, so any
other regression still fails; the boot e2e stays on Linux and real
Macs.
2026-10-31 18:52:02 -05:00
vhaudiquet 775592482b docs: remove trust explanation from README 2026-10-31 18:46:17 -05:00
vhaudiquet f66024bfea docs: the brew tap-trust gate and the macOS from-source section
The one-command install edit had replaced the whole macOS section
instead of just the install lines, dropping the from-source sign
snippet; it is back, with the current entitlements path and
libarchive in the brew line. Homebrew's tap-trust gate now leads the
macOS install: dependencies from third-party taps stay refused until
their tap is trusted once, and libkrun ships from its own tap.
2026-10-31 18:45:06 -05:00
vhaudiquet 8c202c2a68 fix(ci): trust the krun tap before installing libkrun
Homebrew now refuses formulae from third-party taps unless trusted:
the explicitly requested formula is trusted automatically, but its
dependencies are not, so the install died on libkrunfw. Users hit
the same wall on their first install; the README leads with the
one-time brew trust now.
2026-10-31 18:42:56 -05:00
vhaudiquet 612826cca8 build: skip the tap update when the tap repo is absent
A token without its repo (or a typo in it) should not turn a release
red; an unreachable repo for a valid token still fails loudly.
2026-10-31 18:39:06 -05:00
vhaudiquet 561a241e09 build: render the tap formula against any tag pin
The tap job matched the literal v0.1.0 url line, so bumping the pin
broke the release render. The pin is now matched generically and
marked in-source as a placeholder the workflow rewrites; the tap
always ships the release tarball with its real sha256.
2026-10-31 18:35:55 -05:00
vhaudiquet d14d8c07bd build: move the entitlements next to the Homebrew formula
All consumers live in packaging already: the formula codesigns at
install, and the CI and release workflows codesign the built binary.
The macOS dir held nothing else.
2026-10-31 18:27:54 -05:00
vhaudiquet a3b4dca2d9 build: sync the Homebrew tap from the release workflow
The tap repo is a publish target like the snap store: the release
workflow rewrites the in-repo formula's url/sha256 for the release
tarball and pushes it to homebrew-ecrou. No token, no update — tags
still release.
2026-10-31 18:21:23 -05:00
vhaudiquet 3844038d3a docs: brew install with the one-command form
brew install accepts a fully-qualified tap path and taps implicitly,
so the separate brew tap step is noise for a first-time user.
2026-10-31 18:15:22 -05:00
vhaudiquet 6a631a3707 docs: document macOS support and Homebrew distribution
SPEC records the two macOS facts the code bends around (bare-soname
dlopens needing startup-time DYLD paths; the hypervisor entitlement)
and the packaging decisions; README gains the brew install and the
from-source sign step.
2026-10-31 18:10:33 -05:00
vhaudiquet ff73624891 build: add the macOS gate, darwin artifacts, and a Homebrew formula
CI runs fmt/clippy/test plus a real alpine boot on a macos-26 runner
with libkrun from the krun tap; the release workflow ships a signed
darwin-arm64 tarball beside the snaps. The formula installs from the
tap, carries the runtime kegs (libkrun, e2fsprogs, libarchive), and
codesigns the binary with the hypervisor entitlement — Hypervisor
.framework grants VM creation only to entitled binaries on current
macOS, the same requirement krunkit's formula signs for.
2026-10-31 18:10:33 -05:00
vhaudiquet f963578152 feat(cli): re-exec with DYLD fallback paths set
The engine's dlopen of libkrunfw and mke2fs's of libarchive use bare
sonames, and dyld reads DYLD_FALLBACK_LIBRARY_PATH once, at process
startup — a setenv afterwards is invisible to it. When the variable
is unset and the brew kegs exist, the CLI sets it and execv's itself:
exec restarts dyld, which reads the variable fresh. Library consumers
set the variable before their process starts instead (SPEC.md).
2026-10-31 18:10:33 -05:00
vhaudiquet 92ff76a1b6 test(rootfs): pin the arch and locate the keg tools
The cache-seeding tests seeded entry names under amd64 but resolved
the request against the host arch, so on an arm64 host the lookup
missed and the tests walked into the network path. Requests now pin
arch, matching the seeded names on any host. The tool gate uses
find_tool: on macOS the e2fsprogs binaries are keg-only, never on
PATH.
2026-10-31 18:10:32 -05:00
vhaudiquet 84ccff0157 feat(image): populate the image from a tree when mke2fs lacks libarchive
mke2fs built against libarchive populates an ext4 from a tarball in
one pass; Homebrew's bottle is not built that way, so when the found
mke2fs cannot take a tarball the builder unpacks the rootfs with the
library's own tar decoder and populates from the directory — slower,
and the run says so. The mkfs child is exec'd fresh, so it reads
DYLD_FALLBACK_LIBRARY_PATH (libarchive's keg) at startup.
2026-10-31 18:10:32 -05:00
vhaudiquet e075d52f91 feat(krun): run the library on macOS
The engine is libkrun.1.dylib, dlopen'd from the Homebrew kegs when
the loader's own search fails (bare sonames never reach the brew
prefix); the probe becomes a per-platform hypervisor probe — KVM on
Linux, sysctl + a real hv_vm_create on macOS, so an entitlement
denial (HV_DENIED) names the re-sign fix instead of surfacing as an
opaque krun_start_enter EINVAL. Tool lookup (mke2fs, debugfs) gains
the same keg fallback, and dyld_fallback_dirs() collects the kegs the
bare-soname dlopens (libkrunfw, libarchive) need in
DYLD_FALLBACK_LIBRARY_PATH. The machine child's parent-death contract
moves to a kqueue NOTE_EXIT watcher: macOS has no PR_SET_PDEATHSIG,
and SIGKILL-on-parent-exit is what keeps a killed caller from
stranding a booted machine.
2026-10-31 18:09:11 -05:00
17 changed files with 959 additions and 97 deletions

No files matched your search

+54
View File
@@ -38,6 +38,60 @@ jobs:
- name: cargo test - name: cargo test
run: cargo test run: cargo test
macos:
name: macos — fmt, clippy, test, probe
runs-on: macos-26
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
# Homebrew refuses formulae from third-party taps until the tap
# is trusted once; libkrun's dependencies come from the krun tap.
- name: Trust the krun tap
run: brew trust --tap libkrun/krun
# The engine is dlopen'd from the brew kegs at runtime; e2fsprogs
# supplies mke2fs and libarchive its tar path.
- name: Install the engine and the image tooling
run: brew install libkrun/homebrew-krun/libkrun e2fsprogs libarchive
- name: cargo fmt
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --all-targets -- -D warnings
- name: cargo test
run: cargo test
- name: Build the CLI
run: cargo build --release -p ecrou-cli
# HVF grants VM creation only to binaries carrying the hypervisor
# entitlement on current macOS; packaging signs the same way.
- name: Sign the binary with the hypervisor entitlement
run: codesign --force --sign - --entitlements packaging/homebrew/ecrou.entitlements target/release/ecrou
# GitHub's macOS runners expose no Hypervisor.framework (they run
# without nested virtualization), so nothing boots here — the
# boot e2e lives on Linux (the snap e2e) and on real Macs. The
# probe must still behave: the engine loads from the krun tap,
# the tools are found, and the HVF absence is reported as that.
- name: Probe
run: |
set -euo pipefail
out="$(./target/release/ecrou probe || true)"
printf '%s\n' "$out"
grep -q "libkrun: ok" <<<"$out"
grep -q "e2fsprogs: ok" <<<"$out"
grep -q "no Hypervisor.framework support" <<<"$out"
t6: t6:
name: T6 strace audit name: T6 strace audit
runs-on: ubuntu-latest runs-on: ubuntu-latest
+115
View File
@@ -62,6 +62,121 @@ jobs:
name: ${{ matrix.artifact }} name: ${{ matrix.artifact }}
path: ${{ steps.build.outputs.snap }} path: ${{ steps.build.outputs.snap }}
# The macos distribution is a signed tarball, not a store release:
# brew taps install from it, and the entitlement signature rides in
# the artifact.
build-macos:
needs: gate
if: needs.gate.outputs.release == 'true'
runs-on: macos-26
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Stamp the tag version into the manifests
run: |
v="${GITHUB_REF_NAME#v}"
sed -i '' "s/^version = \".*\"/version = \"$v\"/" Cargo.toml
grep -H '^version' Cargo.toml
- name: Build the CLI
run: cargo build --release -p ecrou-cli
- name: Sign the binary with the hypervisor entitlement
run: codesign --force --sign - --entitlements packaging/homebrew/ecrou.entitlements target/release/ecrou
- name: Package
run: |
v="${GITHUB_REF_NAME#v}"
tar -czf "ecrou-v$v-darwin-arm64.tar.gz" -C target/release ecrou
shasum -a 256 "ecrou-v$v-darwin-arm64.tar.gz" > "ecrou-v$v-darwin-arm64.tar.gz.sha256"
mkdir dist && mv ecrou-v$v-darwin-* dist/
- uses: actions/upload-artifact@v4
with:
name: ecrou-darwin-arm64
path: dist/*
# The tap repo (vhaudiquet/homebrew-ecrou) is a publish target, not
# the source of truth: the formula is maintained in packaging/ and
# this job rewrites its url/sha256 for the release tarball (the
# binary publish-release just attached) and pushes it. Needs a PAT with push access to the tap repo as the
# HOMEBREW_TAP_TOKEN secret; without it the job is a no-op so tags
# still release.
# The Homebrew formula pours this tarball, so it has to be attached
# to the release before the tap update runs.
publish-release:
needs: build-macos
runs-on: ubuntu-latest
permissions:
contents: write
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: ecrou-darwin-arm64
path: dist
- name: Create the release and upload the tarball
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
v="${GITHUB_REF_NAME#v}"
gh release create "v$v" --title "ecrou $v" --generate-notes || true
gh release upload "v$v" dist/* --clobber
update-homebrew:
needs: publish-release
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: ecrou-darwin-arm64
path: dist
- name: Push the formula to the tap
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
set -euo pipefail
if [[ -z "$TAP_TOKEN" ]]; then
echo "::notice::no HOMEBREW_TAP_TOKEN secret set; tap not updated"
exit 0
fi
v="${GITHUB_REF_NAME#v}"
sha="$(cut -d' ' -f1 "dist/ecrou-v$v-darwin-arm64.tar.gz.sha256")"
url="https://github.com/vhaudiquet/ecrou/releases/download/v$v/ecrou-v$v-darwin-arm64.tar.gz"
mkdir -p formula/Formula
sed -e '/^ # Placeholder pin:/d' -e "s|^ url \"https://github.com/vhaudiquet/ecrou.git\", tag: \"v[0-9][0-9.]*\"$| url \"$url\"\n sha256 \"$sha\"|" packaging/homebrew/Formula/ecrou.rb > formula/Formula/ecrou.rb
grep -q "sha256 \"$sha\"" formula/Formula/ecrou.rb
# A missing tap repo skips with a notice; a token that
# cannot clone an existing repo still fails the job.
code="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $TAP_TOKEN" \
https://api.github.com/repos/vhaudiquet/homebrew-ecrou)"
if [[ "$code" != "200" ]]; then
echo "::notice::vhaudiquet/homebrew-ecrou unreachable (HTTP $code); tap not updated"
exit 0
fi
git clone -q "https://x-access-token:$TAP_TOKEN@github.com/vhaudiquet/homebrew-ecrou" tap
mkdir -p tap/Formula
cp formula/Formula/ecrou.rb tap/Formula/ecrou.rb
cd tap
git add Formula/ecrou.rb
git -c user.name="ecrou-release" -c user.email="noreply@github.com" \
commit -m "ecrou $v"
git push -q origin HEAD:main
publish-snap: publish-snap:
needs: build-snap needs: build-snap
runs-on: ubuntu-latest runs-on: ubuntu-latest
Generated
+1
View File
@@ -385,6 +385,7 @@ dependencies = [
"clap", "clap",
"ecrou", "ecrou",
"indicatif", "indicatif",
"libc",
"log", "log",
] ]
+7 -1
View File
@@ -8,12 +8,18 @@
### Installation ### Installation
ecrou is available as a strictly confined snap: Linux (strictly confined snap, KVM required):
``` ```
sudo snap install ecrou sudo snap install ecrou
sudo snap connect ecrou:kvm sudo snap connect ecrou:kvm
``` ```
macOS (Apple Silicon, Homebrew):
```
brew trust --tap libkrun/krun
brew install vhaudiquet/ecrou/ecrou
```
### Usage ### Usage
``` ```
+81 -3
View File
@@ -34,9 +34,11 @@ and fails with a remediation message naming the install when it is
missing; nothing links at build time, and the snap bundles the library missing; nothing links at build time, and the snap bundles the library
plus libkrunfw next to itself. plus libkrunfw next to itself.
KVM is required; there is no TCG fallback. A machine without working KVM is required on Linux, Hypervisor.framework on macOS; there is no
KVM is `Error::KvmUnavailable` with the remediation text, and `ecrou TCG fallback. A machine without working KVM is `Error::KvmUnavailable`
probe` reports it before any run. The VMM runs inside the machine with the remediation text, and `ecrou probe` reports it before any
run (on macOS the probe goes further: it checks the hypervisor
entitlement too, see the macOS section). The VMM runs inside the machine
child, not on the caller's thread (see Cancellation). child, not on the caller's thread (see Cancellation).
libkrun's mapped-volumes entry point is explicitly unsupported on libkrun's mapped-volumes entry point is explicitly unsupported on
@@ -282,6 +284,82 @@ stop:
point: 24 s end to end, boot and apt included, cache-warm (snap point: 24 s end to end, boot and apt included, cache-warm (snap
0.1.0 engine, October 2026). 0.1.0 engine, October 2026).
## macOS
The same ABI, a different substrate. The engine is dlopen'd from the
Homebrew kegs (`libkrun` from the `libkrun/homebrew-krun` tap, which
builds the HVF backend and links Hypervisor.framework); the keg path
is a search fallback, not a hard-coded prefix, and the run fails with
a remediation naming the tap when the library is absent. macOS needs
no snap: no store sandbox, no staged libraries — Homebrew's prefix is
the distribution channel. There is no riscv64 or amd64 build; libkrun
supports Hypervisor.framework on arm64 only.
Two macOS facts shape the code:
**Bare-soname dlopens.** The engine loads `libkrunfw.5.dylib` by bare
soname at `krun_start_enter`, and a libarchive-capable `mke2fs` loads
`libarchive.13.dylib` the same way. dyld never searches the brew
prefix, and it reads `DYLD_FALLBACK_LIBRARY_PATH` once, at process
startup — a setenv afterwards is invisible to it. The CLI therefore
re-execs itself (exec restarts dyld) when the variable is unset and
the kegs exist; library consumers must set the variable themselves
before their process starts. mkfs children are exec'd fresh, so the
per-spawn env in image.rs works regardless.
**The hypervisor entitlement.** Hypervisor.framework grants VM
creation only to binaries signed with the
`com.apple.security.hypervisor` entitlement — current macOS enforces
this for ad-hoc-signed binaries too, where older releases granted the
framework implicitly. The denial surfaces as `hv_vm_create` returning
`HV_DENIED` (0xfae94007), which without a probe showed up only as an
opaque `krun_start_enter` EINVAL after a full image build. `probe` now
creates and destroys a real framework VM (dlopen'd, no link-time
dependency) so the denial is reported with the re-sign remediation
before any image work. Packaging signs at install (the Homebrew
formula, the same pattern krunkit's formula carries, and the release
tarball's codesign step); a `cargo build` developer re-signs after
each build, per the README snippet.
The ext4 image is built the same way as on Linux, with one fallback:
when the found `mke2fs` cannot populate from a tarball (Homebrew's
e2fsprogs bottle builds without libarchive), the image builder
unpacks the rootfs to a tree and lets mke2fs populate from the
directory — slower, and run states it in the log. The T6 strace audit
and the `/dev/kvm` gating stay Linux-only: strace does not exist on
macOS and the hypervisor gate is the entitlement probe instead. CI
runs the whole gate on a `macos-26` runner; hosted macOS runners
expose no Hypervisor.framework, so the boot e2e is the Linux snap
e2e and any real Mac — CI only asserts that the probe loads the
engine from the krun tap and reports the HVF absence. The release
workflow ships a signed darwin-arm64 tarball (build and sign only,
no boot).
## Homebrew
`packaging/homebrew/Formula/ecrou.rb` installs into a
`vhaudiquet/homebrew-ecrou` tap — the repo name is what makes
`brew install vhaudiquet/ecrou/ecrou` resolve without an explicit
`brew tap`. The formula pours the darwin tarball the release
workflow signs and attaches to the GitHub release: no local compile,
and the entitlement signature survives, which a rebuild here would
strip. The tap repo is a publish target, not the source of
truth: the release workflow rewrites the formula's url/sha256 for
the release tarball and pushes it (gated on a `HOMEBREW_TAP_TOKEN`
PAT; without the secret the job no-ops). The formula carries the runtime
dependencies (`libkrun` from the krun tap, `e2fsprogs`, `libarchive`),
builds from source with cargo, and re-signs the binary with the
entitlements plist from `packaging/homebrew/` — brew bottles are
ad-hoc signed, so the signature is part of the formula's install,
not of the artifact. The in-repo formula's tag pin is a placeholder:
the release workflow rewrites url and sha256 (of the release asset,
not the source archive — they are different files) when it pushes
the formula to the tap. Homebrew's tap-trust gate refuses dependencies
from third-party taps until their tap is trusted, so the README
leads with `brew trust --tap libkrun/krun` and CI trusts the tap
before its install; removing that tax is another argument for
libkrun landing in homebrew-core.
## Snap packaging ## Snap packaging
Confinement: strict. Plugs: `network`, `home`, `kvm`. The manual Confinement: strict. Plugs: `network`, `home`, `kvm`. The manual
+1
View File
@@ -12,6 +12,7 @@ name = "ecrou"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
libc = "0.2"
ecrou = { path = "../ecrou" } ecrou = { path = "../ecrou" }
# CLI parsing # CLI parsing
+83 -6
View File
@@ -16,6 +16,7 @@ use cli::{parse_env, parse_volume, Cli, Command, RunArgs};
use progress::TermProgress; use progress::TermProgress;
fn main() -> std::process::ExitCode { fn main() -> std::process::ExitCode {
reexec_with_dyld_fallback();
let cli = Cli::parse(); let cli = Cli::parse();
logger::init(cli.verbose); logger::init(cli.verbose);
match cli.command { match cli.command {
@@ -25,6 +26,67 @@ fn main() -> std::process::ExitCode {
} }
} }
/// The engine's dlopen of libkrunfw and mke2fs's dlopen of libarchive
/// both name bare sonames, and dyld reads DYLD_FALLBACK_LIBRARY_PATH
/// only once, at process startup — a setenv afterwards is invisible to
/// it. When the variable is unset and the brew kegs exist, re-exec:
/// exec restarts dyld, which reads the variable fresh. Library
/// consumers instead set the variable themselves before their process
/// starts (SPEC.md, macOS).
#[cfg(target_os = "macos")]
fn reexec_with_dyld_fallback() {
use std::os::unix::ffi::{OsStrExt as _, OsStringExt};
if std::env::var_os("DYLD_FALLBACK_LIBRARY_PATH").is_some() {
return;
}
let dirs = match ecrou::utils::dyld_fallback_dirs() {
Some(dirs) => dirs,
None => return,
};
std::env::set_var("DYLD_FALLBACK_LIBRARY_PATH", &dirs);
let program = match std::env::current_exe() {
Ok(path) => path,
Err(_) => return,
};
let mut argv: Vec<std::ffi::CString> = Vec::new();
let name = program
.file_name()
.map(|n| n.to_owned())
.unwrap_or_default();
argv.push(match std::ffi::CString::new(name.into_vec()) {
Ok(name) => name,
Err(_) => return,
});
let program = match std::ffi::CString::new(program.as_os_str().as_bytes()) {
Ok(program) => program,
Err(_) => return,
};
for arg in std::env::args_os().skip(1) {
match std::ffi::CString::new(arg.into_vec()) {
Ok(arg) => argv.push(arg),
// An argument with interior NULs cannot cross execv at
// all; the original process would have failed the same
// way, so bail out of the re-exec quietly.
Err(_) => return,
}
}
// Safety: execv replaces this process with itself, same program;
// on failure the process keeps running exactly as before.
// execv takes a C array of pointers into the strings, NUL-ended;
// the CStrings stay alive in `strings` across the call.
let mut pointers: Vec<*const libc::c_char> = argv.iter().map(|s| s.as_ptr().cast()).collect();
pointers.push(std::ptr::null());
// Safety: execv replaces this process with itself, same program;
// on failure the process keeps running exactly as before.
unsafe {
libc::execv(program.as_ptr().cast(), pointers.as_ptr());
}
}
#[cfg(not(target_os = "macos"))]
fn reexec_with_dyld_fallback() {}
fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
// A tty drives the payload's output through the terminal while the // A tty drives the payload's output through the terminal while the
// capture share points the same streams at files: one of them has // capture share points the same streams at files: one of them has
@@ -116,8 +178,15 @@ fn cmd_probe() -> std::process::ExitCode {
let mut ok = true; let mut ok = true;
print!("kvm: "); print!(
match krun::kvm_probe() { "{}: ",
if cfg!(target_os = "macos") {
"hypervisor"
} else {
"kvm"
}
);
match krun::hypervisor_probe() {
Ok(()) => println!("ok"), Ok(()) => println!("ok"),
Err(err) => { Err(err) => {
ok = false; ok = false;
@@ -145,12 +214,20 @@ fn cmd_probe() -> std::process::ExitCode {
print!("e2fsprogs: "); print!("e2fsprogs: ");
let mut tools_ok = true; let mut tools_ok = true;
for tool in ["mkfs.ext4", "debugfs"] { for tool in ["mkfs.ext4", "debugfs"] {
match std::process::Command::new(tool).arg("-V").output() { match ecrou::utils::find_tool(tool) {
Ok(_) => {} Some(path) => match std::process::Command::new(path).arg("-V").output() {
Err(err) => { Ok(_) => {}
Err(err) => {
ok = false;
tools_ok = false;
println!("{tool} not runnable ({err}); the rootfs image cannot be built");
break;
}
},
None => {
ok = false; ok = false;
tools_ok = false; tools_ok = false;
println!("missing {tool} ({err}); the rootfs image cannot be built"); println!("missing {tool}; install e2fsprogs, the rootfs image cannot be built");
break; break;
} }
} }
+26
View File
@@ -34,6 +34,7 @@ const KVM_NESTED_HINT: &str = "only actionable on x86_64 hosts";
/// Errors returned by the ecrou library API. /// Errors returned by the ecrou library API.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
#[allow(clippy::enum_variant_names)] // KvmUnavailable is the platform's word; HvUnavailable is macOS's
pub enum Error { pub enum Error {
/// No working KVM on this host, and ecrou has no software-emulation /// No working KVM on this host, and ecrou has no software-emulation
/// fallback. The remediation names the usual causes in order; the /// fallback. The remediation names the usual causes in order; the
@@ -49,6 +50,18 @@ pub enum Error {
)] )]
KvmUnavailable(#[source] std::io::Error), KvmUnavailable(#[source] std::io::Error),
/// No hardware virtualization on this Mac, and ecrou has no
/// software-emulation fallback: libkrun's macOS backend runs on
/// the Hypervisor.framework, which means Apple Silicon and
/// macOS 14 or newer.
#[error(
"Hypervisor.framework is not available\n\n\
ecrou boots microVMs and needs hardware virtualization:\n\
\x20 host: an Apple Silicon Mac (M1 or later)\n\
\x20 os: macOS 14 or newer"
)]
HvUnavailable(#[source] std::io::Error),
/// The registry publishes no linux manifest for the requested /// The registry publishes no linux manifest for the requested
/// architecture; `available` lists the architectures it does serve. /// architecture; `available` lists the architectures it does serve.
#[error("No manifest found for architecture '{arch}'. Available: {available}")] #[error("No manifest found for architecture '{arch}'. Available: {available}")]
@@ -92,6 +105,19 @@ mod tests {
assert!(printed.contains("Permission denied"), "{printed}"); assert!(printed.contains("Permission denied"), "{printed}");
} }
#[cfg(target_os = "macos")]
#[test]
fn hv_unavailable_renders_remediation() {
let msg = Error::HvUnavailable(std::io::Error::other("no hvf")).to_string();
assert!(msg.contains("Hypervisor.framework is not available"));
assert!(msg.contains("Apple Silicon"));
let printed = format!(
"{:#}",
anyhow::Error::new(Error::HvUnavailable(std::io::Error::other("no hvf")))
);
assert!(printed.contains("no hvf"), "{printed}");
}
#[test] #[test]
fn a_typed_error_survives_the_anyhow_conversion() { fn a_typed_error_survives_the_anyhow_conversion() {
let raised = let raised =
+183 -43
View File
@@ -9,6 +9,14 @@
//! lose them. An unprivileged extract cannot preserve a tar's owners //! lose them. An unprivileged extract cannot preserve a tar's owners
//! (chown to an arbitrary uid is root's alone); a tarball to image //! (chown to an arbitrary uid is root's alone); a tarball to image
//! pipeline never needs one. //! pipeline never needs one.
//!
//! Tarball populate is a build-time choice in e2fsprogs: compiled in,
//! it loads libarchive by dlopen at runtime (`libarchive.so.13` on
//! Linux, `libarchive.13.dylib` on macOS), so presence cannot be
//! assumed from the binary. A build without it (Homebrew's bottle, for
//! one) gets the tree-populate fallback: the tarball is unpacked and
//! mke2fs copies the directory, which loses the tar owners — flagged
//! through the progress channel, never silently.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
@@ -17,6 +25,16 @@ use anyhow::{anyhow, Context};
use crate::progress::Progress; use crate::progress::Progress;
/// Appended to a mkfs failure whose stderr names libarchive: the
/// tarball populate loads it by dlopen at runtime, and the message
/// names the platform's way to get the dylib in place.
#[cfg(target_os = "macos")]
const LIBARCHIVE_REMEDIATION: &str = "; the populate step loads libarchive \
by dlopen at runtime, and `brew install libarchive` provides the dylib";
#[cfg(not(target_os = "macos"))]
const LIBARCHIVE_REMEDIATION: &str = "; the populate step loads libarchive \
at runtime (the snap stages libarchive13 next to the binary)";
/// Build the rootfs image at `image` from `tarball`. /// Build the rootfs image at `image` from `tarball`.
/// ///
/// An OCI layer bundle merges into one plain tar first, so the whiteout /// An OCI layer bundle merges into one plain tar first, so the whiteout
@@ -41,7 +59,7 @@ pub(crate) fn build_rootfs_image(
} }
} }
let source: &Path = merged.as_deref().unwrap_or(tarball); let source: &Path = merged.as_deref().unwrap_or(tarball);
let result = mkfs_from_tar(source, image); let result = mkfs_from_tar(source, image, progress);
if let Some(side) = merged { if let Some(side) = merged {
// The merged archive has value only until mke2fs consumes it; // The merged archive has value only until mke2fs consumes it;
// a failure to clean up must not mask mkfs's own error. // a failure to clean up must not mask mkfs's own error.
@@ -71,7 +89,7 @@ pub(crate) fn looks_like_ext4(image: &Path) -> bool {
/// Build the entry image: mke2fs consumes the archive (merged first /// Build the entry image: mke2fs consumes the archive (merged first
/// when the tarball is an OCI layer bundle) and the result publishes /// when the tarball is an OCI layer bundle) and the result publishes
/// atomically. /// atomically.
fn mkfs_from_tar(tarball: &Path, image: &Path) -> anyhow::Result<()> { fn mkfs_from_tar(tarball: &Path, image: &Path, progress: &dyn Progress) -> anyhow::Result<()> {
// The build lands in a sibling file first and renames over the // The build lands in a sibling file first and renames over the
// image only on success. mke2fs creates and sizes the file before // image only on success. mke2fs creates and sizes the file before
// it populates, so a failed build would otherwise leave a // it populates, so a failed build would otherwise leave a
@@ -79,7 +97,7 @@ fn mkfs_from_tar(tarball: &Path, image: &Path) -> anyhow::Result<()> {
// than the tarball it came from, and trusted by the freshness // than the tarball it came from, and trusted by the freshness
// check forever after. // check forever after.
let staging = image.with_extension("part"); let staging = image.with_extension("part");
let built = build_ext4(tarball, &staging); let built = build_ext4(tarball, &staging, progress);
if built.is_ok() { if built.is_ok() {
std::fs::rename(&staging, image) std::fs::rename(&staging, image)
.with_context(|| format!("publishing {}", image.display()))?; .with_context(|| format!("publishing {}", image.display()))?;
@@ -89,10 +107,75 @@ fn mkfs_from_tar(tarball: &Path, image: &Path) -> anyhow::Result<()> {
built built
} }
/// Whether the system mke2fs populates straight from a tarball. The
/// support is compiled in or out (and dlopen'd at runtime when in), so
/// the only honest probe is the real tool over a minimal archive:
/// build a four-MiB image from a one-entry tarball once per process
/// and cache the verdict.
fn mke2fs_reads_tarballs() -> bool {
static VERDICT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*VERDICT.get_or_init(|| {
let scratch = match tempfile::tempdir() {
Ok(dir) => dir,
Err(_) => return false,
};
let tarball = scratch.path().join("probe.tar");
let file = match std::fs::File::create(&tarball) {
Ok(file) => file,
Err(_) => return false,
};
let mut builder = tar::Builder::new(file);
let mut header = tar::Header::new_gnu();
header.set_size(0);
header.set_mode(0o755);
header.set_entry_type(tar::EntryType::Regular);
if builder
.append_data(&mut header, "probe", std::io::empty())
.is_err()
{
return false;
}
let image = scratch.path().join("probe.ext4");
if std::fs::File::create(&image)
.and_then(|f| f.set_len(4 * 1024 * 1024))
.is_err()
{
return false;
}
match mkfs_tool() {
Some(mut cmd) => cmd
.args(["-q", "-F", "-N", "16", "-d"])
.arg(&tarball)
.arg(&image)
.output()
.map(|out| out.status.success())
.unwrap_or(false),
None => false,
}
})
}
/// The mke2fs command, resolved through the tool lookup and, on macOS,
/// pointed at the directories a dlopen'd libarchive can live in.
fn mkfs_tool() -> Option<std::process::Command> {
let mut cmd = Command::new(crate::utils::find_tool("mkfs.ext4")?);
// The child execs fresh, so it reads the variable at startup even
// when the parent process itself never had it.
if let Some(dirs) = crate::utils::dyld_fallback_dirs() {
cmd.env("DYLD_FALLBACK_LIBRARY_PATH", dirs);
}
Some(cmd)
}
/// The debugfs command, resolved through the tool lookup.
fn debugfs_tool() -> Option<Command> {
crate::utils::find_tool("debugfs").map(Command::new)
}
/// Create `image`, size it from a scan of the archive, and run mke2fs /// Create `image`, size it from a scan of the archive, and run mke2fs
/// over it. The filesystem size and inode count come from the scan: /// over it. The filesystem size and inode count come from the scan:
/// mke2fs derives neither from a tar. /// mke2fs derives neither from a tar.
fn build_ext4(tarball: &Path, image: &Path) -> anyhow::Result<()> { fn build_ext4(tarball: &Path, image: &Path, progress: &dyn Progress) -> anyhow::Result<()> {
let (bytes, entries) = tar_scan(tarball)?; let (bytes, entries) = tar_scan(tarball)?;
// Headroom for the journal, the inode tables and block rounding. // Headroom for the journal, the inode tables and block rounding.
// The file is created sparse, so generous headroom costs nothing // The file is created sparse, so generous headroom costs nothing
@@ -115,16 +198,44 @@ fn build_ext4(tarball: &Path, image: &Path) -> anyhow::Result<()> {
.with_context(|| format!("sizing {}", image.display()))?; .with_context(|| format!("sizing {}", image.display()))?;
drop(file); drop(file);
let out = Command::new("mkfs.ext4") // Tarball populate when the tool can, tree populate when it
// cannot: the fallback loses the tar's owners (every entry takes
// the invoking user's), which is said out loud, not swallowed.
let extracted: Option<PathBuf> = if mke2fs_reads_tarballs() {
None
} else {
let dir = image.with_extension("populate");
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
crate::extract::tar_decoder(tarball)?
.unpack(&dir)
.with_context(|| format!("unpacking {} into {}", tarball.display(), dir.display()))?;
progress.message(
"this mke2fs cannot read tarballs (no libarchive); populating \
from the extracted tree, so tar owners are lost",
);
Some(dir)
};
let populate: &Path = extracted.as_deref().unwrap_or(tarball);
let out = mkfs_tool()
.ok_or_else(|| anyhow!("mkfs.ext4 not found: install e2fsprogs"))?
.args(["-q", "-F", "-N", &inodes.to_string(), "-d"]) .args(["-q", "-F", "-N", &inodes.to_string(), "-d"])
.arg(tarball) .arg(populate)
.arg(image) .arg(image)
.output() .output()
.map_err(|e| anyhow!("mkfs.ext4 not found ({e}): install e2fsprogs; the snap stages it"))?; .map_err(|e| anyhow!("mkfs.ext4 not runnable ({e}): install e2fsprogs"))?;
if let Some(dir) = extracted {
// A failure to clean the tree must not mask mkfs's own verdict.
let _ = std::fs::remove_dir_all(dir);
}
if !out.status.success() { if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let note = if stderr.contains("libarchive") {
LIBARCHIVE_REMEDIATION
} else {
""
};
return Err(anyhow!( return Err(anyhow!(
"mkfs.ext4 failed building the rootfs image: {}", "mkfs.ext4 failed building the rootfs image: {stderr}{note}"
String::from_utf8_lossy(&out.stderr).trim()
)); ));
} }
Ok(()) Ok(())
@@ -143,21 +254,32 @@ fn tar_scan(tarball: &Path) -> anyhow::Result<(u64, u64)> {
Ok((bytes, entries)) Ok((bytes, entries))
} }
/// Copy the cache entry image into the scratch directory. cp reflinks /// Copy the cache entry image into the scratch directory. On Linux cp
/// where the filesystem has them and keeps the copy sparse either way: /// reflinks where the filesystem has them and keeps the copy sparse;
/// an image occupying 400 MiB must not materialize a GiB of holes. /// an image occupying 400 MiB must not materialize a GiB of holes. On
/// macOS APFS clones the file at the block level through fclonefileat,
/// which std::fs::copy drives (and a cross-volume copy falls back to a
/// plain read/write).
pub(crate) fn copy_image(src: &Path, dst: &Path) -> anyhow::Result<()> { pub(crate) fn copy_image(src: &Path, dst: &Path) -> anyhow::Result<()> {
let out = Command::new("cp") #[cfg(target_os = "macos")]
.args(["--reflink=auto", "--sparse=auto"]) {
.arg(src) std::fs::copy(src, dst)
.arg(dst) .with_context(|| format!("copying {} to {}", src.display(), dst.display()))?;
.output() }
.map_err(|e| anyhow!("cp not found ({e}): coreutils is required"))?; #[cfg(not(target_os = "macos"))]
if !out.status.success() { {
return Err(anyhow!( let out = Command::new("cp")
"copying the rootfs image failed: {}", .args(["--reflink=auto", "--sparse=auto"])
String::from_utf8_lossy(&out.stderr).trim() .arg(src)
)); .arg(dst)
.output()
.map_err(|e| anyhow!("cp not found ({e}): coreutils is required"))?;
if !out.status.success() {
return Err(anyhow!(
"copying the rootfs image failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
} }
Ok(()) Ok(())
} }
@@ -190,12 +312,13 @@ pub(crate) fn provision(
script.push_str(&format!("sif {guest} mode 0{:o}\n", 0o100000 | mode)); script.push_str(&format!("sif {guest} mode 0{:o}\n", 0o100000 | mode));
} }
std::fs::write(&cmds, script).with_context(|| format!("writing {}", cmds.display()))?; std::fs::write(&cmds, script).with_context(|| format!("writing {}", cmds.display()))?;
let out = Command::new("debugfs") let out = debugfs_tool()
.ok_or_else(|| anyhow!("debugfs not found: install e2fsprogs"))?
.args(["-w", "-f"]) .args(["-w", "-f"])
.arg(&cmds) .arg(&cmds)
.arg(image) .arg(image)
.output() .output()
.map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs; the snap stages it"))?; .map_err(|e| anyhow!("debugfs not runnable ({e})"))?;
let _ = std::fs::remove_file(&cmds); let _ = std::fs::remove_file(&cmds);
if !out.status.success() { if !out.status.success() {
return Err(anyhow!( return Err(anyhow!(
@@ -212,12 +335,13 @@ pub(crate) fn provision(
/// reader (the exit-code control file) holds an integer, so those /// reader (the exit-code control file) holds an integer, so those
/// lines are dropped. An absent file reads back empty. /// lines are dropped. An absent file reads back empty.
pub(crate) fn read_file(image: &Path, guest: &str) -> anyhow::Result<String> { pub(crate) fn read_file(image: &Path, guest: &str) -> anyhow::Result<String> {
let out = Command::new("debugfs") let out = debugfs_tool()
.ok_or_else(|| anyhow!("debugfs not found: install e2fsprogs"))?
.arg("-R") .arg("-R")
.arg(format!("cat {guest}")) .arg(format!("cat {guest}"))
.arg(image) .arg(image)
.output() .output()
.map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs"))?; .map_err(|e| anyhow!("debugfs not runnable ({e})"))?;
let text = String::from_utf8_lossy(&out.stdout); let text = String::from_utf8_lossy(&out.stdout);
Ok(text Ok(text
.lines() .lines()
@@ -229,12 +353,13 @@ pub(crate) fn read_file(image: &Path, guest: &str) -> anyhow::Result<String> {
/// Whether a guest path exists in the image: a debugfs stat that finds /// Whether a guest path exists in the image: a debugfs stat that finds
/// an inode. Symlinks resolve; stat follows them like the guest will. /// an inode. Symlinks resolve; stat follows them like the guest will.
pub(crate) fn stat_exists(image: &Path, guest: &str) -> anyhow::Result<bool> { pub(crate) fn stat_exists(image: &Path, guest: &str) -> anyhow::Result<bool> {
let out = Command::new("debugfs") let out = debugfs_tool()
.ok_or_else(|| anyhow!("debugfs not found: install e2fsprogs"))?
.arg("-R") .arg("-R")
.arg(format!("stat {guest}")) .arg(format!("stat {guest}"))
.arg(image) .arg(image)
.output() .output()
.map_err(|e| anyhow!("debugfs not found ({e}): install e2fsprogs"))?; .map_err(|e| anyhow!("debugfs not runnable ({e})"))?;
Ok(String::from_utf8_lossy(&out.stdout).contains("Inode:")) Ok(String::from_utf8_lossy(&out.stdout).contains("Inode:"))
} }
@@ -256,8 +381,8 @@ mod tests {
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
fn have_e2fsprogs() -> bool { fn have_e2fsprogs() -> bool {
Command::new("mkfs.ext4").arg("-V").output().is_ok() crate::utils::find_tool("mkfs.ext4").is_some()
&& Command::new("debugfs").arg("-V").output().is_ok() && crate::utils::find_tool("debugfs").is_some()
} }
/// A tarball with one root-owned directory and a file owned by uid /// A tarball with one root-owned directory and a file owned by uid
@@ -343,23 +468,37 @@ mod tests {
assert!(looks_like_ext4(&image), "the built image lacks the magic"); assert!(looks_like_ext4(&image), "the built image lacks the magic");
assert!(!work.path().join("rootfs.part").exists()); assert!(!work.path().join("rootfs.part").exists());
let out = Command::new("debugfs") let out = debugfs_tool()
.unwrap()
.arg("-R") .arg("-R")
.arg("stat /etc/marker") .arg("stat /etc/marker")
.arg(&image) .arg(&image)
.output() .output()
.unwrap(); .unwrap();
let stat = String::from_utf8_lossy(&out.stdout); let stat = String::from_utf8_lossy(&out.stdout);
assert!( if mke2fs_reads_tarballs() {
stat.lines() // Tarball populate bakes the archive's owners in.
.any(|l| l.contains("User:") && l.contains("104")), assert!(
"{stat}" stat.lines()
); .any(|l| l.contains("User:") && l.contains("104")),
assert!( "{stat}"
stat.lines() );
.any(|l| l.contains("Group:") && l.contains("104")), assert!(
"{stat}" stat.lines()
); .any(|l| l.contains("Group:") && l.contains("104")),
"{stat}"
);
} else {
// The tree fallback cannot: every entry carries the
// invoking user's ids, which is the documented loss.
use std::os::unix::fs::MetadataExt;
let uid = std::fs::metadata(&image).unwrap().uid();
assert!(
stat.lines()
.any(|l| l.contains("User:") && l.contains(&uid.to_string())),
"{stat}"
);
}
} }
#[test] #[test]
@@ -393,7 +532,8 @@ mod tests {
assert!(stat_exists(&image, "/ecrou-init").unwrap()); assert!(stat_exists(&image, "/ecrou-init").unwrap());
assert!(!stat_exists(&image, "/etc/marker-does-not-exist").unwrap()); assert!(!stat_exists(&image, "/etc/marker-does-not-exist").unwrap());
let out = Command::new("debugfs") let out = debugfs_tool()
.unwrap()
.arg("-R") .arg("-R")
.arg("stat /ecrou-init") .arg("stat /ecrou-init")
.arg(&image) .arg(&image)
+183 -19
View File
@@ -1,13 +1,14 @@
//! Runtime bindings to libkrun's stable C ABI. //! Runtime bindings to libkrun's stable C ABI.
//! //!
//! The library (`libkrun.so.1`) loads through dlopen so a missing //! The library (`libkrun.so.1` on Linux, `libkrun.1.dylib` on macOS)
//! install is a remediation message rather than a link error. The //! loads through dlopen so a missing install is a remediation message
//! rather than a link error. The
//! symbol signatures mirror the `stable-1.19.x` headers; the main //! symbol signatures mirror the `stable-1.19.x` headers; the main
//! branch is mid-rewrite to a vtable API and is not the target. //! branch is mid-rewrite to a vtable API and is not the target.
//! //!
//! The founding rule holds for this module too: libkrun's VMM runs in //! The founding rule holds for this module too: libkrun's VMM runs in
//! our process and touches only files, sockets, and `/dev/kvm`, all //! our process and touches only files and sockets (plus `/dev/kvm` on
//! inside the confinement budget (SPEC.md). //! Linux), all inside the confinement budget (SPEC.md).
use std::ffi::{c_char, CString}; use std::ffi::{c_char, CString};
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
@@ -19,16 +20,41 @@ use crate::Error;
/// The ABI version libkrun reports for a working `/dev/kvm` /// The ABI version libkrun reports for a working `/dev/kvm`
/// (`KVM_GET_API_VERSION`, the first request of the KVMIO range). /// (`KVM_GET_API_VERSION`, the first request of the KVMIO range).
#[cfg(target_os = "linux")]
const KVM_GET_API_VERSION: libc::c_ulong = 0xAE00; const KVM_GET_API_VERSION: libc::c_ulong = 0xAE00;
#[cfg(target_os = "linux")]
const KVM_API_VERSION_EXPECTED: libc::c_int = 12; const KVM_API_VERSION_EXPECTED: libc::c_int = 12;
#[cfg(target_os = "macos")]
pub const SONAME: &str = "libkrun.1.dylib";
#[cfg(not(target_os = "macos"))]
pub const SONAME: &str = "libkrun.so.1"; pub const SONAME: &str = "libkrun.so.1";
/// Remediation shown when libkrun cannot be loaded: distro packaging is /// Remediation shown when libkrun cannot be loaded: on macOS the
/// still sparse (Fedora carries it; Debian and Ubuntu do not yet). /// official Homebrew tap bottles the library; Linux distro packaging
/// is still sparse (Fedora carries it; Debian and Ubuntu do not yet).
#[cfg(target_os = "macos")]
const LOAD_REMEDIATION: &str = "install it with `brew install libkrun/krun/libkrun`, \
or point DYLD_LIBRARY_PATH at the directory holding it";
#[cfg(not(target_os = "macos"))]
const LOAD_REMEDIATION: &str = "install libkrun, or point LD_LIBRARY_PATH \ const LOAD_REMEDIATION: &str = "install libkrun, or point LD_LIBRARY_PATH \
at the directory holding it (a snap stages it next to the binary)"; at the directory holding it (a snap stages it next to the binary)";
/// The paths dlopen is tried against, in order. A bare soname relies
/// on the loader's own search (LD_LIBRARY_PATH on Linux; on macOS it
/// never reaches the Homebrew prefix, so the keg paths are named).
fn load_candidates() -> Vec<std::path::PathBuf> {
let mut candidates = vec![std::path::PathBuf::from(SONAME)];
for prefix in crate::utils::BREW_PREFIXES {
candidates.push(
std::path::Path::new(prefix)
.join("opt/libkrun/lib")
.join(SONAME),
);
}
candidates
}
macro_rules! symbol { macro_rules! symbol {
($lib:expr, $name:expr, $ty:ty) => { ($lib:expr, $name:expr, $ty:ty) => {
symbol!($lib, $name, $ty, "libkrun too old?") symbol!($lib, $name, $ty, "libkrun too old?")
@@ -65,11 +91,24 @@ pub struct Krun {
impl Krun { impl Krun {
/// dlopen libkrun, failing with the install remediation. /// dlopen libkrun, failing with the install remediation.
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {
// Safety: dlopen of a fixed SONAME; the handle lives in `self` let candidates = load_candidates();
// and every symbol below is used through it. let mut last: Option<anyhow::Error> = None;
let lib = unsafe { libloading::Library::new(SONAME) } for candidate in &candidates {
.with_context(|| format!("cannot load {SONAME}: {LOAD_REMEDIATION}"))?; // Safety: dlopen of a fixed name; the handle lives in
Ok(Krun { lib }) // `self` and every symbol below is used through it.
let lib = unsafe { libloading::Library::new(candidate) };
match lib {
Ok(lib) => return Ok(Krun { lib }),
Err(err) => last = Some(anyhow::Error::new(err)),
}
}
let tried = candidates
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
Err(last.unwrap_or_else(|| anyhow::anyhow!("no library name to try")))
.with_context(|| format!("cannot load {SONAME} (tried {tried}): {LOAD_REMEDIATION}"))
} }
/// `krun_set_log_level` /// `krun_set_log_level`
@@ -327,9 +366,12 @@ fn path_cstring(path: &Path) -> Result<CString> {
.with_context(|| format!("path contains a NUL: {}", path.display())) .with_context(|| format!("path contains a NUL: {}", path.display()))
} }
/// Probe KVM the crun way: open `/dev/kvm` read-write, then check /// Probe the host hypervisor. On Linux that is KVM: open `/dev/kvm`
/// `KVM_GET_API_VERSION` returns 12. /// read-write, then check `KVM_GET_API_VERSION` returns 12. On macOS
pub fn kvm_probe() -> Result<()> { /// libkrun runs on the Hypervisor.framework, which exists on Apple
/// Silicon Macs and nothing else.
#[cfg(target_os = "linux")]
pub fn hypervisor_probe() -> Result<()> {
use std::os::unix::io::AsRawFd; use std::os::unix::io::AsRawFd;
let file = std::fs::OpenOptions::new() let file = std::fs::OpenOptions::new()
.read(true) .read(true)
@@ -355,10 +397,102 @@ pub fn kvm_probe() -> Result<()> {
Ok(()) Ok(())
} }
/// The Hypervisor.framework check, in two steps. `sysctl
/// kern.hv_support` says the machine has the framework; a real
/// `hv_vm_create` says this process may use it — on current macOS the
/// entitlement check inside the framework is what kills most
/// processes, and the denial used to surface only as an opaque
/// `krun_start_enter` EINVAL after a full image build.
#[cfg(target_os = "macos")]
pub fn hypervisor_probe() -> Result<()> {
// libkrun's macOS backend is HVF and arm64-only; there is no
// build for Intel Macs to probe for.
#[cfg(not(target_arch = "aarch64"))]
{
return Err(anyhow::Error::new(Error::HvUnavailable(
std::io::Error::other(
"libkrun ships no macOS backend for Intel Macs; an Apple Silicon host is required",
),
)));
}
#[cfg(target_arch = "aarch64")]
{
let mut supported: libc::c_int = 0;
let mut len = std::mem::size_of_val(&supported);
// Safety: sysctlbyname with a fixed name and an int-sized
// output buffer; the kernel writes only into `supported`.
let rc = unsafe {
libc::sysctlbyname(
c"kern.hv_support".as_ptr(),
(&mut supported as *mut libc::c_int).cast(),
&mut len,
std::ptr::null_mut(),
0,
)
};
if rc != 0 {
let err = std::io::Error::last_os_error();
return Err(anyhow::Error::new(Error::HvUnavailable(err))
.context("reading sysctl kern.hv_support"));
}
if supported != 1 {
return Err(anyhow::Error::new(Error::HvUnavailable(
std::io::Error::other("this Mac reports no Hypervisor.framework support"),
)));
}
hypervisor_entitlement_probe()
}
}
/// Create and destroy a real framework VM. `HV_DENIED` is the denial
/// every macOS packaging step has to design around: the framework
/// grants VM creation only to binaries signed with the
/// `com.apple.security.hypervisor` entitlement, so the fix is a
/// re-sign, not a system change. The framework is dlopen'd by
/// absolute path — ecrou links nothing, and the path is stable across
/// releases; if it cannot be opened at all, the probe defers to the
/// engine instead of inventing a verdict.
#[cfg(target_os = "macos")]
fn hypervisor_entitlement_probe() -> Result<()> {
const HV_DENIED: u32 = 0xfae94007;
let framework =
std::path::Path::new("/System/Library/Frameworks/Hypervisor.framework/Hypervisor");
// Safety: dlopen of a fixed absolute framework path; symbols are
// resolved immediately and the handle lives for this function.
let lib = match unsafe { libloading::Library::new(framework) } {
Ok(lib) => lib,
Err(_) => return Ok(()),
};
let create = symbol!(
&lib,
b"hv_vm_create\0",
unsafe extern "C" fn(*const std::ffi::c_void) -> u32
);
let destroy = symbol!(&lib, b"hv_vm_destroy\0", unsafe extern "C" fn() -> u32);
// Safety: plain calls into the framework; the VM is destroyed
// right away, or never created.
let ret = unsafe { create(std::ptr::null()) };
if ret == 0 {
unsafe { destroy() };
return Ok(());
}
let reason = if ret == HV_DENIED {
"the framework denied the process (HV_DENIED): sign the binary with the \
com.apple.security.hypervisor entitlement, the way packaging does — \
`codesign --force --sign - --entitlements <plist> <binary>`"
} else {
"the framework refused VM creation"
};
Err(anyhow::Error::new(Error::HvUnavailable(
std::io::Error::other(format!("{reason} (hv error 0x{ret:x})")),
)))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[cfg(target_os = "linux")]
#[test] #[test]
fn kvm_probe_constant_matches_kvm_abi() { fn kvm_probe_constant_matches_kvm_abi() {
// KVMIO base 0xAE; KVM_GET_API_VERSION is the first request. // KVMIO base 0xAE; KVM_GET_API_VERSION is the first request.
@@ -366,9 +500,10 @@ mod tests {
assert_eq!(KVM_API_VERSION_EXPECTED, 12); assert_eq!(KVM_API_VERSION_EXPECTED, 12);
} }
#[cfg(target_os = "linux")]
#[test] #[test]
fn kvm_probe_reports_unavailability_or_works() { fn kvm_probe_reports_unavailability_or_works() {
match kvm_probe() { match hypervisor_probe() {
Ok(()) => {} Ok(()) => {}
Err(err) => { Err(err) => {
let msg = format!("{err:#}"); let msg = format!("{err:#}");
@@ -378,6 +513,21 @@ mod tests {
} }
} }
#[cfg(target_os = "macos")]
#[test]
fn hypervisor_probe_reports_unavailability_or_works() {
match hypervisor_probe() {
Ok(()) => {}
Err(err) => {
let msg = format!("{err:#}");
assert!(
msg.contains("Hypervisor.framework"),
"unhelpful probe error: {msg}"
);
}
}
}
#[test] #[test]
fn load_fails_with_remediation_or_round_trips() { fn load_fails_with_remediation_or_round_trips() {
match Krun::load() { match Krun::load() {
@@ -389,14 +539,28 @@ mod tests {
Err(err) => { Err(err) => {
let msg = format!("{err:#}"); let msg = format!("{err:#}");
assert!(msg.contains(SONAME), "bad load error: {msg}"); assert!(msg.contains(SONAME), "bad load error: {msg}");
assert!( // The remediation names the platform's install path:
msg.contains("LD_LIBRARY_PATH"), // the loader variable on Linux, the brew tap on macOS.
"remediation missing: {msg}" let hint = if cfg!(target_os = "macos") {
); "brew install"
} else {
"LD_LIBRARY_PATH"
};
assert!(msg.contains(hint), "remediation missing: {msg}");
} }
} }
} }
#[test]
fn load_candidates_start_with_the_bare_soname() {
assert_eq!(load_candidates()[0], std::path::PathBuf::from(SONAME));
// The brew keg paths exist only as candidates: dlopen tries
// them in order and they are listed even when absent.
if cfg!(target_os = "macos") {
assert!(load_candidates().len() > 1);
}
}
#[test] #[test]
fn ptr_array_is_null_terminated() { fn ptr_array_is_null_terminated() {
let args = CArgs::build(&["a".to_string(), "b".to_string()]).unwrap(); let args = CArgs::build(&["a".to_string(), "b".to_string()]).unwrap();
+3 -3
View File
@@ -17,9 +17,9 @@
//! preparation) and [`vm`] (running a command inside a prepared //! preparation) and [`vm`] (running a command inside a prepared
//! rootfs). Public functions return [`Result`] carrying the [`Error`] //! rootfs). Public functions return [`Result`] carrying the [`Error`]
//! enum, so consumers can match on host-readiness failures //! enum, so consumers can match on host-readiness failures
//! ([`Error::KvmUnavailable`], [`Error::ArchNotAvailable`]) versus //! ([`Error::KvmUnavailable`], [`Error::HvUnavailable`],
//! ordinary failures ([`Error::Other`], which preserves the full context //! [`Error::ArchNotAvailable`]) versus ordinary failures
//! chain). //! ([`Error::Other`], which preserves the full context chain).
//! //!
//! ## Crate split //! ## Crate split
//! //!
+18 -12
View File
@@ -405,10 +405,8 @@ mod tests {
use crate::progress::NoProgress; use crate::progress::NoProgress;
fn have_e2fsprogs() -> bool { fn have_e2fsprogs() -> bool {
std::process::Command::new("mkfs.ext4") crate::utils::find_tool("mkfs.ext4").is_some()
.arg("-V") && crate::utils::find_tool("debugfs").is_some()
.output()
.is_ok()
} }
#[test] #[test]
@@ -447,9 +445,10 @@ mod tests {
.unwrap(); .unwrap();
tar.into_inner().unwrap().finish().unwrap(); tar.into_inner().unwrap().finish().unwrap();
let req = PrepareRequest::new( let mut req = PrepareRequest::new(
"ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "ubuntu@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
); );
req.arch = "amd64".to_string();
let pulled = cache.pull(&req, &NoProgress).unwrap(); let pulled = cache.pull(&req, &NoProgress).unwrap();
assert!(!pulled.downloaded); assert!(!pulled.downloaded);
assert_eq!(pulled.cache_path, entry); assert_eq!(pulled.cache_path, entry);
@@ -474,9 +473,10 @@ mod tests {
// real pull flow. // real pull flow.
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let cache = RootfsCache::new(dir.path().to_path_buf()); let cache = RootfsCache::new(dir.path().to_path_buf());
let req = PrepareRequest::new( let mut req = PrepareRequest::new(
"ubuntu@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "ubuntu@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
); );
req.arch = "amd64".to_string();
// A pinned OCI source resolves without the network, so // A pinned OCI source resolves without the network, so
// parse_image_ref succeeds; the entry is absent, so pull takes // parse_image_ref succeeds; the entry is absent, so pull takes
// the download path and fails in fill_entry (no registry // the download path and fails in fill_entry (no registry
@@ -502,7 +502,8 @@ mod tests {
touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz"); touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz");
touch_entry(&cache, "alpine-3.22.9-x86_64.tar.gz"); touch_entry(&cache, "alpine-3.22.9-x86_64.tar.gz");
let req = PrepareRequest::new("alpine:3.23"); let mut req = PrepareRequest::new("alpine:3.23");
req.arch = "amd64".to_string();
let (source, path, downloaded) = cache.ensure_entry(&req, &NoProgress, false).unwrap(); let (source, path, downloaded) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
assert!(!downloaded); assert!(!downloaded);
assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz")); assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz"));
@@ -519,7 +520,8 @@ mod tests {
touch_entry(&cache, "alpine-3.20.3-x86_64.tar.gz"); touch_entry(&cache, "alpine-3.20.3-x86_64.tar.gz");
touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz"); touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz");
let req = PrepareRequest::new("alpine:3.23"); let mut req = PrepareRequest::new("alpine:3.23");
req.arch = "amd64".to_string();
let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap(); let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz")); assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz"));
} }
@@ -531,13 +533,15 @@ mod tests {
touch_entry(&cache, "alpine-20250401-x86_64.tar.gz"); touch_entry(&cache, "alpine-20250401-x86_64.tar.gz");
touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz"); touch_entry(&cache, "alpine-3.23.0-x86_64.tar.gz");
let req = PrepareRequest::new("alpine:edge"); let mut req = PrepareRequest::new("alpine:edge");
req.arch = "amd64".to_string();
let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap(); let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
assert_eq!(path, dir.path().join("alpine-20250401-x86_64.tar.gz")); assert_eq!(path, dir.path().join("alpine-20250401-x86_64.tar.gz"));
// Plain "alpine" floats among releases; the edge date is // Plain "alpine" floats among releases; the edge date is
// invisible to it. // invisible to it.
let req = PrepareRequest::new("alpine"); let mut req = PrepareRequest::new("alpine");
req.arch = "amd64".to_string();
let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap(); let (_, path, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz")); assert_eq!(path, dir.path().join("alpine-3.23.0-x86_64.tar.gz"));
} }
@@ -549,7 +553,8 @@ mod tests {
touch_entry(&cache, "ubuntu-24.04-amd64.tar.gz"); touch_entry(&cache, "ubuntu-24.04-amd64.tar.gz");
touch_entry(&cache, "ubuntu-26.04-amd64.tar.gz"); touch_entry(&cache, "ubuntu-26.04-amd64.tar.gz");
let req = PrepareRequest::new("ubuntu:lts"); let mut req = PrepareRequest::new("ubuntu:lts");
req.arch = "amd64".to_string();
let (source, _, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap(); let (source, _, _) = cache.ensure_entry(&req, &NoProgress, false).unwrap();
let ImageSource::DirectTarball { version, .. } = source else { let ImageSource::DirectTarball { version, .. } = source else {
panic!("expected a direct tarball"); panic!("expected a direct tarball");
@@ -599,7 +604,8 @@ mod tests {
// tree itself: the marker is read back out of the image. // tree itself: the marker is read back out of the image.
let image = rootfs.dir().join("rootfs.img"); let image = rootfs.dir().join("rootfs.img");
assert!(image.exists()); assert!(image.exists());
let out = std::process::Command::new("debugfs") let debugfs = crate::utils::find_tool("debugfs").unwrap();
let out = std::process::Command::new(debugfs)
.arg("-R") .arg("-R")
.arg("cat /etc-marker") .arg("cat /etc-marker")
.arg(&image) .arg(&image)
+65 -8
View File
@@ -10,6 +10,54 @@ use anyhow::{anyhow, Context};
use crate::krun::Krun; use crate::krun::Krun;
use crate::{Error, Result}; use crate::{Error, Result};
/// The macOS stand-in for `prctl(PR_SET_PDEATHSIG)`: a thread in the
/// child registers a kqueue filter on the parent pid (EVFILT_PROC,
/// NOTE_EXIT) and kills this process with SIGKILL when the parent
/// exits. The kernel fires the filter for an already-dead pid's
/// registration too, and the getppid check catches the window where
/// the parent died before registration; SIGKILL itself cannot be
/// caught, which keeps the child's fate identical to Linux's.
#[cfg(target_os = "macos")]
fn watch_parent_or_die() {
std::thread::spawn(|| {
// Safety: kqueue/kevent on self-owned descriptors; the kevent
// structs are plain value types the kernel fills in.
unsafe {
let kq = libc::kqueue();
if kq < 0 {
return;
}
let change = libc::kevent {
ident: libc::getppid() as usize,
filter: libc::EVFILT_PROC,
flags: libc::EV_ADD,
fflags: libc::NOTE_EXIT,
data: 0,
udata: std::ptr::null_mut(),
};
let n = libc::kevent(kq, &change, 1, std::ptr::null_mut(), 0, std::ptr::null());
// A failed registration or an orphaned ppid means the
// parent is already gone.
if n != 0 || libc::getppid() == 1 {
libc::kill(libc::getpid(), libc::SIGKILL);
}
let mut event: libc::kevent = std::mem::zeroed();
loop {
let n = libc::kevent(kq, std::ptr::null(), 0, &mut event, 1, std::ptr::null());
if n > 0 {
break;
}
if n < 0
&& std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
{
break;
}
}
libc::kill(libc::getpid(), libc::SIGKILL);
}
});
}
/// Fork the machine process and enter the VM loop inside it. The /// Fork the machine process and enter the VM loop inside it. The
/// child only touches libkrun configuration before entering the VM /// child only touches libkrun configuration before entering the VM
/// loop, which never returns: a normal payload exit ends the child /// loop, which never returns: a normal payload exit ends the child
@@ -23,7 +71,7 @@ pub(crate) fn spawn_machine(
let child = unsafe { libc::fork() }; let child = unsafe { libc::fork() };
if child == 0 { if child == 0 {
// Safety: after fork the child runs no threads of its own; the // Safety: after fork the child runs no threads of its own; the
// prctl/exit pair has no unwinding preconditions. // kill/exit pair has no unwinding preconditions.
let bail = |msg: String, code: i32| -> ! { let bail = |msg: String, code: i32| -> ! {
// The console file is the channel the parent streams: a // The console file is the channel the parent streams: a
// boot failure belongs in it, next to whatever the guest // boot failure belongs in it, next to whatever the guest
@@ -37,15 +85,24 @@ pub(crate) fn spawn_machine(
eprintln!("Error: {msg}"); eprintln!("Error: {msg}");
unsafe { libc::_exit(code) } unsafe { libc::_exit(code) }
}; };
// The parent-death contract, per platform: Linux delivers
// SIGKILL through PR_SET_PDEATHSIG; macOS has no prctl, so
// a watcher thread follows the parent through a kqueue and
// kills this process when the parent goes away.
#[cfg(target_os = "linux")]
unsafe { unsafe {
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL); libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
// Headless only: unwire the console input (with a tty on }
// fd 0 libkrun puts that terminal into raw mode, which #[cfg(target_os = "macos")]
// fights whoever owns it, and stops a background caller watch_parent_or_die();
// with SIGTTOU). /dev/null keeps the console on its plain // Headless only: unwire the console input (with a tty on
// path and still forwards a Ctrl-C sent to the process // fd 0 libkrun puts that terminal into raw mode, which
// group into the guest. An interactive machine wants the // fights whoever owns it, and stops a background caller
// opposite: fd 0 is its console input by design. // with SIGTTOU). /dev/null keeps the console on its plain
// path and still forwards a Ctrl-C sent to the process
// group into the guest. An interactive machine wants the
// opposite: fd 0 is its console input by design.
unsafe {
if !interactive { if !interactive {
let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDWR); let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDWR);
if null >= 0 { if null >= 0 {
+90
View File
@@ -160,6 +160,96 @@ pub fn map_oci_arch(arch: &str) -> String {
Arch::from_str(arch).oci_name().to_string() Arch::from_str(arch).oci_name().to_string()
} }
// ============================================================================
// External tool lookup
// ============================================================================
/// The Homebrew prefixes the lookup falls back to. Empty off macOS:
/// Homebrew does not exist there and PATH is the whole story.
#[cfg(target_os = "macos")]
pub const BREW_PREFIXES: &[&str] = &["/opt/homebrew", "/usr/local"];
#[cfg(not(target_os = "macos"))]
pub const BREW_PREFIXES: &[&str] = &[];
/// The keg-only formulae the run's external tools come from, in the
/// order they should be preferred. e2fsprogs is keg-only in Homebrew
/// (its sbin never reaches PATH), and the tap ships its own variant
/// built with libarchive, which outranks the core bottle.
#[cfg(target_os = "macos")]
const BREW_TOOL_FORMULAE: &[&str] = &["e2fsprogs-libarchive", "e2fsprogs"];
#[cfg(not(target_os = "macos"))]
const BREW_TOOL_FORMULAE: &[&str] = &[];
/// Locate an external tool the run shells out to: PATH first, then
/// the Homebrew keg directories. Returns the path, so callers spawn a
/// deterministic binary instead of a whatever-came-first `Command`.
pub fn find_tool(name: &str) -> Option<PathBuf> {
if let Some(paths) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&paths) {
let candidate = dir.join(name);
if is_executable(&candidate) {
return Some(candidate);
}
}
}
for prefix in BREW_PREFIXES {
for formula in BREW_TOOL_FORMULAE {
for sub in ["sbin", "bin"] {
let candidate = Path::new(prefix)
.join("opt")
.join(formula)
.join(sub)
.join(name);
if is_executable(&candidate) {
return Some(candidate);
}
}
}
}
None
}
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
/// The brew kegs whose dylibs are loaded by bare soname at runtime:
/// libkrunfw (loaded by libkrun) and libarchive (loaded by a
/// libarchive-capable mke2fs). dyld's fallback search never reaches
/// the brew prefix, so these directories have to land in
/// DYLD_FALLBACK_LIBRARY_PATH before any of those loads happen.
#[cfg(target_os = "macos")]
const DYLD_KEGS: &[&str] = &["libkrunfw", "libarchive"];
#[cfg(not(target_os = "macos"))]
const DYLD_KEGS: &[&str] = &[];
/// Directories the bare-soname dlopens can be satisfied from, joined
/// for DYLD_FALLBACK_LIBRARY_PATH, or None when none of them exists.
pub fn dyld_fallback_dirs() -> Option<String> {
let mut dirs = Vec::new();
for prefix in BREW_PREFIXES {
for keg in DYLD_KEGS {
let dir = Path::new(prefix).join("opt").join(keg).join("lib");
if dir.is_dir() {
dirs.push(dir.into_os_string());
}
}
}
if dirs.is_empty() {
None
} else {
Some(
std::env::join_paths(dirs)
.ok()?
.to_string_lossy()
.into_owned(),
)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+2 -2
View File
@@ -2,7 +2,7 @@
//! payload with live stdio, propagate its exit code, throw the machine //! payload with live stdio, propagate its exit code, throw the machine
//! away. //! away.
//! //!
//! The flow is: KVM probe, rootfs prepare (cache-aware download and //! The flow is: hypervisor probe, rootfs prepare (cache-aware download and
//! extraction), guest init written into the scratch rootfs, libkrun //! extraction), guest init written into the scratch rootfs, libkrun
//! context wired to it, `krun_start_enter` blocking on this thread, then //! context wired to it, `krun_start_enter` blocking on this thread, then
//! the control file read back for the exit code. The VMM runs inside //! the control file read back for the exit code. The VMM runs inside
@@ -142,7 +142,7 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
} }
} }
krun::kvm_probe()?; krun::hypervisor_probe()?;
let krun = Krun::load()?; let krun = Krun::load()?;
let ram_mib = parse_memory_mib(&options.memory).map_err(Error::Other)?; let ram_mib = parse_memory_mib(&options.memory).map_err(Error::Other)?;
+42
View File
@@ -0,0 +1,42 @@
# The formula lives in the repo and ships through the
# vhaudiquet/homebrew-ecrou tap; it needs the same tap libkrun's
# krunkit comes from, because the engine is dlopen'd from those kegs
# at runtime.
class Ecrou < Formula
desc "a small microVM runtime based on libkrun"
homepage "https://github.com/vhaudiquet/ecrou"
# Placeholder pin: the release workflow rewrites url and sha256 on publish.
url "https://github.com/vhaudiquet/ecrou.git", tag: "v0.1.0"
license "MIT"
# libkrun's Hypervisor.framework backend is arm64-only upstream.
depends_on arch: :arm64
depends_on "libkrun/homebrew-krun/libkrun"
depends_on "e2fsprogs"
# A libarchive-capable mke2fs populates ext4 images from tarballs in
# one pass; without it the build falls back to a per-file tree walk.
depends_on "libarchive"
def install
# CI signed the binary with the hypervisor entitlement (a hard
# requirement on current macOS, the same one krunkit's formula
# signs for); a rebuild here would strip the signature, so the
# release tarball's binary is poured as-is.
bin.install "ecrou"
end
def caveats
<<~EOS
The binary is ad-hoc signed with the com.apple.security.hypervisor
entitlement. Rebuilding from source (cargo) requires re-signing:
codesign --force --sign - --entitlements packaging/homebrew/ecrou.entitlements target/release/ecrou
`ecrou probe` reports this when the entitlement is missing.
EOS
end
test do
system bin/"ecrou", "probe"
output = shell_output("#{bin}/ecrou run --quiet alpine -- uname -r").strip
assert_match(/\d+\.\d+/, output)
end
end
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.hypervisor</key><true/>
</dict></plist>