chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:13:07 +08:00
commit 1c4adf53ac
336 changed files with 131689 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# cargo-audit configuration.
#
# The advisories below are "unmaintained" notices (not vulnerabilities) for
# transitive dependencies we don't pull directly and can't drop without a major
# bump of an upstream crate. They're listed here — with intent — so a real
# vulnerability is never lost in warning noise. Revisit when upstreams migrate
# (e.g. rustls-pemfile -> rustls-pki-types).
[advisories]
ignore = [
"RUSTSEC-2024-0436", # paste — unmaintained (transitive, macro deps)
"RUSTSEC-2026-0173", # proc-macro-error2 — unmaintained (transitive)
"RUSTSEC-2025-0134", # rustls-pemfile — unmaintained (transitive, TLS)
]
+1
View File
@@ -0,0 +1 @@
use flake
+8
View File
@@ -0,0 +1,8 @@
lib/libkrun.dylib filter=lfs diff=lfs merge=lfs -text
lib/libkrunfw.5.dylib filter=lfs diff=lfs merge=lfs -text
lib/libvirglrenderer.1.dylib filter=lfs diff=lfs merge=lfs -text
lib/libMoltenVK.dylib filter=lfs diff=lfs merge=lfs -text
lib/libepoxy.0.dylib filter=lfs diff=lfs merge=lfs -text
lib/linux-x86_64/*.so* filter=lfs diff=lfs merge=lfs -text
lib/linux-aarch64/*.so* filter=lfs diff=lfs merge=lfs -text
lib/windows-x86_64/*.dll filter=lfs diff=lfs merge=lfs -text
+1
View File
@@ -0,0 +1 @@
github: BinSquare
+111
View File
@@ -0,0 +1,111 @@
name: Build libkrun (linux, glibc 2.35)
# Rebuild the bundled linux libkrun.so on ubuntu-22.04 so its glibc floor is
# 2.35 — the same floor the release binaries get (they also build on 22.04, see
# release.yml). A libkrun built on a newer distro (e.g. Ubuntu 24.04 / glibc
# 2.39) loads fine there but fails on 22.04 with "GLIBC_2.39 not found", which is
# exactly how a hand-built lib slipped a 2.39 floor into v1.2.0.
#
# SKIP_LIBKRUNFW=1: reuse the committed lib/linux-<arch>/libkrunfw.so (it wraps a
# guest-kernel blob, floor GLIBC_2.2 — host-glibc-independent), so we skip the
# multi-minute kernel rebuild and only recompile libkrun itself.
#
# Dispatch-only. Uploads lib/linux-<arch>/ as an artifact; the rebuilt .so is
# then committed into the repo (git-LFS) and shipped in the next release.
on:
workflow_dispatch:
inputs:
rebuild_libkrunfw:
description: "Rebuild libkrunfw from source (full guest-kernel rebuild) instead of reusing the committed .so. Needed when a libkrunfw patch changed the guest kernel."
type: boolean
default: false
jobs:
build:
name: libkrun ${{ matrix.arch }}
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-22.04
- arch: aarch64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive # libkrun links against the libkrunfw source
lfs: true # committed libkrunfw.so reused under SKIP_LIBKRUNFW
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
/opt/hostedtoolcache/CodeQL 2>/dev/null || true
df -h / | tail -1
# By default reuse the committed libkrunfw.so (skips the multi-minute guest
# kernel rebuild). When a libkrunfw patch changed the guest kernel, dispatch
# with rebuild_libkrunfw=true so libkrunfw.so is rebuilt from the submodule
# source and picked up here.
- name: Build libkrun (GPU=1)
run: |
if [ "${{ inputs.rebuild_libkrunfw }}" = "true" ]; then
echo "Rebuilding libkrunfw from source"
GPU=1 ./scripts/build-libkrun-linux.sh
else
SKIP_LIBKRUNFW=1 GPU=1 ./scripts/build-libkrun-linux.sh
fi
- name: Assert glibc floor <= 2.35
run: |
so="lib/linux-${{ matrix.arch }}/libkrun.so"
maxv=$(objdump -T "$so" | grep -oE 'GLIBC_[0-9]+\.[0-9]+' \
| sed 's/GLIBC_//' | sort -V | tail -1)
echo "max GLIBC required by $so: ${maxv:-none}"
if [ -n "$maxv" ] && [ "$(printf '%s\n2.35\n' "$maxv" | sort -V | tail -1)" != "2.35" ]; then
echo "::error::$so requires glibc $maxv (> 2.35) — floor not lowered"
exit 1
fi
echo "OK: glibc floor is <= 2.35"
- uses: actions/upload-artifact@v4
with:
name: libkrun-linux-${{ matrix.arch }}
path: lib/linux-${{ matrix.arch }}/
if-no-files-found: error
# Cross-build the Windows libkrunfw.dll (guest kernel wrapped for a Windows
# host) with mingw-w64 on Linux. The kernel builds with native gcc; only the
# final bundle link uses the mingw toolchain (Makefile OS=Windows path). Only
# runs when refreshing libkrunfw, since the .dll wraps the guest kernel.
windows-libkrunfw:
name: libkrunfw.dll (windows cross)
if: ${{ inputs.rebuild_libkrunfw }}
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
lfs: false
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
/opt/hostedtoolcache/CodeQL 2>/dev/null || true
- name: Install build + mingw toolchain
run: |
sudo apt-get update
sudo apt-get install -y make gcc bc bison flex libelf-dev libssl-dev \
python3-pyelftools curl xz-utils cpio kmod gcc-mingw-w64-x86-64
- name: Build libkrunfw.dll (mingw cross)
run: cd libkrunfw && make OS=Windows -j"$(nproc)"
- uses: actions/upload-artifact@v4
with:
name: libkrunfw-windows-x86_64
path: libkrunfw/libkrunfw.dll
if-no-files-found: error
+337
View File
@@ -0,0 +1,337 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:
env:
CARGO_TERM_COLOR: always
jobs:
# ==========================================================================
# Quick checks (no platform-specific deps needed)
# ==========================================================================
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all -- --check
secrets-guards:
name: Secrets invariants
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check secrets guardrails
run: ./scripts/check-secrets-guards.sh
libkrun-provenance:
name: libkrun freshness
runs-on: ubuntu-latest
steps:
# LFS on: check-krun-exports.sh reads the actual bundled lib binaries.
# (check-libkrun-provenance.sh itself only needs the pinned gitlinks.)
- uses: actions/checkout@v4
with:
lfs: true
- name: Bundled libkrun matches the pinned submodule
run: ./scripts/check-libkrun-provenance.sh
# nm (ELF), llvm-nm (Mach-O dylib), mingw objdump (PE dll exports).
- name: Install symbol-reading tools
run: |
sudo apt-get update
sudo apt-get install -y binutils binutils-mingw-w64-x86-64 llvm
- name: Bundled libkrun exports every symbol smolvm requires
run: ./scripts/check-krun-exports.sh
audit:
name: Cargo Audit
runs-on: ubuntu-latest
# Scans Cargo.lock for RustSec advisories — no engine/libkrun needed. Honors
# .cargo/audit.toml, which ignores documented unmaintained-transitive notices
# so a real vulnerability stands out instead of hiding in warning noise.
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@cargo-audit
- run: cargo audit
# ==========================================================================
# macOS Build & Test (Apple Silicon)
# ==========================================================================
macos:
name: macOS (arm64)
runs-on: macos-14
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Cache cargo
uses: actions/cache@v5
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: macos-arm64-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: macos-arm64-cargo-
- name: Setup Git LFS
run: |
git lfs install
git lfs pull
- name: Verify LFS dylibs
run: |
ls -la lib/
file lib/*.dylib
- name: Build
run: cargo build --release
env:
LIBRARY_PATH: ${{ github.workspace }}/lib
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
env:
LIBRARY_PATH: ${{ github.workspace }}/lib
- name: Run unit tests
run: cargo test --lib
env:
LIBRARY_PATH: ${{ github.workspace }}/lib
DYLD_LIBRARY_PATH: ${{ github.workspace }}/lib
- name: Run agent unit tests
run: cargo test -p smolvm-agent --bins
env:
LIBRARY_PATH: ${{ github.workspace }}/lib
DYLD_LIBRARY_PATH: ${{ github.workspace }}/lib
- name: Sign binary
run: codesign --force --sign - --entitlements smolvm.entitlements target/release/smolvm
- name: Verify binary runs
run: ./target/release/smolvm --version
env:
DYLD_LIBRARY_PATH: ${{ github.workspace }}/lib
# Note: Integration tests (E2E VM tests) require Hypervisor.framework access,
# which GitHub runners don't provide. Run integration tests locally.
# ==========================================================================
# Linux Build & Test
# ==========================================================================
linux:
name: Linux (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
target: x86_64-unknown-linux-gnu
- arch: aarch64
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
targets: ${{ matrix.target }}
- name: Cache cargo
uses: actions/cache@v5
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: linux-${{ matrix.arch }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: linux-${{ matrix.arch }}-cargo-
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
libssl-dev \
pkg-config
# libkrun is not easily available on Ubuntu, so we create a stub library
# for linking. This allows us to compile and run unit tests.
# Full integration tests require macOS or a Linux machine with KVM.
- name: Create libkrun stub for linking
run: |
# Create minimal stub that satisfies the linker
cat > /tmp/libkrun_stub.c << 'EOF'
// Stub implementations - these won't be called in unit tests
int krun_create_ctx() { return -1; }
int krun_free_ctx(int ctx) { return 0; }
int krun_set_vm_config(int ctx, int cpus, int mem) { return -1; }
int krun_set_root(int ctx, const char* path) { return -1; }
int krun_set_workdir(int ctx, const char* path) { return -1; }
int krun_set_exec(int ctx, const char* path, char** argv, char** env) { return -1; }
int krun_start_enter(int ctx) { return -1; }
int krun_set_log_level(int level) { return 0; }
int krun_add_disk2(int ctx, const char* id, const char* path, int flags, int ro) { return -1; }
int krun_set_port_map(int ctx, char** map) { return -1; }
int krun_add_vsock_port(int ctx, int port, const char* path) { return -1; }
int krun_add_vsock_port2(int ctx, int port, const char* path, int flags) { return -1; }
int krun_disable_implicit_vsock(int ctx) { return -1; }
int krun_add_vsock(int ctx, int tsi_features) { return -1; }
int krun_set_passt_fd(int ctx, int fd) { return -1; }
int krun_add_virtiofs(int ctx, const char* tag, const char* path) { return -1; }
int krun_add_virtiofs2(int ctx, const char* tag, const char* path, int flags) { return -1; }
int krun_set_console_output(int ctx, const char* path) { return -1; }
int krun_add_virtio_console_default(int ctx) { return -1; }
EOF
gcc -shared -fPIC -o /tmp/libkrun.so /tmp/libkrun_stub.c
sudo cp /tmp/libkrun.so /usr/local/lib/
sudo ln -sf /usr/local/lib/libkrun.so /usr/local/lib/libkrun.1.dylib
sudo ldconfig
- name: Build
run: cargo build --release --target ${{ matrix.target }}
env:
LIBRARY_PATH: /usr/local/lib
- name: Clippy
run: cargo clippy --all-targets --target ${{ matrix.target }} -- -D warnings
env:
LIBRARY_PATH: /usr/local/lib
- name: Run unit tests
run: cargo test --lib --target ${{ matrix.target }}
env:
LIBRARY_PATH: /usr/local/lib
LD_LIBRARY_PATH: /usr/local/lib
- name: Run agent unit tests
run: cargo test -p smolvm-agent --bins --target ${{ matrix.target }}
env:
LIBRARY_PATH: /usr/local/lib
LD_LIBRARY_PATH: /usr/local/lib
# Note: Full VM integration tests require KVM + real libkrun.
# Run integration tests locally on a machine with KVM access.
# ==========================================================================
# Windows Build & Lint (cross-compiled from Linux, mingw-w64 / GNU target)
# ==========================================================================
windows:
name: Windows (x86_64, cross)
runs-on: ubuntu-latest
env:
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
targets: x86_64-pc-windows-gnu
- name: Cache cargo
uses: actions/cache@v5
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: windows-gnu-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: windows-gnu-cargo-
- name: Install mingw-w64 cross toolchain
run: |
sudo apt-get update
sudo apt-get install -y gcc-mingw-w64-x86-64 clang libclang-dev
# The Windows host dlopens libkrun at runtime (libloading), so unlike the
# macOS/Linux jobs there is no link-time libkrun dependency and no stub is
# needed. This gate catches host-side compile + lint regressions on
# Windows; WHP VM integration tests run out-of-band on real hardware via
# the WHP QA harness (unit tests can additionally be run under Wine with
# CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=wine).
- name: Build
run: cargo build --release --target x86_64-pc-windows-gnu
- name: Clippy
run: cargo clippy --release --target x86_64-pc-windows-gnu --all-targets -- -D warnings
# ==========================================================================
# Build agent (Linux musl for static binary)
# ==========================================================================
agent:
name: Agent (Linux musl)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-musl, x86_64-unknown-linux-musl
- name: Install musl tools
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
- name: Install cross-compilation tools
run: |
# For aarch64 cross-compilation
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Build agent (x86_64)
run: cargo build --release --target x86_64-unknown-linux-musl -p smolvm-agent
working-directory: .
- name: Build agent (aarch64)
run: |
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
cargo build --release --target aarch64-unknown-linux-musl -p smolvm-agent
working-directory: .
- name: Verify static binaries
run: |
echo "x86_64 agent:"
file target/x86_64-unknown-linux-musl/release/smolvm-agent
echo "aarch64 agent:"
file target/aarch64-unknown-linux-musl/release/smolvm-agent
# ==========================================================================
# Summary job for branch protection
# ==========================================================================
ci-success:
name: CI Success
needs: [fmt, macos, linux, windows, agent]
runs-on: ubuntu-latest
if: always()
steps:
- name: Check all jobs passed
run: |
if [[ "${{ needs.fmt.result }}" != "success" ]] || \
[[ "${{ needs.macos.result }}" != "success" ]] || \
[[ "${{ needs.linux.result }}" != "success" ]] || \
[[ "${{ needs.windows.result }}" != "success" ]] || \
[[ "${{ needs.agent.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
echo "All CI jobs passed!"
+101
View File
@@ -0,0 +1,101 @@
name: Publish pacman repo
# Builds Arch packages from the release binaries and publishes them to the
# GitHub Pages `pacman-repo` branch, served at
# https://smol-machines.github.io/smolvm/pacman/$arch — a single stable
# repository endpoint (no per-arch release entries). See docs/install-arch.md.
#
# Triggers:
# - workflow_call: invoked by release.yml right after it creates the GitHub
# Release. This is the real automation path — the `release: published` event
# does NOT fire here on its own, because that release is created with the
# default GITHUB_TOKEN and GitHub does not start workflows from token-created
# releases (same reason release.yml pings the Homebrew tap explicitly).
# - workflow_dispatch: manual re-run / backfill for a given tag.
# - release: kept as a safety net for releases published by a human/PAT.
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to package (e.g. v1.5.0)'
required: true
workflow_call:
inputs:
tag:
description: 'Release tag to package (e.g. v1.5.0)'
required: true
type: string
permissions:
contents: write
jobs:
publish:
runs-on: ubuntu-latest
container:
image: archlinux:base-devel
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Resolve tag
id: tag
run: |
TAG='${{ github.event.release.tag_name || inputs.tag }}'
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "pkgver=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Install tooling
run: pacman -Syu --noconfirm pacman-contrib git
- uses: actions/checkout@v4
- name: Prepare build user
run: |
useradd -m builder
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
cp -r packaging/arch /home/builder/build
chown -R builder /home/builder/build
- name: Build packages (both arches; repackage-only, no compilation)
run: |
cd /home/builder/build
sed -i "s/^pkgver=.*/pkgver=${{ steps.tag.outputs.pkgver }}/; s/^pkgrel=.*/pkgrel=1/" PKGBUILD
sudo -u builder updpkgsums
sudo -u builder makepkg -df --noconfirm
sudo -u builder env CARCH=aarch64 makepkg -df --noconfirm --ignorearch
ls -l *.pkg.tar.zst
- name: Publish to the GitHub Pages pacman repo (branch pacman-repo)
run: |
cd /home/builder/build
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git config --global --add safe.directory '*'
# Pages serves the pacman-repo branch at https://<owner>.github.io/<repo>/
# (Deploy from a branch); the pacman endpoint is pacman/$arch/. Pushing
# to the branch triggers the Pages build.
auth="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git clone --depth 1 --branch pacman-repo "$auth" pages
for arch in x86_64 aarch64; do
dir="pages/pacman/$arch"
mkdir -p "$dir"
# Rolling repo: keep only the current version's package so the branch
# stays small. repo-add then builds a fresh single-entry db.
rm -f "$dir"/smolvm-*.pkg.tar.zst "$dir"/smol-machines.db* "$dir"/smol-machines.files*
cp smolvm-*-"$arch".pkg.tar.zst "$dir/"
(
cd "$dir"
repo-add smol-machines.db.tar.gz smolvm-*-"$arch".pkg.tar.zst
# GitHub Pages does not serve broken symlinks — materialize the
# convenience `.db`/`.files` names as real copies of their targets.
cp --remove-destination "$(readlink -f smol-machines.db)" smol-machines.db
cp --remove-destination "$(readlink -f smol-machines.files)" smol-machines.files
)
done
cd pages
git add -A
git commit -m "pacman: publish ${{ steps.tag.outputs.tag }}" \
|| { echo "no package changes to publish"; exit 0; }
git push origin pacman-repo
+91
View File
@@ -0,0 +1,91 @@
name: Publish crates to crates.io
# Publishes the smolvm *library* crates to crates.io so external consumers can
# `cargo add smolvm-protocol` (etc.) at the same version as the released engine.
#
# The crate versions track the workspace — they're bumped in lockstep with the
# engine — so this publishes whatever version is on the checked-out ref. Runs on
# a version tag (the engine release) so publishing can never lapse behind a
# release again, and on manual dispatch for a one-off catch-up.
#
# REQUIRES the `CARGO_REGISTRY_TOKEN` repo secret: a crates.io API token that
# owns/can-publish the smolvm-* + smolfile crates. Without it the job skips with
# a warning rather than failing a release.
#
# Publishes in dependency order (a dependency must be on crates.io before a
# dependent's `version = "x"` requirement can resolve):
# smolvm-protocol, smolvm-network (leaves)
# → smolvm-pack, smolvm-smolfile (need protocol)
# → smolvm-registry (needs pack)
# smolvm-cuda / smolvm-cuda-guest are intentionally NOT published.
on:
push:
tags: ["v1.*"]
workflow_dispatch:
inputs:
dry_run:
description: "Package + verify only (cargo publish --dry-run); don't upload."
type: boolean
default: false
permissions:
contents: read
# Never let two publish runs race on the same crate versions.
concurrency:
group: publish-crates
cancel-in-progress: false
jobs:
publish:
name: publish library crates
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Publish library crates in dependency order
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
if [ "${DRY_RUN:-false}" != "true" ] && [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then
echo "::warning::CARGO_REGISTRY_TOKEN is not set — skipping crates.io publish. \
Add the secret to enable it."
exit 0
fi
# Dependency order: a dep must be published (and indexed) before a
# dependent, so its `version = "x"` requirement resolves on crates.io.
crates=(smolvm-protocol smolvm-network smolvm-pack smolvm-smolfile smolvm-registry)
for c in "${crates[@]}"; do
name="$(grep -m1 '^name' "crates/$c/Cargo.toml" | sed -E 's/.*"([^"]+)".*/\1/')"
ver="$(grep -m1 '^version' "crates/$c/Cargo.toml" | sed -E 's/.*"([^"]+)".*/\1/')"
echo "::group::$name $ver"
if [ "${DRY_RUN:-false}" = "true" ]; then
cargo publish -p "$name" --dry-run
echo "::endgroup::"
continue
fi
# Idempotent: skip a version that's already on crates.io, so a re-run
# after a partial failure resumes instead of erroring on duplicates.
if curl -sf -H "User-Agent: smolvm-publish (ops@smolmachines.com)" \
"https://crates.io/api/v1/crates/$name/$ver" >/dev/null 2>&1; then
echo "$name $ver already on crates.io — skipping"
echo "::endgroup::"
continue
fi
# `cargo publish` blocks until the new version is in the registry
# index, so the next (dependent) crate can resolve it.
cargo publish -p "$name"
echo "published $name $ver"
echo "::endgroup::"
done
+460
View File
@@ -0,0 +1,460 @@
name: Release
on:
push:
tags: ['v*']
env:
CARGO_TERM_COLOR: always
jobs:
# ==========================================================================
# Run CI checks first
# ==========================================================================
ci:
uses: ./.github/workflows/ci.yml
# ==========================================================================
# Build agent binaries (both architectures)
# ==========================================================================
build-agent:
name: Build agent (${{ matrix.arch }})
needs: ci
runs-on: ubuntu-latest
strategy:
matrix:
include:
- arch: x86_64
target: x86_64-unknown-linux-musl
linker_pkg: ""
linker_env: ""
- arch: aarch64
target: aarch64-unknown-linux-musl
linker_pkg: gcc-aarch64-linux-gnu
linker_env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache cargo
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: agent-${{ matrix.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: agent-${{ matrix.arch }}-cargo-
- name: Install cross-compilation tools
if: matrix.linker_pkg != ''
run: |
sudo apt-get update
sudo apt-get install -y musl-tools ${{ matrix.linker_pkg }}
- name: Install musl-tools
if: matrix.linker_pkg == ''
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
- name: Build agent
run: |
${{ matrix.linker_env }} \
cargo build --profile release-small -p smolvm-agent --target ${{ matrix.target }}
- name: Verify static binary
run: file target/${{ matrix.target }}/release-small/smolvm-agent
- uses: actions/upload-artifact@v7
with:
name: agent-${{ matrix.arch }}
path: target/${{ matrix.target }}/release-small/smolvm-agent
# ==========================================================================
# Build agent rootfs (both architectures)
# ==========================================================================
build-rootfs:
name: Build rootfs (${{ matrix.arch }})
needs: build-agent
runs-on: ubuntu-latest
strategy:
matrix:
include:
- arch: aarch64
- arch: x86_64
steps:
- uses: actions/checkout@v6
- name: Download agent binary
uses: actions/download-artifact@v8
with:
name: agent-${{ matrix.arch }}
path: /tmp/agent/
- name: Build rootfs
run: |
chmod +x /tmp/agent/smolvm-agent
AGENT_BINARY=/tmp/agent/smolvm-agent \
./scripts/build-agent-rootfs.sh --arch ${{ matrix.arch }} --no-build-agent
- name: Package rootfs as tarball
run: |
# Tar preserves ownership/permissions without needing read access
# to restricted dirs like /var/run/chrony (owned by chrony:chrony 700)
sudo tar -cf target/agent-rootfs.tar -C target/agent-rootfs .
- uses: actions/upload-artifact@v7
with:
name: rootfs-${{ matrix.arch }}
path: target/agent-rootfs.tar
# ==========================================================================
# Build distribution tarballs (per platform)
# ==========================================================================
build-dist:
name: Build dist (${{ matrix.platform }})
needs: [build-agent, build-rootfs]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: darwin-arm64
runner: macos-14
rootfs_arch: aarch64
agent_arch: aarch64
lib_dir: lib
# Linux dist binaries build on 22.04 (glibc 2.35), NOT ubuntu-latest
# (24.04 = glibc 2.39). Symbol versioning is backward-compatible only,
# so the build host's glibc is the MINIMUM a user needs at runtime —
# a 2.39-built binary dies on 22.04 with "GLIBC_2.39 not found". 2.35
# covers Ubuntu 22.04+ / Debian 12+ / RHEL 9+.
- platform: linux-x86_64
runner: ubuntu-22.04
rootfs_arch: x86_64
agent_arch: x86_64
lib_dir: lib/linux-x86_64
- platform: linux-arm64
runner: ubuntu-22.04-arm
rootfs_arch: aarch64
agent_arch: aarch64
lib_dir: lib/linux-aarch64
steps:
- uses: actions/checkout@v6
with:
lfs: true
submodules: ${{ startsWith(matrix.platform, 'linux-') && 'recursive' || 'false' }}
- name: Pull LFS files
run: git lfs pull
- uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v5
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
# Include lib dir hash so libkrun updates bust the cache
key: dist-${{ matrix.platform }}-cargo-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles(format('{0}/*', matrix.lib_dir)) }}
restore-keys: dist-${{ matrix.platform }}-cargo-
# Force relink if cached binary was linked against a different libkrun
- name: Clean stale build artifacts
run: rm -f target/release/smolvm target/release/deps/smolvm-*
- name: Install build dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
# patchelf: build-dist.sh strips libkrun's hard libvirglrenderer NEEDED
# so non-GPU Linux hosts can dlopen it (GPU stays optional at runtime).
# Without patchelf the strip is silently skipped and `smolvm run` fails
# with "libvirglrenderer.so.1: cannot open shared object file".
sudo apt-get install -y build-essential libssl-dev pkg-config e2fsprogs patchelf
- name: Install build dependencies (macOS)
if: runner.os == 'macOS'
run: brew install e2fsprogs
- name: Verify LFS libraries
run: |
echo "Library directory: ${{ matrix.lib_dir }}"
ls -la ${{ matrix.lib_dir }}/
file ${{ matrix.lib_dir }}/*
- name: Download rootfs
uses: actions/download-artifact@v8
with:
name: rootfs-${{ matrix.rootfs_arch }}
path: /tmp/rootfs-dl/
- name: Extract rootfs tarball
run: |
mkdir -p target/agent-rootfs
sudo tar -xf /tmp/rootfs-dl/agent-rootfs.tar -C target/agent-rootfs
- name: Download agent binary
uses: actions/download-artifact@v8
with:
name: agent-${{ matrix.agent_arch }}
path: /tmp/agent/
- name: Place agent binary for build-dist.sh
run: |
mkdir -p target/release-small
cp /tmp/agent/smolvm-agent target/release-small/smolvm-agent
chmod +x target/release-small/smolvm-agent
# Import Developer ID certificate if available (macOS only).
# Set secrets APPLE_CERTIFICATE_P12 (base64-encoded .p12) and
# APPLE_CERTIFICATE_PASSWORD in your GitHub repo settings.
- name: Import signing certificate (macOS)
if: runner.os == 'macOS' && env.APPLE_CERTIFICATE_P12 != ''
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "$APPLE_CERTIFICATE_P12" | base64 --decode > /tmp/certificate.p12
KEYCHAIN_PASSWORD="$(openssl rand -hex 16)"
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security import /tmp/certificate.p12 -k build.keychain \
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
rm /tmp/certificate.p12
# Check out the unified `smol` CLI (a separate repo) into ./smol so
# build-dist.sh builds and bundles it alongside the engine — one tarball
# serves both `smol` and `smolvm`. Requires repo secret SMOL_REPO_TOKEN
# with read access to smol-machines/smol. If unset, the step is skipped and
# an engine-only tarball is produced. Ref is left at the smol default
# branch (its versioning is independent of the engine tag); pin a ref here
# if you want reproducible smol-CLI releases.
- name: Check out smol CLI source
if: env.SMOL_REPO_TOKEN != ''
uses: actions/checkout@v6
with:
repository: smol-machines/smol
path: smol
token: ${{ secrets.SMOL_REPO_TOKEN }}
env:
SMOL_REPO_TOKEN: ${{ secrets.SMOL_REPO_TOKEN }}
# No separate guest-init build: libkrun 2.0.0 embeds the guest init
# (krun-init) directly in libkrun.so (INIT_BLOB is on by default), so the
# old per-arch `cc init/init.c` step is gone — init.c no longer exists in
# the 2.0.0 submodule, and a VM boots from the embedded init with no
# external init.krun (verified). build-dist.sh no longer bundles init.krun.
- name: Build distribution tarball
run: ./scripts/build-dist.sh --skip-agent-build
env:
LIB_DIR: ${{ matrix.lib_dir }}
CODESIGN_IDENTITY: ${{ secrets.APPLE_CERTIFICATE_P12 != '' && secrets.CODESIGN_IDENTITY || '-' }}
# Notarize the macOS binary if Developer ID signing was used.
# Requires secrets: APPLE_ID, APPLE_TEAM_ID, APPLE_APP_PASSWORD.
- name: Notarize (macOS)
if: runner.os == 'macOS' && env.APPLE_CERTIFICATE_P12 != ''
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
run: |
TARBALL=$(ls dist/smolvm-*.tar.gz)
xcrun notarytool submit "$TARBALL" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" \
--wait
- name: Verify bundled libraries match source
run: |
VERSION="$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2)"
DIST_LIB="dist/smolvm-${VERSION}-${{ matrix.platform }}/lib"
echo "Source libraries:"
ls -la ${{ matrix.lib_dir }}/
echo "Bundled libraries:"
ls -la "$DIST_LIB"/
# Verify sizes match for all bundled libraries (catches stale LFS or copy issues).
# Covers both required libs (libkrun, libkrunfw) and optional GPU libs.
for lib in "$DIST_LIB"/lib*; do
[[ -L "$lib" ]] && continue # skip symlinks
name=$(basename "$lib")
src="${{ matrix.lib_dir }}/$name"
if [[ -f "$src" ]]; then
src_size=$(wc -c < "$src")
dst_size=$(wc -c < "$lib")
if [[ "$src_size" != "$dst_size" ]]; then
echo "ERROR: $name size mismatch: source=$src_size bundled=$dst_size"
exit 1
fi
echo "OK: $name ($dst_size bytes)"
fi
done
- name: List distribution contents
run: |
VERSION="$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2)"
ls -la "dist/smolvm-${VERSION}-"*/
- uses: actions/upload-artifact@v7
with:
name: dist-${{ matrix.platform }}
path: dist/smolvm-*.tar.gz
# ==========================================================================
# Build Windows distribution (cross-compiled; zipped)
# ==========================================================================
build-dist-windows:
name: Build dist (windows-x86_64)
needs: [build-agent, build-rootfs]
runs-on: ubuntu-latest
env:
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
steps:
- uses: actions/checkout@v6
with:
lfs: true
- name: Pull LFS files
run: git lfs pull
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-gnu
- name: Install build dependencies
run: |
sudo apt-get update
# mingw: cross-link smolvm.exe; clang/libclang: bindgen in build deps
# (matches the CI windows job); e2fsprogs: pre-format disk templates
# (Windows has no host mkfs.ext4); zip: package the dist.
sudo apt-get install -y gcc-mingw-w64-x86-64 clang libclang-dev e2fsprogs zip
- name: Verify Windows libraries
run: |
ls -la lib/windows-x86_64/
file lib/windows-x86_64/*.dll
- name: Download rootfs (x86_64)
uses: actions/download-artifact@v8
with:
name: rootfs-x86_64
path: /tmp/rootfs-dl/
- name: Extract rootfs
run: |
mkdir -p target/agent-rootfs
sudo tar -xf /tmp/rootfs-dl/agent-rootfs.tar -C target/agent-rootfs
- name: Download agent binary (x86_64)
uses: actions/download-artifact@v8
with:
name: agent-x86_64
path: /tmp/agent/
# The rootfs artifact ships agent-less; inject the freshly built agent and
# repoint /sbin/init at it (same as the Unix build-dist.sh path).
- name: Inject agent into rootfs
run: |
sudo cp /tmp/agent/smolvm-agent target/agent-rootfs/usr/local/bin/smolvm-agent
sudo chmod +x target/agent-rootfs/usr/local/bin/smolvm-agent
sudo ln -sf /usr/local/bin/smolvm-agent target/agent-rootfs/sbin/init
- name: Build Windows distribution
run: ./scripts/build-dist-windows.sh
env:
LIB_DIR: lib/windows-x86_64
- uses: actions/upload-artifact@v7
with:
name: dist-windows-x86_64
path: dist/smolvm-*-windows-x86_64.zip
# ==========================================================================
# Create GitHub Release
# ==========================================================================
release:
name: Create Release
needs: [build-dist, build-dist-windows]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Download all dist artifacts
uses: actions/download-artifact@v8
with:
pattern: dist-*
path: dist/
merge-multiple: true
- name: List release artifacts
run: ls -la dist/
- name: Generate checksums
run: |
cd dist
sha256sum *.tar.gz *.zip > checksums.sha256
cat checksums.sha256
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ github.ref_name }}" \
--title "smolvm ${{ github.ref_name }}" \
--generate-notes \
dist/*.tar.gz \
dist/*.zip \
dist/checksums.sha256
# Ping the Homebrew tap to bump its formula to this release immediately,
# instead of waiting for its scheduled poll. Best-effort: if the token
# secret isn't configured, this no-ops (the tap's cron picks the release up
# within a few hours anyway) and never fails the release. The token is a
# fine-grained PAT with Contents:write on smol-machines/homebrew-tap.
- name: Notify Homebrew tap of the new release
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_DISPATCH_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
if [ -z "${GH_TOKEN:-}" ]; then
echo "HOMEBREW_TAP_DISPATCH_TOKEN not set — skipping instant bump (the tap polls on a schedule)."
exit 0
fi
version="${TAG#v}"
gh api --method POST repos/smol-machines/homebrew-tap/dispatches \
-f event_type=smolvm-release \
-f "client_payload[version]=$version"
echo "Dispatched smolvm-release ($version) to the Homebrew tap."
# Build + publish the Arch/pacman packages for this release. Invoked directly
# (not via the `release: published` event) because that event never fires for a
# release created with the default GITHUB_TOKEN — the same GitHub restriction
# the Homebrew step above works around. Runs after the release exists so the
# PKGBUILD can download its release binaries.
publish-pacman:
name: Publish pacman repo
needs: release
permissions:
contents: write
uses: ./.github/workflows/pacman-repo.yml
with:
tag: ${{ github.ref_name }}
+48
View File
@@ -0,0 +1,48 @@
# Rust
/target/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# macOS
.DS_Store
# Build artifacts
*.so
*.a
# ...except the vendored runtime libs (else base libkrun.so/libkrunfw.so get
# dropped, leaving dangling symlinks that break `hashFiles` in release CI).
!lib/linux-x86_64/*.so
!lib/linux-aarch64/*.so
# Distribution build output
/dist/smolvm-*/
/dist/*.tar.gz
# Nix/devshell artifacts
/.direnv/
/result
/result-*
# Bundled libraries are in the /lib (large binaries - download or build locally)
# These are bundled into release tarballs by build-dist.sh
# Agent rootfs is generated in target/ by build-agent-rootfs.sh
# Local config
.env
.env.local
# Test artifacts
/tmp/
*.log
# smol machines artifacts
*.smolmachine
# Registry infrastructure (separate git repo)
/smolmachines-registry/
+9
View File
@@ -0,0 +1,9 @@
[submodule "libkrun"]
path = libkrun
url = git@github.com:smol-machines/libkrun.git
[submodule "libkrunfw"]
path = libkrunfw
url = git@github.com:smol-machines/libkrunfw.git
[submodule "smolvm-sdk"]
path = smolvm-sdk
url = git@github.com:smol-machines/smolvm-sdk.git
+610
View File
@@ -0,0 +1,610 @@
# smolvm — Agent Reference
A tool to build and run portable, self-contained virtual machines locally. <200ms boot time. No daemon, no Docker.
## Platform Support
| Host | Guest | Hypervisor | Requirements |
|------|-------|------------|--------------|
| macOS Apple Silicon | arm64 Linux | Hypervisor.framework | macOS 11+ |
| Linux x86_64 / aarch64 | matching Linux | KVM | `/dev/kvm` |
| Windows x86_64 | x86_64 Linux | Windows Hypervisor Platform (WHP) | WHP feature enabled |
Windows caveats (run, persistent machines, volumes, port-forwarding, pack create/run, and interactive TTY all work): networking is TSI-only (TCP/UDP + inbound `-p`, no virtio-net); no GPU acceleration; no fork/snapshot. `pack create` needs `storage-template.ext4` / `overlay-template.ext4` beside `smolvm.exe` (Windows has no host `mkfs.ext4`). Set `SMOLVM_LIB_DIR` (folder holding `krun.dll` + `libkrunfw.dll`) and `SMOLVM_AGENT_ROOTFS` when running from a non-standard layout.
## Quick Reference
```bash
# Ephemeral (cleaned up after exit)
smolvm machine run --net --image alpine -- echo hello
smolvm machine run --net -it --image alpine -- /bin/sh # interactive shell
smolvm machine run --net --image python:3.12-alpine -- python3 script.py
# Persistent (survives across exec sessions and stop/start)
smolvm machine create --net --name myvm
smolvm machine start --name myvm
smolvm machine exec --name myvm -- apk add python3 # installs persist
smolvm machine exec --name myvm -- which python3 # still there
smolvm machine shell --name myvm # interactive shell (auto-starts if stopped)
smolvm machine stop --name myvm
smolvm machine delete --name myvm
# Image-based persistent (filesystem changes persist across exec sessions)
smolvm machine create --net --image ubuntu --name myvm
smolvm machine start --name myvm
smolvm machine exec --name myvm -- apt-get update
smolvm machine exec --name myvm -- apt-get install -y python3
smolvm machine exec --name myvm -- which python3 # still there after exit+re-exec
# SSH agent forwarding (git/ssh without exposing keys) — see "SSH Agent Forwarding" below
smolvm machine run --ssh-agent --net --image alpine -- sh -c "apk add -q openssh-client && ssh-add -l"
smolvm machine create --name myvm --image alpine --ssh-agent --net # persistent: then start + exec git/ssh
# Inject secrets into workload env (referenced from host env var / file)
smolvm machine run --secret-env OPENAI_API_KEY=OPENAI_API_KEY -- ./app
smolvm machine run -s Smolfile -- ./app # Smolfile [secrets] resolves at launch
# Pack into portable executable
smolvm pack create --image python:3.12-alpine -o ./my-python
./my-python run -- python3 -c "print('hello')"
# Create machine from packed artifact (fast start, no pull)
smolvm machine create --name my-vm --from ./my-python.smolmachine
smolvm machine start --name my-vm
smolvm machine exec --name my-vm -- pip install requests
# Use local container images (CI, air-gapped, fast iteration)
docker save myapp:latest -o myapp.tar
smolvm machine run --image ./myapp.tar -- ./app # from a docker/podman save archive
docker save myapp:latest | smolvm machine run --image - -- ./app # from stdin
smolvm machine run --image ./rootfs/ -- ./app # from an unpacked rootfs dir
smolvm machine create --name myvm --image ./myapp.tar # persistent, from a local archive
```
## When to Use What
| Goal | Command |
|------|---------|
| Run a one-off command in isolation | `smolvm machine run --net --image IMAGE -- CMD` |
| Interactive shell (ephemeral) | `smolvm machine run --net -it --image IMAGE -- /bin/sh` |
| Interactive shell (persistent) | `smolvm machine shell --name NAME` |
| Persistent dev environment | `machine create``machine start``machine exec` |
| Ship software as a binary | `smolvm pack create --image IMAGE -o OUTPUT` |
| Fast persistent machine from packed artifact | `machine create --name NAME --from FILE.smolmachine` |
| Use local container images (CI / air-gapped / fast iteration) | `--image ./archive.tar`, `--image -` (stdin), or `--image ./rootfs/` |
| Use git/ssh with private keys safely | Add `--ssh-agent` to run or create |
| Inject API keys / tokens without putting them on the command line | `--secret-env`/`--secret-file` flags or Smolfile `[secrets]` |
| Minimal VM without image | `smolvm machine run -s Smolfile` (bare VM) |
| Change mounts/ports/resources on existing VM | `machine update --name NAME -v ./src:/app -p 8080:8080` |
| Declarative VM config | Create a Smolfile, use `--smolfile`/`-s` flag |
### Persistence Model
- **`machine run`** — ephemeral. All changes are discarded when the command exits.
- **`machine exec`** — persistent. Filesystem changes (package installs, config edits) persist across exec sessions for the same machine, whether bare or image-based. Changes are stored in an overlay on the machine's storage disk.
- **`machine stop` + `start`** — changes persist across restarts. The persistent overlay is remounted preserving previous changes.
- **`pack run`** — ephemeral. Each run starts fresh from the packed image.
- **`pack start` + `exec`** — daemon mode. `/workspace` persists across exec sessions and stop/start. Container overlay resets per exec (package installs don't persist — use `/workspace` for durable data).
- **`machine create --from .smolmachine`** — creates a persistent named machine from a packed artifact. Boots from pre-extracted layers (~250ms, no image pull). Full `machine exec` persistence — package installs, file writes all survive across exec and stop/start.
## CLI Structure
All commands use named flags (no positional args except `machine create --name NAME` and `machine delete --name NAME`).
```
smolvm machine run --image IMAGE [-- COMMAND] # ephemeral
smolvm machine exec --name NAME [-- COMMAND] # run in existing VM
smolvm machine shell [--name NAME] # interactive shell (auto-starts)
smolvm machine create --name NAME [OPTIONS] # create persistent
smolvm machine create --name NAME --from FILE.smolmachine # from packed artifact
smolvm machine start [--name NAME] # start (default: "default")
smolvm machine stop [--name NAME] # stop
smolvm machine delete --name NAME [-f] # delete
smolvm machine status [--name NAME] # check state
smolvm machine ls [--json] # list all
smolvm machine update --name NAME [OPTIONS] # modify stopped machine settings
smolvm machine cp SRC DST # copy files (host↔VM)
smolvm machine exec --stream --name NAME -- CMD # streaming output
smolvm machine monitor [--name NAME] # foreground health + restart
smolvm pack create --image IMAGE -o PATH # package
smolvm pack create --from-vm NAME -o PATH # pack from VM snapshot
smolvm pack run [--sidecar PATH] [-- CMD] # run .smolmachine
smolvm serve start [--listen ADDR:PORT|PATH] # HTTP API
smolvm config registries edit # registry auth
# Secrets are references to host env vars / files, resolved at launch — no
# built-in store. Attach them on the command line or in a Smolfile [secrets].
smolvm machine run --secret-env GUEST_VAR=HOST_VAR # from host env var
smolvm machine run --secret-file GUEST_VAR=/abs/path # from host file
smolvm machine create --name NAME --secret-env GUEST_VAR=HOST_VAR # persists the ref
smolvm machine exec --name NAME --secret-env GUEST_VAR=HOST_VAR -- cmd
```
## Artifact References
Artifact references follow OCI conventions and support both tags and digests:
```
python-dev # bare name (default registry + latest)
python-dev:v1.0 # name + tag
binsquare/custom:v1 # namespace + name + tag
smolmachines.com/python-dev:latest # registry + name + tag
smolmachines.com/binsquare/custom:v1 # registry + namespace + name + tag
python-dev@sha256:abcdef0123... # digest reference (immutable)
```
Default registry: `registry.smolmachines.com`. Digest references require `sha256:` followed by exactly 64 hex characters.
### Local container images
`--image` also accepts a local source — useful for CI, air-gapped hosts, and fast
local iteration. smolvm stays a microVM runtime and delegates all image work
(flatten, whiteouts, config) to container tooling (`crane`/`docker`/`podman`); the
archive is flattened with `crane export`.
```
./image.tar ./image.tar.gz ./image.tgz # a `docker save` / `podman save` archive (gzip ok)
- # the same archive streamed on stdin
./rootfs/ # an already-unpacked root filesystem directory
```
A source is treated as local when it starts with `/`, `./`, `../`, is `-`, or ends in
`.tar`/`.tar.gz`/`.tgz`; everything else is a registry reference (so bare `alpine`
still pulls). Archives are cached content-addressed by hash and re-resolved on
`machine start`. `--image -` cannot be combined with `-i`/`-t` (both read stdin).
smolvm boots images, it does not build them: a Dockerfile passed to `--image` is
rejected with a hint to build first (`docker build … && docker save … | … --image -`).
## Key Flags
| Flag | Short | Used on | Description |
|------|-------|---------|-------------|
| `--image` | `-I` | run, create, pack create | OCI image, or a local source: a `docker save` archive (`./img.tar`, or `-` for stdin) or unpacked rootfs dir (`./rootfs/`) |
| `--name` | `-n` | run, start, stop, status, exec, update | Machine name (default: "default") |
| `--net` | | run, create | Enable outbound networking (off by default) |
| `--gpu` | | run, create | Enable GPU acceleration (Vulkan via virtio-gpu) |
| `--gpu-vram` | | run, create | GPU shared-memory region size in MiB (default: 4096). Ignored without `--gpu`. |
| `--volume` | `-v` | run, create, update | Mount host dir: `HOST:GUEST[:ro]` |
| `--port` | `-p` | run, create, update | Port mapping: `HOST:GUEST` |
| `--smolfile` | `-s` | run, create, pack create | Load config from Smolfile |
| `--interactive` | `-i` | run, exec | Keep stdin open |
| `--tty` | `-t` | run, exec | Allocate pseudo-TTY |
| `--allow-cidr` | | run, create | CIDR egress filter (implies --net) |
| `--allow-host` | | run, create | Hostname egress filter, resolved at VM start (implies --net) |
| `--ssh-agent` | | run, create | Forward host SSH agent (git/ssh without exposing keys) |
## Smolfile Reference
A Smolfile is a TOML file declaring a VM workload. Use with `--smolfile`/`-s`.
```toml
# Top-level: workload definition
image = "python:3.12-alpine" # OCI image (omit for bare Alpine)
entrypoint = ["/app/run"] # overrides image ENTRYPOINT
cmd = ["serve"] # overrides image CMD
env = ["PORT=8080", "DEBUG=1"] # environment variables
workdir = "/app" # working directory
# Resources
cpus = 2 # vCPUs (default: 4)
memory = 1024 # MiB (default: 8192, elastic via balloon)
net = true # outbound networking (default: false)
gpu = true # GPU acceleration (default: false)
gpu_vram = 4096 # GPU VRAM MiB (default: 4096, ignored unless gpu=true)
storage = 40 # storage disk GiB (default: 20)
overlay = 4 # overlay disk GiB (default: 2)
# Network policy — egress filtering by hostname and/or CIDR
[network]
allow_hosts = ["api.stripe.com"] # resolved at VM start (implies net)
allow_cidrs = ["10.0.0.0/8"] # IP/CIDR ranges (implies net)
# Dev profile (used by `machine run` and `machine create`)
[dev]
volumes = ["./src:/app"] # host bind mounts
ports = ["8080:8080"] # port forwarding
init = ["pip install -r requirements.txt"] # run on every VM start
env = ["APP_MODE=dev"] # dev-only env (extends top-level)
workdir = "/app" # dev-only workdir
# Artifact profile (used by `pack create`)
[artifact]
cpus = 4 # override resources for distribution
memory = 2048
entrypoint = ["/app/run"] # override entrypoint for packed binary
oci_platform = "linux/amd64" # target OCI platform
# Health check (used by `machine monitor`)
[health]
exec = ["curl", "-f", "http://127.0.0.1:8080/health"]
interval = "10s"
timeout = "2s"
retries = 3
startup_grace = "20s"
# Credential forwarding
[auth]
ssh_agent = true # forward host SSH agent into the VM
# Secrets — references to host sources, resolved at workload launch
[secrets]
DATABASE_URL = { from_env = "PROD_DB_URL" } # host env var (at launch)
GCP_CREDS = { from_file = "/abs/creds.json" } # host file (at launch)
```
### Merge Precedence
CLI flags override Smolfile values:
```
image: --image flag > Smolfile image > None (bare Alpine)
entrypoint: Smolfile entrypoint > image metadata
cmd: trailing args (after --) > Smolfile cmd > image metadata
env: top-level env + [dev].env + CLI -e (all merged)
volumes: [dev].volumes + CLI -v (all merged)
ports: [dev].ports + CLI -p (all merged)
init: [dev].init + CLI --init (all merged)
cpus/mem: CLI flag > Smolfile > defaults (4 CPU, 8192 MiB)
```
## Networking
- **Off by default** — VMs have no outbound access unless `--net` is specified
- `--net` enables full outbound (TCP/UDP, DNS)
- `--allow-host api.stripe.com` enables egress only to resolved IPs of that hostname (implies `--net`). Also enables DNS filtering — only allowed hostnames can be resolved.
- `--allow-cidr 10.0.0.0/8` enables egress only to specified IP ranges (implies `--net`)
- `--allow-host` and `--allow-cidr` can be combined and used multiple times
- `--outbound-localhost-only` restricts to 127.0.0.0/8 and ::1 (implies `--net`)
- `-p HOST:GUEST` forwards a host port to the VM (TCP)
- Smolfile: use `[network] allow_hosts` and `[network] allow_cidrs`
### Proxy Support
Pass proxy settings into VMs with `-e` when behind a corporate proxy or VPN:
```bash
smolvm machine run --net \
-e https_proxy=http://proxy.corp:3128 \
-e http_proxy=http://proxy.corp:3128 \
-e no_proxy=localhost,127.0.0.1 \
--image alpine -- wget -q -O /dev/null https://example.com
```
Or declare them in a Smolfile:
```toml
net = true
env = [
"https_proxy=http://proxy.corp:3128",
"http_proxy=http://proxy.corp:3128",
"no_proxy=localhost,127.0.0.1"
]
```
Proxy vars are NOT forwarded automatically — each VM gets exactly the env you specify. The VM uses the host's DNS server (from `/etc/resolv.conf`) for name resolution.
## SSH Agent Forwarding
Forward the host's SSH agent into the VM so git, ssh, and scp work with your keys — without the private keys ever entering the VM.
```bash
# Quick check the agent is forwarded (alpine needs openssh-client for ssh-add):
smolvm machine run --ssh-agent --net --image alpine -- sh -c "apk add -q openssh-client && ssh-add -l"
# Persistent VM — create, start, install tooling, then use the agent:
smolvm machine create --name myvm --image alpine --ssh-agent --net
smolvm machine start --name myvm
smolvm machine exec --name myvm -- apk add -q git openssh-client
# Smolfile
# [auth]
# ssh_agent = true
```
Inside the VM, `SSH_AUTH_SOCK` is set automatically. Any tool that uses the SSH agent protocol (git, ssh, scp) works transparently:
```bash
smolvm machine exec --name myvm -- git clone git@github.com:org/private-repo.git
smolvm machine exec --name myvm -- ssh deploy@server "systemctl restart app"
```
The host SSH agent signs challenges but never sends private keys across the boundary. Even with root inside the VM, keys cannot be extracted — this is enforced by the SSH agent protocol and the hypervisor isolation.
Requires `SSH_AUTH_SOCK` to be set on the host. If missing, smolvm exits with an error and remediation instructions.
## GPU Acceleration
Enable the host GPU inside a VM with `--gpu`. Guest Vulkan talks to the host GPU via virtio-gpu/Venus; ANGLE uses it as the WebGL/OpenGL ES backend.
Not available on Windows (WHP) — `--gpu` has no effect there.
**Host setup:**
- macOS — bundled, no extra installs needed.
- Linux — install virglrenderer from the system package manager before use:
- Alpine: `apk add virglrenderer mesa-vulkan-intel` (or `mesa-vulkan-ati` for AMD)
- Debian/Ubuntu: `apt install virglrenderer0 mesa-vulkan-drivers`
```bash
# One-shot GPU workload
smolvm machine run --gpu --image alpine -- sh -c '
apk add --no-cache mesa-vulkan-virtio vulkan-tools
VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/virtio_icd.x86_64.json \
vulkaninfo --summary 2>/dev/null | grep deviceName
'
# → deviceName = Virtio-GPU Venus (Intel(R) UHD Graphics ...)
# Persistent GPU machine
smolvm machine create --name browser --gpu --gpu-vram 2048
smolvm machine start --name browser
smolvm machine exec --name browser -- \
chromium --headless=new --no-sandbox --use-gl=angle --use-angle=vulkan \
--screenshot=/tmp/out.png --window-size=1280,800 https://example.com
```
The guest must set `VK_ICD_FILENAMES` so the Vulkan loader finds the virtio ICD. Put it in `env` in a Smolfile to avoid repeating it on every exec:
```toml
gpu = true
gpu_vram = 2048
env = ["VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/virtio_icd.x86_64.json"]
```
For a complete working example see [`examples/headless-browser/browser.smolfile`](examples/headless-browser/browser.smolfile).
## CUDA (experimental)
`--cuda` remotes guest CUDA **Driver-API** calls to the host NVIDIA GPU over
vsock — separate from `--gpu` (Vulkan graphics). The host needs a working
NVIDIA driver (`libcuda.so.1` + loaded kernel module); no CUDA toolkit is
required on host or guest. Enable with `--cuda` on `machine run`/`create`, or
`cuda = true` in a Smolfile.
Guest programs reach the GPU through the drop-in driver library built from
[`crates/smolvm-cuda-shim`](crates/smolvm-cuda-shim) (a `cdylib` with soname
`libcuda.so.1`). Mount or install it in the guest and unmodified Driver-API
programs work with no code changes:
```bash
cargo build --release -p smolvm-cuda-shim # → target/release/libcuda.so
mkdir -p /tmp/shim && cp target/release/libcuda.so /tmp/shim/libcuda.so.1
smolvm machine run --net --cuda -v /tmp/shim:/opt/shim:ro --image debian:bookworm-slim -- \
sh -c 'gcc myapp.c -o myapp -L/opt/shim -l:libcuda.so.1 && LD_LIBRARY_PATH=/opt/shim ./myapp'
```
Covered surface: init/device queries (name, attributes, uuid, total/free mem),
contexts (incl. the primary-context flow the CUDA runtime uses), module
load/unload (PTX, cubin, fatbin), mem alloc/free/HtoD/DtoH/DtoD/memset,
kernel launch (param sizes come from the host via `cuFuncGetParamInfo`,
CUDA 12.4+ drivers), streams, events, `cuGetProcAddress`. All work executes
synchronously host-side; `*Async` calls complete before returning (permitted
by the CUDA contract).
**This covers programs written against the Driver API (the `cu*` C API), not
the Runtime API.** A program built with `nvcc` (or PyTorch, RAPIDS, etc.) links
NVIDIA's `libcudart`, which bootstraps by requesting a *private* internal
driver interface via `cuGetExportTable` (a versioned UUID whose function ABIs
are undocumented). A pure `libcuda` shim cannot provide it, so `libcudart`
aborts during context init. Hosting Runtime-API workloads therefore requires
remoting at the `libcudart` level instead — a separate, larger effort (see
`docs/cuda-support-plan.md`, Phase 4). Set `SMOLVM_CUDA_SHIM_TRACE=1` to log
which driver entry points a program resolves through the shim.
Without an NVIDIA driver the host serves a CPU-emulation backend (test-only:
it knows the `vecadd` test kernel), so the transport stays testable anywhere.
## Secrets
smolvm stores no secret material. A secret is a *reference* to a value that
already lives on the host — a host environment variable or a host file — and is
resolved into the workload's process environment at launch time. Bring your own
secrets manager (Vault, 1Password, AWS, sops, your shell): render the value into
an env var or file, then point a ref at it. Only the reference is ever
persisted; the resolved value never lands in the VM record, the database, or a
`.smolmachine` pack.
Attach refs on the command line:
```bash
# From a host environment variable (GUEST_VAR=HOST_VAR)
smolvm machine run --secret-env OPENAI_API_KEY=OPENAI_API_KEY -- ./app
smolvm machine create --name web --secret-env DATABASE_URL=PROD_DB_URL # persists the ref
smolvm machine exec --name web --secret-env TOKEN=CI_TOKEN -- ./deploy
# From a host file (GUEST_VAR=/absolute/path)
smolvm machine run --secret-file GCP_CREDS=/abs/creds.json -- ./app
# Bridge any external manager through the env/file seam, e.g. 1Password:
op run --env-file=secrets.env -- smolvm machine run -- ./app
```
Or reference them from a Smolfile. The left-hand key becomes the env var name in
the guest workload:
```toml
[secrets]
DATABASE_URL = { from_env = "PROD_DB_URL" } # host env var (at launch)
GCP_CREDS = { from_file = "/abs/creds.json" } # absolute host file (at launch)
```
Exactly one of `from_env`, `from_file` must be set per entry; `from_file` paths
must be absolute. Resolved values are appended *after* top-level `env` and CLI
`-e` flags. Resolution is late-bound, so rotating the underlying env var or file
takes effect at the next launch with nothing to re-sync.
**Threat model:** this is defense-in-depth, not zero-knowledge. The target
process sees plaintext in its own environment, and root inside the guest can
read any `/proc/*/environ`. Use SSH agent forwarding instead when a secret must
never leave the host.
**Where they're resolved:** `machine run`, `machine create` + `machine start`,
and `machine exec` resolve refs against *this host* under a trusted-local scope.
Untrusted surfaces — HTTP API request bodies and portable `.smolmachine` packs —
are treated as untrusted callers and may carry **no** resolvable secret ref:
`from_env` would expose the server's env and `from_file` would be an arbitrary
host-file read, so both are rejected. Configure secrets locally instead.
## File Copy
Copy files between the host and a running machine using `machine:path` syntax:
```bash
# Upload a file to the VM
smolvm machine cp ./script.py myvm:/workspace/script.py
# Download a file from the VM
smolvm machine cp myvm:/workspace/output.json ./output.json
```
**Image-based VMs (--image):** Files copied with `cp` are visible to
`exec` at the same path, and vice versa. This works for any path —
`/tmp`, `/home`, `/workspace`, etc. Under the hood, `cp` routes
through the container's overlay filesystem so both commands see the
same files.
**`/workspace` shared directory:** Every machine has a `/workspace`
directory — bare VMs, image-based VMs, and machines created from
`.smolmachine` artifacts. It persists across `exec` sessions and
across `stop`/`start` cycles. It's a good default location for
scripts, data, and results. Passing `-v /host/dir:/workspace` replaces
the default storage-disk workspace with your host directory for that
run — the host mount takes priority and the storage workspace is skipped:
```bash
# Typical agent workflow: copy code in, execute, extract results
smolvm machine create --name r-sandbox --image r-base:latest --net
smolvm machine start --name r-sandbox
smolvm machine cp analysis.R r-sandbox:/workspace/analysis.R
smolvm machine exec --name r-sandbox -- Rscript /workspace/analysis.R
smolvm machine cp r-sandbox:/workspace/results.csv ./results.csv
smolvm machine stop --name r-sandbox
```
**Behavior and limits:**
- Files up to 1 MiB transfer as a single message — no perceptible
overhead beyond the agent round-trip.
- Larger files stream automatically: 1 MiB chunks for upload, 16 MiB
chunks for download. The split is asymmetric because the
host→guest direction has tighter socket-buffer headroom.
- Per-transfer cap is **4 GiB** in either direction. Files at or
above this size are rejected up front (`total_size exceeds maximum`
on upload; `exceeding the byte cap` on download). For larger
blobs, mount a host directory with `--volume` instead of copying.
- A throttled progress line prints to stderr while large transfers
run, including bytes-so-far, percentage (uploads), and rate.
Pipe captures (`> file`) only see the upload/download summary,
not the progress noise.
- Atomic on the guest side: a partially-written file never appears
at the target path. If the transfer fails or the connection drops
mid-stream, the staging file is cleaned up and the original
destination (if any) is unaffected.
Typical throughput on macOS (Apple Silicon): ~35-42 MB/s upload,
~170 MB/s download.
## Streaming Exec
Stream command output in real-time instead of buffering:
```bash
# CLI — prints output as it arrives
smolvm machine exec --stream --name myvm -- python3 train.py
# API — Server-Sent Events
POST /api/v1/machines/:name/exec/stream
Content-Type: application/json
{"command": ["python3", "train.py"]}
# Response: text/event-stream
# event: stdout
# data: Epoch 1/10...
# event: exit
# data: {"exitCode":0}
```
## Bare VM Mode
`machine run` works without `--image` when a Smolfile provides the workload config, or for direct Alpine shell access:
```bash
# Bare Alpine shell
smolvm machine run -it
# Smolfile with entrypoint/cmd (no container overhead)
smolvm machine run -s Smolfile
# Bare VM with init setup, detached
smolvm machine run -d -s Smolfile
```
Bare VMs run commands directly in the Alpine rootfs — no OCI image pull needed. Use this when you need a minimal Linux environment.
## Packed Binaries (.smolmachine)
`smolvm pack create` produces two files:
- `my-app` — stub binary with embedded VM runtime (platform-specific)
- `my-app.smolmachine` — VM payload: rootfs, OCI layers, storage (cross-platform)
The packed binary runs as a normal executable:
```bash
./my-app run -- python3 -c "print('hello')" # ephemeral, cleaned up after exit
./my-app start # persistent daemon mode
./my-app exec -- pip install x # exec into daemon
./my-app stop # stop daemon
```
Alternatively, create a named machine from the `.smolmachine` for full lifecycle management:
```bash
smolvm machine create --name my-vm --from my-app.smolmachine
smolvm machine start --name my-vm # ~250ms boot, no image pull
smolvm machine exec --name my-vm -- pip install x # fully persistent
smolvm machine stop --name my-vm
smolvm machine ls # shows my-vm
```
The `.smolmachine` manifest includes registry-oriented metadata:
- `host_platform` — host OS+arch this machine runs on (e.g., `darwin/arm64`), distinct from `platform` which is the guest
- `created` — RFC 3339 timestamp of when the machine was packed
- `smolvm_version` — version of smolvm that built it
## HTTP API
Start with `smolvm serve start --listen 127.0.0.1:8080` or `smolvm serve start --listen $XDG_RUNTIME_DIR/smolvm.sock`. Key endpoints:
```
POST /api/v1/machines Create machine
GET /api/v1/machines List machines
GET /api/v1/machines/:name Get machine
POST /api/v1/machines/:name/start Start machine
POST /api/v1/machines/:name/stop Stop machine
DELETE /api/v1/machines/:name Delete machine
POST /api/v1/machines/:name/exec Execute command
POST /api/v1/machines/:name/exec/stream Streaming exec (SSE)
PUT /api/v1/machines/:name/files/*path Upload file
GET /api/v1/machines/:name/files/*path Download file
GET /api/v1/machines/:name/logs Stream logs (SSE)
POST /api/v1/machines/:name/images/pull Pull OCI image
```
OpenAPI spec: `smolvm serve openapi`
## Important Defaults
- Machine name defaults to `"default"` when `--name` is omitted
- Network is **off** by default (security-first)
- CPUs: 4, Memory: 8192 MiB, Storage: 20 GiB, Overlay: 2 GiB
- Packed binaries use the same defaults (CPUs: 4, Memory: 8192 MiB)
- Memory and CPU are elastic via virtio balloon — the host only commits what the guest actually uses and reclaims the rest
## Important Behaviors
- **Observational commands don't stop running VMs.** `machine images`, `machine status`, `machine ls` and similar read-only commands leave a running VM in its current state. If the VM was already running before the command, it stays running after.
- **`machine prune` works on a running VM.** Regular prune only removes unreferenced layers and is safe while containers are active. `prune --all` requires the VM to be stopped first since it deletes manifests for layers that may be in use.
- **`machine exec` persists filesystem changes.** Package installs, config edits, and file writes inside `exec` survive across sessions. This works for both bare VMs and image-based VMs (created with `--image`).
- **`machine update` modifies a stopped machine.** Add/remove mounts, ports, env vars, or change CPU/memory without recreating the VM. Changes take effect on next `machine start`. Requires the machine to be stopped.
- **`machine run` is always ephemeral.** The VM is created, the command runs, and everything is cleaned up. No state carries over.
- **`-v host:/workspace` replaces the default workspace.** Every image-based VM exposes `/workspace` backed by the VM's storage disk. Mounting a host directory at `/workspace` takes priority — the host share is used instead and the storage-disk workspace is not mounted. Any other target path (e.g. `/data`, `/app`) does not affect `/workspace`.
Generated
+3836
View File
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
[workspace]
members = [
".",
"crates/smolvm-agent",
"crates/smolvm-cuda",
"crates/smolvm-cuda-guest",
"crates/smolvm-cuda-shim",
"crates/smolvm-cuda-codegen",
"crates/smolvm-cudart-shim",
"crates/smolvm-network",
"crates/smolvm-pack",
"crates/smolvm-protocol",
"crates/smolvm-registry",
"crates/smolvm-smolfile",
]
resolver = "2"
[package]
name = "smolvm"
version = "1.5.2"
edition = "2021"
description = "OCI-native microVM runtime"
license = "Apache-2.0"
repository = "https://github.com/smol-machines/smolvm"
keywords = ["microvm", "container", "virtualization"]
categories = ["virtualization", "command-line-utilities"]
[lib]
name = "smolvm"
path = "src/lib.rs"
[[bin]]
name = "smolvm"
path = "src/main.rs"
[dependencies]
smolvm-network = { path = "crates/smolvm-network", version = "1.5.2" }
smolvm-protocol = { path = "crates/smolvm-protocol", version = "1.5.2" }
smolvm-cuda = { path = "crates/smolvm-cuda", version = "1.5.2" }
smolvm-pack = { path = "crates/smolvm-pack", version = "1.5.2" }
smolvm-registry = { path = "crates/smolvm-registry", version = "1.5.2" }
smolfile = { path = "crates/smolvm-smolfile", version = "1.5.2" }
thiserror = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
toml = "0.8"
rusqlite = { version = "0.32", features = ["bundled"] }
libc = "0.2"
# Cross-platform dynamic library loading for libkrun (dlopen on Unix,
# LoadLibraryW on Windows). Replaces the previous direct libc::dlopen path.
libloading = "0.8"
# Cross-platform AF_UNIX sockets for the agent control channel and vsock-port
# bridges. std has no UnixStream on Windows, but socket2 (Domain::UNIX) works
# on Windows 10 1809+. See src/platform/uds.rs.
socket2 = "0.6"
dirs = "5"
sha2 = "0.10"
hex = "0.4"
# Host filesystem watcher (FSEvents on macOS, inotify on Linux) that drives
# host→guest fsnotify propagation for -v mounts. See src/agent/fsnotify_watch.rs.
notify = "6"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
base64 = { workspace = true }
# Secrets management. Resolved plaintext is scrubbed on drop; smolvm stores no
# secret material itself (refs only), so no at-rest crypto/store deps are needed.
zeroize = { version = "1", features = ["zeroize_derive"] }
humantime = "2"
tempfile = "3"
# HTTP API server
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["macros", "ws"] }
# mTLS for the fleet serve API (control↔node). axum-server's rustls acceptor
# does the TLS handshake + client-cert verification; rustls-pemfile parses the
# CA/cert/key PEMs handed to us at enrollment.
axum-server = { version = "0.7", features = ["tls-rustls"] }
rustls = { version = "0.23", default-features = false, features = ["ring"] }
rustls-pemfile = "2"
rustls-pki-types = "1"
tower-http = { version = "0.6", features = ["cors", "trace", "timeout"] }
tokio-stream = "0.1"
# ReaderStream for streaming a cached blob from disk to a peer (GET /p2p/blob).
tokio-util = { version = "0.7", features = ["io"] }
parking_lot = "0.12"
async-stream = "0.3"
futures-util = "0.3"
# OpenAPI documentation
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-axum = "0.2"
utoipa-swagger-ui = { version = "9", features = ["axum", "vendored"] }
ipnet = "2.12.0"
metrics = "0.24"
metrics-exporter-prometheus = { version = "0.16", default-features = false }
# Seccomp + Landlock for the VM boot subprocess (runtime isolation). Linux-only
# — macOS/HVF has neither; the code is cfg-gated to linux (seccomp also to x86_64).
[target.'cfg(target_os = "linux")'.dependencies]
seccompiler = "0.4"
landlock = "0.4"
# Windows process management (OpenProcess / TerminateProcess / Wait) and
# DLL-search-path control (SetDllDirectoryW). Used to replace the Unix
# kill/waitpid/dlopen primitives on the Windows host build.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.60", features = [
"Win32_Foundation",
"Win32_System_Threading",
"Win32_System_ProcessStatus",
"Win32_System_LibraryLoader",
"Win32_System_Console",
"Win32_System_IO",
"Win32_Storage_FileSystem",
"Win32_Networking_WinSock",
] }
[dev-dependencies]
tempfile = "3"
smolvm-protocol = { path = "crates/smolvm-protocol", version = "1.5.2" }
regex = "1"
[build-dependencies]
pkg-config = "0.3"
[workspace.dependencies]
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
tracing = "0.1"
# Release profile optimizations
[profile.release]
lto = true
codegen-units = 1
strip = true
# Smaller binary profile for agent (use with --profile release-small)
[profile.release-small]
inherits = "release"
opt-level = "z" # Optimize for size
lto = true
codegen-units = 1
panic = "abort" # No unwinding - saves ~10% binary size
strip = true
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024-2025 smolvm contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+40
View File
@@ -0,0 +1,40 @@
# Third-Party Licenses
smolvm bundles the following third-party libraries in the `lib/` directory.
## libkrun
- **Version:** 1.15.1
- **License:** Apache License 2.0
- **Source:** https://github.com/containers/libkrun
- **Copyright:** The libkrun Authors
Licensed under the Apache License, Version 2.0. You may obtain a copy of the License at:
http://www.apache.org/licenses/LICENSE-2.0
## libkrunfw
- **Version:** 4.x
- **License:** LGPL-2.1-only (library), GPL-2.0-only (bundled Linux kernel)
- **Source:** https://github.com/containers/libkrunfw
- **Copyright:** The libkrunfw Authors
libkrunfw is a library that bundles the Linux kernel for use with libkrun.
### Source Code Availability
In compliance with LGPL-2.1 and GPL-2.0, the complete source code for libkrunfw and the bundled Linux kernel is available at:
- **libkrunfw:** https://github.com/containers/libkrunfw
- **Linux kernel (with patches):** https://github.com/containers/libkrunfw/tree/main/patches
To obtain the exact source code corresponding to the bundled binary, check out the version tag matching the library version from the repository above.
### Your Rights Under LGPL-2.1
You have the right to:
- Use this library in your own projects
- Modify the library and distribute your modifications
- Reverse engineer the library for debugging purposes
If you distribute a modified version of libkrunfw, you must make your modifications available under the same license.
+276
View File
@@ -0,0 +1,276 @@
# cargo-make configuration for smolvm
# https://github.com/sagiegurari/cargo-make
#
# Usage:
# cargo make dev # Build and codesign (macOS)
# cargo make smolvm <args> # Run with env vars configured for local resources
# cargo make dist # Build distribution package
# cargo make test # Run all tests
# cargo make lint # Clippy + fmt checks
[config]
min_version = "0.37.0"
default_to_workspace = false
skip_core_tasks = true
# =============================================================================
# Development Tasks
# =============================================================================
[tasks.dev]
description = "Build and codesign dev binary (macOS auto-signs)"
category = "Development"
script_runner = "bash"
script = [
'''
if [ -z "${LIBKRUN_BUNDLE:-}" ]; then
if [ "$(uname -s)" = "Linux" ]; then
export LIBKRUN_BUNDLE="${CARGO_MAKE_WORKING_DIRECTORY}/lib/linux-$(uname -m)"
else
export LIBKRUN_BUNDLE="${CARGO_MAKE_WORKING_DIRECTORY}/lib"
fi
fi
cargo build --release
if [ "$(uname -s)" = "Darwin" ]; then
codesign --force --sign - --entitlements smolvm.entitlements ./target/release/smolvm
echo "✓ Codesigned ./target/release/smolvm"
else
echo "✓ Linux: no codesigning needed"
fi
echo ""
echo "Binary ready! Run it with:"
echo " cargo make smolvm --version"
echo " cargo make smolvm sandbox run --net alpine:latest -- echo hello"
'''
]
[tasks.ensure-binary-signed]
description = "Ensure binary is built and signed (macOS)"
category = "Development"
script_runner = "bash"
script = [
'''
if [ ! -f ./target/release/smolvm ]; then
echo "Binary not found, building..."
cargo build --release
fi
if [ "$(uname -s)" = "Darwin" ]; then
codesign --force --sign - --entitlements smolvm.entitlements ./target/release/smolvm 2>/dev/null || true
fi
'''
]
[tasks.smolvm]
description = "Run smolvm with environment variables set up"
category = "Development"
dependencies = ["ensure-binary-signed"]
env = { DYLD_LIBRARY_PATH = "${CARGO_MAKE_WORKING_DIRECTORY}/lib", SMOLVM_AGENT_ROOTFS = "${CARGO_MAKE_WORKING_DIRECTORY}/target/agent-rootfs" }
script_runner = "bash"
script = [
'''
IFS=';' read -ra ARGS <<< "${CARGO_MAKE_TASK_ARGS}"
exec ./target/release/smolvm "${ARGS[@]}"
'''
]
# =============================================================================
# Build Tasks
# =============================================================================
[tasks.build]
description = "Build release binary"
category = "Build"
command = "cargo"
args = ["build", "--release"]
[tasks.build-agent]
description = "Build agent for Linux (size-optimized)"
category = "Build"
script_runner = "bash"
script = [
'''
if [ "$(uname -s)" = "Linux" ]; then
cargo build --profile release-small -p smolvm-agent --target x86_64-unknown-linux-musl
else
docker run --rm -v "$(pwd):/work" -w /work rust:alpine sh -c 'apk add musl-dev && cargo build --profile release-small -p smolvm-agent'
fi
'''
]
# =============================================================================
# libkrun Tasks
# =============================================================================
[tasks.build-libkrunfw]
description = "Build libkrunfw guest kernel and update lib/ (slow: downloads and compiles Linux 6.x)"
category = "Build"
script_runner = "bash"
script = [
'''
set -e
ARCH=$(uname -m)
FULL_VERSION=$(grep '^FULL_VERSION' libkrunfw/Makefile | cut -d= -f2 | tr -d ' ')
KERNEL_SRC=$(grep '^KERNEL_VERSION' libkrunfw/Makefile | cut -d= -f2 | tr -d ' ')
# If the kernel source tree already exists, update its .config and force
# kernel.c to regenerate — otherwise make skips both steps.
if [ -d "libkrunfw/${KERNEL_SRC}" ]; then
cp libkrunfw/config-libkrunfw_x86_64 libkrunfw/${KERNEL_SRC}/.config
make -C libkrunfw/${KERNEL_SRC} olddefconfig
rm -f libkrunfw/kernel.c
fi
make -C libkrunfw
mkdir -p lib/linux-${ARCH}
cp libkrunfw/libkrunfw.so.${FULL_VERSION} lib/linux-${ARCH}/libkrunfw.so.${FULL_VERSION}
cd lib/linux-${ARCH}
ln -sf libkrunfw.so.${FULL_VERSION} libkrunfw.so.5
ln -sf libkrunfw.so.5 libkrunfw.so
echo "✓ libkrunfw ${FULL_VERSION} built and installed to lib/linux-${ARCH}/"
'''
]
[tasks.build-libkrun]
description = "Build libkrun with BLK and GPU support and copy into lib/ (run build-libkrunfw first if kernel config changed)"
category = "Build"
script_runner = "bash"
script = [
'''
set -e
ARCH=$(uname -m)
make -C libkrun BLK=1 NET=1 GPU=1
if [ "$(uname -s)" = "Darwin" ]; then
# macOS: the dylib lives in lib/ (alongside libkrunfw.5.dylib).
cp libkrun/target/release/libkrun.dylib lib/libkrun.dylib
./scripts/stamp-libkrun-provenance.sh lib --skip-libkrunfw
echo "✓ libkrun built and copied to lib/libkrun.dylib"
else
mkdir -p lib/linux-${ARCH}
cp libkrun/target/release/libkrun.so lib/linux-${ARCH}/libkrun.so
./scripts/stamp-libkrun-provenance.sh lib/linux-${ARCH} --skip-libkrunfw
echo "✓ libkrun built and copied to lib/linux-${ARCH}/libkrun.so"
fi
'''
]
# =============================================================================
# Distribution Tasks
# =============================================================================
[tasks.dist]
description = "Build distribution package"
category = "Distribution"
script_runner = "bash"
script = [
'''
./scripts/build-dist.sh "$@"
'''
]
# =============================================================================
# Agent Tasks
# =============================================================================
[tasks.agent-rootfs]
description = "Build agent rootfs"
category = "Agent"
script_runner = "bash"
script = [
'''
./scripts/build-agent-rootfs.sh
'''
]
[tasks.agent-rebuild]
description = "Rebuild agent and update rootfs"
category = "Agent"
dependencies = ["build-agent", "agent-rootfs"]
script_runner = "bash"
script = [
'''
./scripts/rebuild-agent.sh
'''
]
# =============================================================================
# Test Tasks
# =============================================================================
[tasks.test]
description = "Run all feature suites (11 groups, ~10 min)"
category = "Test"
dependencies = ["test-lib"]
script_runner = "bash"
script = [
'''
./tests/run_tests.sh
'''
]
[tasks.test-cli]
description = "Run CLI tests only"
category = "Test"
script_runner = "bash"
script = [
'''
./tests/run_tests.sh cli
'''
]
[tasks.test-pack]
description = "Run pack tests only"
category = "Test"
script_runner = "bash"
script = [
'''
./tests/run_tests.sh pack
'''
]
[tasks.test-lib]
description = "Run unit tests (no VM required)"
category = "Test"
command = "cargo"
args = ["test", "--lib"]
# =============================================================================
# Install Tasks
# =============================================================================
[tasks.install]
description = "Install locally from dist package"
category = "Install"
script_runner = "bash"
script = [
'''
./scripts/install-local.sh
'''
]
# =============================================================================
# Code Quality Tasks
# =============================================================================
[tasks.lint]
description = "Run clippy and fmt checks"
category = "Quality"
script_runner = "bash"
script = [
'''
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
'''
]
[tasks.fix-lints]
description = "Auto-fix linting issues"
category = "Quality"
script_runner = "bash"
script = [
'''
cargo fmt --all
cargo clippy --all-targets --fix --allow-dirty
'''
]
+236
View File
@@ -0,0 +1,236 @@
<p align="center">
<img src="assets/logo.png" alt="smol machines" width="80">
</p>
<p align="center">
<a href="https://discord.gg/E5r8rEWY9J"><img src="https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://github.com/smol-machines/smolvm/releases"><img src="https://img.shields.io/github/v/release/smol-machines/smolvm?label=Release" alt="Release"></a>
<a href="https://github.com/smol-machines/smolvm/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue.svg" alt="License"></a>
</p>
smolvm
======
Ship and run software with isolation by default.
This is a CLI tool that lets you:
1. Manage and run custom Linux virtual machines locally with: sub-second cold start, cross-platform (macOS, Linux, Windows), elastic memory usage.
2. Pack a stateful virtual machine into a single file (.smolmachine) to rehydrate on any supported platform.
Install
-------
```bash
# install (macOS + Linux)
curl -sSL https://smolmachines.com/install.sh | bash
# for coding agents — install + discover all commands
curl -sSL https://smolmachines.com/install.sh | bash && smolvm --help
```
Or download from [GitHub Releases](https://github.com/smol-machines/smolvm/releases), and place it into `~/.local/share/`.
**Windows:** download the `windows-x86_64` release (bundles `krun.dll` + `libkrunfw.dll`), unzip it, and run `smolvm.exe`. Requires the [Windows Hypervisor Platform](https://learn.microsoft.com/en-us/virtualization/api/) (WHP) feature enabled.
Quick Start
-----------
```bash
# run a command in an ephemeral VM (cleaned up after exit)
smolvm machine run --net --image alpine -- sh -c "echo 'Hello world from a microVM' && uname -a"
# interactive shell
smolvm machine run --net -it --image alpine -- /bin/sh
# inside the VM: apk add sl && sl && exit
```
Use This For
------------
**Sandbox untrusted code** — run untrusted programs in a hardware-isolated VM. Host filesystem, network, and credentials are separated by a hypervisor boundary.
```bash
# network is off by default — untrusted code can't phone home
smolvm machine run --image alpine -- nslookup example.com
# fails — no network access
# lock down egress — only allow specific hosts
smolvm machine run --net --image alpine --allow-host registry.npmjs.org -- wget -q -O /dev/null https://registry.npmjs.org
# works — allowed host
smolvm machine run --net --image alpine --allow-host registry.npmjs.org -- wget -q -O /dev/null https://google.com
# fails — not in allow list
```
**Pack into portable executables** — turn any workload into a self-contained binary. All dependencies are pre-baked — no install step, no runtime downloads, boots in <200ms.
```bash
smolvm pack create --image python:3.12-alpine -o ./python312
./python312 run -- python3 --version
# Python 3.12.x — isolated, no pyenv/venv/conda needed
```
**Use local container images** — for CI, air-gapped hosts, and fast iteration. Feed `--image` a `docker save` / `podman save` archive, pipe one on stdin, or point it at an unpacked rootfs directory. Image work is delegated to your container tooling; smolvm just boots the result.
```bash
# build locally, run in the VM with no push/pull
docker build -t myapp .
docker save myapp | smolvm machine run --image - -- ./app
# from an archive file (boots with no network)
smolvm machine run --image ./myapp.tar -- ./app
# from an already-unpacked rootfs directory
smolvm machine run --image ./rootfs/ -- ./app
```
**Persistent machines for development** — create, stop, start. Installed packages survive restarts.
```bash
smolvm machine create --net --name myvm
smolvm machine start --name myvm
smolvm machine exec --name myvm -- apk add sl
smolvm machine exec --name myvm -it -- /bin/sh
# inside: sl, ls, uname -a — type 'exit' to leave
smolvm machine stop --name myvm
```
**Use git and SSH without copying private keys into the guest.** Forward your host SSH agent into the VM. The guest can ask the agent to sign with any forwarded key while the socket is available, so forward it only to workloads you trust. Requires an SSH agent running on your host (`ssh-add -l` to check).
```bash
smolvm machine run --ssh-agent --net --image alpine -- sh -c "apk add -q openssh-client && ssh-add -l"
# lists your host keys; private key material remains in the host agent
smolvm machine exec --name myvm -- git clone git@github.com:org/private-repo.git
```
**Declare environments with a Smolfile** — reproducible VM config in a simple TOML file.
```toml
image = "python:3.12-alpine"
net = true
[network]
allow_hosts = ["api.stripe.com", "db.example.com"]
[dev]
init = ["pip install -r requirements.txt"]
volumes = ["./src:/app"]
[auth]
ssh_agent = true
```
```bash
smolvm machine create --name myvm -s Smolfile
smolvm machine start --name myvm
```
More examples: [python](https://github.com/smol-machines/smolvm/tree/main/examples/python-app) · [node](https://github.com/smol-machines/smolvm/tree/main/examples/node-app) · [doom](https://github.com/smol-machines/smolvm/tree/main/examples/doom-web)
How It Works
------------
Each workload runs in a hardware-virtualized VM with its own guest kernel on [Hypervisor.framework](https://developer.apple.com/documentation/hypervisor) (macOS), KVM (Linux), or the [Windows Hypervisor Platform](https://learn.microsoft.com/en-us/virtualization/api/) (Windows). [libkrun](https://github.com/containers/libkrun) is the VMM and [libkrunfw](https://github.com/smol-machines/libkrunfw) supplies the guest kernel. Pack it into a `.smolmachine` and it runs anywhere the host architecture matches, with zero dependencies.
Images use the [OCI](https://opencontainers.org/) format — the same open standard Docker uses. Any image on Docker Hub, ghcr.io, or other OCI registries can be pulled and booted as a microVM. No Docker daemon required.
Defaults: 4 vCPUs, 8 GiB RAM. Memory is elastic via virtio balloon — the host only commits what the guest actually uses and reclaims the rest automatically. vCPU threads sleep in the hypervisor when idle, so over-provisioning has near-zero cost. Override with `--cpus` and `--mem`.
Security Model
--------------
smolvm strengthens the guest/host boundary by giving each workload a separate VM and guest kernel. It is not, by itself, a hardened multi-user control plane:
* The `smolvm` CLI and VMM processes run with the permissions of the invoking host user. That user account, the host OS, the hypervisor backend, libkrun, and smolvm are in the trusted computing base.
* Host directories passed with `--volume` are intentionally exposed to the guest with the requested access. Do not mount secrets or sensitive paths into an untrusted workload.
* `--ssh-agent` does not copy private key material into the guest, but it grants the guest access to the forwarded agent socket and therefore the ability to request signatures while the VM is running.
* Networking is disabled by default. Enabling `--net`, port forwarding, or host services expands the workload's reachable surface.
* In standalone local use, smolvm's state and control endpoints are scoped to the invoking user's environment. For hostile local co-tenants, add host-level account separation and OS confinement around the VMM process. This section does not describe the separate smolmachines cloud control plane or its tenant-isolation guarantees.
* Release archives publish SHA-256 checksums and the installer rejects a mismatch when the checksum file is available. Releases are not currently signed or accompanied by provenance attestations, and the installer permits installation when the checksum file cannot be downloaded.
Treat root in the guest as untrusted. The VM boundary limits its direct access to the host, while every explicitly forwarded capability, including mounts, network access, ports, and SSH agent access, becomes part of the workload's authority.
Comparison
----------
| | smolvm | Containers | Colima | QEMU | Firecracker | Kata |
|---------------------|--------|------------|--------|------|-------------|------|
| Workload boundary | VM + guest kernel | Namespace + shared kernel | Namespace inside shared VM | VM + guest kernel | VM + guest kernel | VM per container |
| Boot time | <200ms | ~100ms | ~seconds | ~15-30s | <125ms | ~500ms |
| Architecture | Library (libkrun) | Daemon | Daemon (in VM) | Process | Process | Runtime stack |
| Per-workload VMs | Yes | No | No (shared) | Yes | Yes | Yes |
| macOS native | Yes | Via Docker VM | Yes (krunkit) | Yes | No | No |
| Embeddable SDK | Yes | No | No | No | No | No |
| Portable artifacts | `.smolmachine` | Images (need daemon) | No | No | No | No |
Platform Support
----------------
| Host | Guest | Requirements |
|------|-------|-------------|
| macOS Apple Silicon | arm64 Linux | macOS 11+ |
| macOS Intel | x86_64 Linux | macOS 11+ (untested) |
| Linux x86_64 | x86_64 Linux | KVM (`/dev/kvm`) |
| Linux aarch64 | aarch64 Linux | KVM (`/dev/kvm`) |
| Windows x86_64 | x86_64 Linux | Windows Hypervisor Platform (WHP) enabled |
Known Limitations
-----------------
* Network is opt-in (`--net` on `machine create`). TCP/UDP only, no ICMP.
* Volume mounts: directories only (no single files). Mounting at `/workspace` (`-v /host/dir:/workspace`) takes priority over the default storage-disk workspace — your host directory is used instead.
* macOS: binary must be signed with Hypervisor.framework entitlements.
* `--ssh-agent` requires an SSH agent running on the host (`SSH_AUTH_SOCK` must be set).
* GPU acceleration requires libkrun built with `GPU=1` and virglrenderer + a Vulkan driver on the host (see [GPU Acceleration](#gpu-acceleration) below).
* Windows: `--net` works the same as on other platforms (virtio-net with inbound port-forwarding; TSI for outbound-only VMs), as do `machine exec` / interactive sessions and `machine stats`. Not yet available on Windows: GPU acceleration and `machine fork` / snapshot. Pack *create* needs `storage-template.ext4` / `overlay-template.ext4` next to `smolvm.exe` (Windows has no host `mkfs.ext4`).
GPU Acceleration
----------------
smolvm exposes the host GPU to guests via **virtio-gpu / Venus** (Vulkan-over-virtio). Guest workloads see a real Vulkan device; on Linux + Intel this renders as:
```
ANGLE (Intel, Vulkan 1.4 (Virtio-GPU Venus (Intel(R) UHD Graphics ...)), venus)
```
### Host requirements
**macOS** — virglrenderer and MoltenVK are bundled in the smolvm distribution. No extra installs needed.
**Linux** — virglrenderer and a host Vulkan driver must be installed from the system package manager:
| Distro | Packages |
|--------|----------|
| Alpine | `apk add virglrenderer mesa-vulkan-intel` (or `mesa-vulkan-ati` for AMD) |
| Debian/Ubuntu | `apt install virglrenderer0 mesa-vulkan-drivers` |
> virglrenderer depends on libEGL and libdrm from the host GPU driver stack — these are hardware-specific and cannot be bundled. Any GPU-capable Linux host will already have them installed via its GPU driver.
### Usage
```bash
# CLI
smolvm machine run --gpu --image alpine -- vulkaninfo --summary
# Smolfile
# gpu = true
# gpu_vram = 2048 # MiB, default 4096
```
The guest Vulkan loader must be pointed at the virtio ICD:
```bash
export VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/virtio_icd.x86_64.json
```
### Headless browser example
See [`examples/headless-browser/`](examples/headless-browser/) for a working Chromium setup using ANGLE + Venus for hardware-accelerated WebGL inside a headless VM.
Development
-----------
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
[Apache-2.0](LICENSE) · made by [@binsquare](https://github.com/BinSquare) · [twitter](https://x.com/binsquares) · [github](https://github.com/smol-machines/smolvm)
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`smol-machines/smolvm`
- 原始仓库:https://github.com/smol-machines/smolvm
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+436
View File
@@ -0,0 +1,436 @@
//! Build script for smolvm.
//!
//! Handles macOS weak-link setup for libkrun.
//!
//! Linux loads libkrun at runtime with `dlopen` so packed stubs can start
//! before bundled libraries are extracted.
//!
//! # Linking Options
//!
//! ## Dynamic (default)
//! macOS can weak-link against libkrun installed on the system:
//! ```sh
//! brew install libkrun # macOS
//! cargo build
//! ```
//!
//! ## Bundle Pre-built Library (Recommended for Distribution)
//! Copy Homebrew's libkrun to your bundle directory and build:
//! ```sh
//! mkdir -p lib
//! cp /opt/homebrew/opt/libkrun/lib/libkrun.dylib lib/
//! # Also copy libkrunfw if bundling
//! cp /opt/homebrew/opt/libkrunfw/lib/libkrunfw.dylib lib/
//! LIBKRUN_BUNDLE=$PWD/lib cargo build --release
//! ```
//! The binary will use @rpath to find libraries in ./lib or ../lib.
//!
//! ## Build from Submodule (Experimental)
//! Build libkrun from the vendored submodule:
//! ```sh
//! LIBKRUN_BUILD=1 cargo build
//! ```
//! Note: This builds a minimal libkrun. Block device support (`krun_add_disk2`)
//! requires building libkrun separately with `make BLK=1` and using LIBKRUN_BUNDLE.
//!
//! ## Static (libkrun only)
//! Statically link libkrun (still dynamically links libkrunfw):
//! ```sh
//! LIBKRUN_STATIC=/path/to/libkrun.a cargo build
//! ```
#[cfg(target_os = "macos")]
use std::path::PathBuf;
#[cfg(target_os = "macos")]
use std::process::Command;
/// Link libkrun for legacy macOS weak-link behavior.
///
/// Linux intentionally does not link libkrun at build time: packed stubs must
/// be able to start before bundled libraries are extracted, so Linux VM launch
/// paths use `dlopen` instead.
#[cfg(target_os = "macos")]
fn link_krun() {
println!("cargo:rustc-link-arg=-Wl,-weak-lkrun");
}
#[cfg(target_os = "macos")]
fn has_library(name: &str) -> bool {
pkg_config::Config::new().probe(name).is_ok()
}
fn main() {
// Build scripts run on the HOST: `cfg!(target_os)` here describes the
// build machine, not the artifact. Cross-compiling smolvm from macOS to
// Linux (zigbuild) must NOT emit macOS-only linker args (-sectcreate,
// -weak-lkrun) — gate every emission on the actual compile target.
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "macos" {
// On macOS, create a placeholder __SMOLVM,__smolvm Mach-O section.
// This section is replaced with real data by `smolvm pack --single-file`.
// The placeholder marker is NOT the SMOLSECT magic, so detect.rs won't
// false-positive on a normal smolvm binary.
#[cfg(target_os = "macos")]
{
use std::io::Write;
let out_dir = std::env::var("OUT_DIR").unwrap();
let placeholder_path = format!("{}/smolvm_placeholder.bin", out_dir);
let mut f = std::fs::File::create(&placeholder_path).unwrap();
f.write_all(b"SMOLVM_SECTION_PLACEHOLDER_V1").unwrap();
f.write_all(&[0u8; 4]).unwrap();
println!(
"cargo:rustc-link-arg=-Wl,-sectcreate,__SMOLVM,__smolvm,{}",
placeholder_path
);
}
#[cfg(target_os = "macos")]
link_libkrun();
}
}
#[cfg(target_os = "macos")]
fn link_libkrun() {
println!("cargo:rerun-if-env-changed=LIBKRUN_STATIC");
println!("cargo:rerun-if-env-changed=LIBKRUN_BUNDLE");
println!("cargo:rerun-if-env-changed=LIBKRUN_DIR");
println!("cargo:rerun-if-env-changed=LIBKRUN_BUILD");
// Option 0: Build from submodule
if std::env::var("LIBKRUN_BUILD").is_ok() {
if let Some(lib_path) = build_libkrun_from_submodule() {
println!("cargo:rustc-link-search=native={}", lib_path.display());
link_krun();
// Set rpath to find libraries relative to executable
#[cfg(target_os = "macos")]
{
println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/lib");
println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/../lib");
// Also add the build output path for development
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_path.display());
}
#[cfg(target_os = "linux")]
{
println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN/lib");
println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN/../lib");
}
return;
}
}
// Option 1: Bundle libraries with the binary
if let Ok(bundle_path) = std::env::var("LIBKRUN_BUNDLE") {
// On Linux, check that the library is not an LFS pointer before linking
#[cfg(target_os = "linux")]
{
let lib_path = std::path::Path::new(&bundle_path).join("libkrun.so");
if lib_path.exists() && is_lfs_pointer(&lib_path) {
println!("cargo:error=libkrun.so is a Git LFS pointer, not the actual library.");
println!("cargo:error=Run 'git lfs pull' to fetch the actual library binary.");
panic!("Git LFS pointer detected");
}
}
println!("cargo:rustc-link-search=native={}", bundle_path);
link_krun();
// Set rpath to find libraries relative to executable
#[cfg(target_os = "macos")]
{
println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/lib");
println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/../lib");
// Change the library's install_name to use @rpath and re-sign
let lib_path = std::path::Path::new(&bundle_path).join("libkrun.dylib");
if lib_path.exists() {
let _ = Command::new("install_name_tool")
.args(["-id", "@rpath/libkrun.dylib", lib_path.to_str().unwrap()])
.status();
// Re-sign after modification (macOS requires valid signature)
let _ = Command::new("codesign")
.args(["--force", "--sign", "-", lib_path.to_str().unwrap()])
.status();
}
}
#[cfg(target_os = "linux")]
{
println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN/lib");
println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN/../lib");
}
return;
}
// Option 2: Static linking
if let Ok(static_path) = std::env::var("LIBKRUN_STATIC") {
let path = std::path::Path::new(&static_path);
if path.is_dir() {
println!("cargo:rustc-link-search=native={}", static_path);
} else if path.is_file() {
if let Some(dir) = path.parent() {
println!("cargo:rustc-link-search=native={}", dir.display());
}
} else {
panic!("LIBKRUN_STATIC path does not exist: {}", static_path);
}
println!("cargo:rustc-link-lib=static=krun");
// Static libkrun requires these frameworks on macOS
#[cfg(target_os = "macos")]
{
println!("cargo:rustc-link-lib=framework=Hypervisor");
println!("cargo:rustc-link-lib=framework=vmnet");
}
return;
}
// Option 3: Custom directory
if let Ok(dir) = std::env::var("LIBKRUN_DIR") {
println!("cargo:rustc-link-search=native={}", dir);
link_krun();
return;
}
// Option 4: Bundled libraries in lib/linux-{arch}/ (for distribution builds)
#[cfg(target_os = "linux")]
{
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
let lib_dir = format!("{}/lib/linux-{}", manifest_dir, arch);
let lib_path = std::path::Path::new(&lib_dir);
let libkrun_path = lib_path.join("libkrun.so");
// Check if the library exists and is a real library (not an LFS pointer)
if libkrun_path.exists() && !is_lfs_pointer(&libkrun_path) {
println!(
"cargo:warning=Using bundled Linux libraries from {}",
lib_dir
);
println!("cargo:rustc-link-search=native={}", lib_dir);
link_krun();
// Set rpath to find libraries relative to executable
println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN/lib");
println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN/../lib");
// Also add the source directory for development builds
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir);
return;
}
}
// Option 5: System installation via pkg-config
if pkg_config::Config::new()
.atleast_version("1.0")
.probe("libkrun")
.is_ok()
{
return;
}
// Option 6: Common installation paths
#[cfg(target_os = "macos")]
{
let paths = [
"/opt/homebrew/lib",
"/usr/local/lib",
"/opt/homebrew/opt/libkrun/lib",
"/usr/local/opt/libkrun/lib",
];
for path in paths {
if std::path::Path::new(path).join("libkrun.dylib").exists() {
println!("cargo:rustc-link-search=native={}", path);
link_krun();
// Set rpath so runtime linker can find dependencies
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", path);
return;
}
}
}
#[cfg(target_os = "linux")]
{
let paths = [
"/usr/lib",
"/usr/local/lib",
"/usr/lib64",
"/usr/local/lib64",
"/usr/lib/x86_64-linux-gnu",
"/usr/lib/aarch64-linux-gnu",
];
for path in paths {
if std::path::Path::new(path).join("libkrun.so").exists() {
println!("cargo:rustc-link-search=native={}", path);
link_krun();
return;
}
}
}
// Fallback
link_krun();
}
/// Build libkrun from the vendored submodule.
///
/// Returns the path to the directory containing the built library.
#[cfg(target_os = "macos")]
fn build_libkrun_from_submodule() -> Option<PathBuf> {
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").ok()?);
let libkrun_dir = manifest_dir.join("libkrun");
// Check if submodule exists
if !libkrun_dir.join("Cargo.toml").exists() {
println!(
"cargo:warning=libkrun submodule not found at {}. \
Run: git submodule update --init",
libkrun_dir.display()
);
return None;
}
// On macOS, libkrun embeds the init binary via include_bytes!()
// We need to ensure init/init exists (copy from init/init.krun if needed)
#[cfg(target_os = "macos")]
{
let init_dst = libkrun_dir.join("init/init");
let init_src = libkrun_dir.join("init/init.krun");
if !init_dst.exists() && init_src.exists() {
println!("cargo:warning=Copying init.krun to init for embedding...");
if let Err(e) = std::fs::copy(&init_src, &init_dst) {
println!("cargo:warning=Failed to copy init binary: {}", e);
return None;
}
} else if !init_dst.exists() {
println!("cargo:warning=init binary not found. Need init/init or init/init.krun");
return None;
}
}
// Determine profile
let profile = std::env::var("PROFILE").unwrap_or_else(|_| "release".to_string());
println!(
"cargo:warning=Building libkrun from submodule ({} build)...",
profile
);
// Build libkrun using cargo directly (make has issues on macOS)
let libkrun_manifest = libkrun_dir.join("src/libkrun/Cargo.toml");
let mut cmd = Command::new("cargo");
cmd.arg("build")
.arg("--manifest-path")
.arg(&libkrun_manifest);
if profile == "release" {
cmd.arg("--release");
}
// Auto-detect GPU support: enable if virglrenderer is installed on the host.
// GPU feature requires virglrenderer + libclang (for krun_display bindgen).
// On macOS, also needs MoltenVK for Vulkan → Metal translation.
let gpu_available = has_library("virglrenderer");
if gpu_available {
cmd.arg("--features").arg("gpu");
// krun_display uses bindgen which needs libclang.
// Help it find libclang on macOS (Homebrew LLVM).
#[cfg(target_os = "macos")]
{
let llvm_lib = std::path::Path::new("/opt/homebrew/opt/llvm/lib");
if llvm_lib.exists() {
cmd.env("LIBCLANG_PATH", llvm_lib);
}
}
println!("cargo:warning=GPU support enabled (virglrenderer found)");
}
let status = cmd.status();
match status {
Ok(s) if s.success() => {
println!("cargo:warning=libkrun built successfully");
}
Ok(s) => {
println!(
"cargo:warning=libkrun build failed with exit code: {:?}",
s.code()
);
return None;
}
Err(e) => {
println!("cargo:warning=Failed to run cargo: {}", e);
return None;
}
}
// Find the built library
let target_dir = if profile == "release" {
libkrun_dir.join("target/release")
} else {
libkrun_dir.join("target/debug")
};
#[cfg(target_os = "macos")]
let lib_name = "libkrun.dylib";
#[cfg(target_os = "linux")]
let lib_name = "libkrun.so";
if target_dir.join(lib_name).exists() {
// Copy library to smolvm's target directory for bundling
let out_dir = PathBuf::from(std::env::var("OUT_DIR").ok()?);
let lib_out_dir = out_dir.join("lib");
std::fs::create_dir_all(&lib_out_dir).ok()?;
let src = target_dir.join(lib_name);
let dst = lib_out_dir.join(lib_name);
std::fs::copy(&src, &dst).ok()?;
println!(
"cargo:warning=Copied {} to {}",
src.display(),
dst.display()
);
// On macOS, change the install_name to use @rpath so the binary finds
// the bundled library instead of a system-installed one
#[cfg(target_os = "macos")]
{
let install_name = format!("@rpath/{}", lib_name);
let status = Command::new("install_name_tool")
.args(["-id", &install_name, dst.to_str().unwrap()])
.status();
if let Ok(s) = status {
if s.success() {
println!("cargo:warning=Set install_name to {}", install_name);
// Re-sign after modification
let _ = Command::new("codesign")
.args(["--force", "--sign", "-", dst.to_str().unwrap()])
.status();
}
}
}
// Tell cargo to rebuild if libkrun source changes
println!(
"cargo:rerun-if-changed={}",
libkrun_dir.join("src").display()
);
println!(
"cargo:rerun-if-changed={}",
libkrun_dir.join("init").display()
);
Some(lib_out_dir)
} else {
println!(
"cargo:warning=Built library not found at {}",
target_dir.join(lib_name).display()
);
None
}
}
+46
View File
@@ -0,0 +1,46 @@
[package]
name = "smolvm-agent"
version = "1.5.2"
edition = "2021"
description = "Guest agent for smolvm VMs (handles OCI operations and command execution)"
license = "Apache-2.0"
repository = "https://github.com/smol-machines/smolvm"
[[bin]]
name = "smolvm-agent"
path = "src/main.rs"
[dependencies]
smolvm-protocol = { path = "../smolvm-protocol", version = "1.5.2" }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
lazy_static = "1.4"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
libc = "0.2"
parking_lot = "0.12"
tempfile = "3"
# Read OCI layer tars in-process so extraction can apply overlayfs whiteouts,
# and decompress them in-process too: layers may be gzip- OR zstd-compressed
# (`skopeo`/`smolvm pack` default to zstd), and the guest rootfs ships no zstd
# tool — so the old external `gunzip`-only path silently broke every zstd layer.
tar = "0.4"
# gzip via `flate2` (defaults to the pure-Rust `miniz_oxide` backend) and zstd
# via the pure-Rust `ruzstd` decoder. The C-backed `zstd` crate can't link
# against musl (its zstd-sys references glibc's `__memcpy_chk`) and the agent is
# a static-musl binary, so runtime decompression must be pure Rust.
flate2 = "1"
ruzstd = "0.7"
# `zstd` (C-backed) is used ONLY to encode fixtures in unit tests; dev-deps are
# excluded from the musl release binary, so it never hits the link issue above.
[dev-dependencies]
zstd = "0.13"
# Linux-specific dependencies for vsock
[target.'cfg(target_os = "linux")'.dependencies]
nix = { version = "0.27", features = ["socket", "fs"] }
# The new mount API (fsopen/fsconfig/fsmount/move_mount) for overlay mounts — lets
# us append each lower layer via `lowerdir+` instead of shelling out to `mount(8)`,
# whose `-o lowerdir=` value is capped at ~255 bytes. Already in the workspace lock.
rustix = { version = "1", features = ["mount", "fs"] }
+356
View File
@@ -0,0 +1,356 @@
//! Crun OCI runtime command builder.
//!
//! This module provides a consistent interface for invoking crun commands
//! with the correct configuration (cgroup-manager, etc.).
use std::path::Path;
use std::process::{Command, Stdio};
use crate::paths;
/// Default PATH for container execution.
///
/// This is passed explicitly when using `crun exec --env` because crun doesn't
/// preserve the container's PATH for command lookup when custom env vars are set.
pub const DEFAULT_CONTAINER_PATH: &str =
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
/// Ensure PATH is included in environment variables for crun exec.
///
/// When crun exec is called with `--env`, it doesn't search PATH for executables
/// unless PATH is explicitly set. This function ensures PATH is always present.
fn ensure_path_in_env(env: &[(String, String)]) -> Vec<(String, String)> {
let has_path = env.iter().any(|(k, _)| k == "PATH");
if has_path {
env.to_vec()
} else {
let mut result = env.to_vec();
result.push(("PATH".to_string(), DEFAULT_CONTAINER_PATH.to_string()));
result
}
}
/// Builder for crun commands with consistent configuration.
///
/// This ensures all crun invocations use the same cgroup-manager setting
/// and other common options.
pub struct CrunCommand {
cmd: Command,
/// Trailing positional arguments (e.g. the container id for `crun run`, or
/// the container id followed by the command for `crun exec`). Appended at
/// the very end in `spawn`/`output`/`status` so options added later (e.g.
/// `--console-socket` via `console_socket()`) still land before them.
pending_positionals: Vec<String>,
}
impl CrunCommand {
/// Create a new crun command with standard configuration.
///
/// Uses `--root` to store container state on the persistent storage disk
/// instead of the default `/run/crun`, which may not be writable when the
/// rootfs is an overlayfs with an initramfs lower layer.
fn new() -> Self {
let mut cmd = Command::new(paths::CRUN_PATH);
cmd.args(["--root", paths::CRUN_ROOT_DIR]);
cmd.args(["--cgroup-manager", paths::CRUN_CGROUP_MANAGER]);
Self {
cmd,
pending_positionals: Vec::new(),
}
}
/// Create a container: `crun create --bundle <path> <id>`
///
/// This puts the container in "created" state, ready for `crun start`.
/// Stdio defaults to null because capturing pipes can block when child
/// processes inherit file descriptors.
pub fn create(bundle_dir: &Path, container_id: &str) -> Self {
let mut c = Self::new();
c.cmd.args([
"create",
"--bundle",
&bundle_dir.to_string_lossy(),
container_id,
]);
c.cmd.stdin(Stdio::null());
c.cmd.stdout(Stdio::null());
c.cmd.stderr(Stdio::null());
c
}
/// Run a container: `crun run [options] --bundle <path> <id>`
///
/// Creates, starts, waits, and deletes the container in one operation.
/// The container id is deferred so later builder calls (e.g.
/// `console_socket`) can still insert options before the positional.
pub fn run(bundle_dir: &Path, container_id: &str) -> Self {
let mut c = Self::new();
c.cmd
.args(["run", "--bundle", &bundle_dir.to_string_lossy()]);
c.pending_positionals = vec![container_id.to_string()];
c
}
/// Run a container with its console handed back over a socket:
/// `crun run --bundle <path> --console-socket <sock> <id>`.
///
/// crun creates the container's PTY and sends its master fd to `console_socket`
/// (via SCM_RIGHTS), instead of relaying through crun's own stdio. This is the
/// only way the agent can own the real console — and therefore drive window
/// size / resize, which crun does not propagate in stdio-relay mode. crun's own
/// stdio is unused, so it's nulled.
pub fn run_with_console(bundle_dir: &Path, container_id: &str, console_socket: &Path) -> Self {
let mut c = Self::new();
c.cmd.args([
"run",
"--bundle",
&bundle_dir.to_string_lossy(),
"--console-socket",
&console_socket.to_string_lossy(),
container_id,
]);
c.cmd.stdin(Stdio::null());
c.cmd.stdout(Stdio::null());
// Pipe stderr so the caller can surface crun's reason if the console
// handshake fails (and it falls back to a stdio PTY).
c.cmd.stderr(Stdio::piped());
c
}
/// `crun exec --tty --console-socket <sock> [--env ...] [--cwd <wd>] <id> <cmd...>`.
/// The TTY counterpart to [`Self::exec`] that takes the console over a socket
/// so the agent owns the real PTY master (for resize). crun's stdio is nulled.
pub fn exec_with_console(
container_id: &str,
env: &[(String, String)],
command: &[String],
workdir: Option<&str>,
console_socket: &Path,
) -> Self {
let mut c = Self::new();
c.cmd.arg("exec").arg("--tty");
c.cmd.arg("--console-socket").arg(console_socket);
let env_with_path = crate::cuda::augment_exec_env(ensure_path_in_env(env));
for (key, value) in &env_with_path {
c.cmd.arg("--env").arg(format!("{}={}", key, value));
}
if let Some(wd) = workdir {
c.cmd.args(["--cwd", wd]);
}
c.cmd.arg(container_id).args(command);
c.cmd.stdin(Stdio::null());
c.cmd.stdout(Stdio::null());
c.cmd.stderr(Stdio::piped());
c
}
/// Start a container: `crun start <id>`
pub fn start(container_id: &str) -> Self {
let mut c = Self::new();
c.cmd.args(["start", container_id]);
c
}
/// Execute a command in a running container.
///
/// Supports optional working directory and TTY allocation.
/// Automatically ensures PATH is set if not provided, because crun doesn't
/// search PATH for executables when `--env` is used.
///
/// The container id and command are deferred (see `pending_positionals`) so
/// later builder calls — notably `console_socket()` for the interactive TTY
/// path — still insert their options before the positional arguments.
pub fn exec(
container_id: &str,
env: &[(String, String)],
command: &[String],
workdir: Option<&str>,
tty: bool,
) -> Self {
let mut c = Self::new();
c.cmd.arg("exec");
if tty {
c.cmd.arg("--tty");
}
// Ensure PATH is set for command lookup; forward CUDA zero-copy opt-in.
let env_with_path = crate::cuda::augment_exec_env(ensure_path_in_env(env));
for (key, value) in &env_with_path {
c.cmd.arg("--env").arg(format!("{}={}", key, value));
}
if let Some(wd) = workdir {
c.cmd.args(["--cwd", wd]);
}
c.pending_positionals = std::iter::once(container_id.to_string())
.chain(command.iter().cloned())
.collect();
c
}
/// Kill a container: `crun kill <id> <signal>`
pub fn kill(container_id: &str, signal: &str) -> Self {
let mut c = Self::new();
c.cmd.args(["kill", container_id, signal]);
c
}
/// Delete a container: `crun delete [-f] <id>`
pub fn delete(container_id: &str, force: bool) -> Self {
let mut c = Self::new();
if force {
c.cmd.args(["delete", "-f", container_id]);
} else {
c.cmd.args(["delete", container_id]);
}
c
}
/// Get container state: `crun state <id>`
pub fn state(container_id: &str) -> Self {
let mut c = Self::new();
c.cmd.args(["state", container_id]);
c
}
/// Set stdin to null.
pub fn stdin_null(mut self) -> Self {
self.cmd.stdin(Stdio::null());
self
}
/// Set stdin to piped.
pub fn stdin_piped(mut self) -> Self {
self.cmd.stdin(Stdio::piped());
self
}
/// Set stdin from a raw fd (e.g., PTY slave).
///
/// # Safety
/// The fd must be a valid open file descriptor. Ownership is transferred.
#[cfg(unix)]
pub unsafe fn stdin_from_fd(mut self, fd: std::os::unix::io::RawFd) -> Self {
use std::os::unix::io::FromRawFd;
self.cmd.stdin(Stdio::from_raw_fd(fd));
self
}
/// Set stdout from a raw fd (e.g., PTY slave).
///
/// # Safety
/// The fd must be a valid open file descriptor. Ownership is transferred.
#[cfg(unix)]
pub unsafe fn stdout_from_fd(mut self, fd: std::os::unix::io::RawFd) -> Self {
use std::os::unix::io::FromRawFd;
self.cmd.stdout(Stdio::from_raw_fd(fd));
self
}
/// Set stderr from a raw fd (e.g., PTY slave).
///
/// # Safety
/// The fd must be a valid open file descriptor. Ownership is transferred.
#[cfg(unix)]
pub unsafe fn stderr_from_fd(mut self, fd: std::os::unix::io::RawFd) -> Self {
use std::os::unix::io::FromRawFd;
self.cmd.stderr(Stdio::from_raw_fd(fd));
self
}
/// Capture stdout.
pub fn stdout_piped(mut self) -> Self {
self.cmd.stdout(Stdio::piped());
self
}
/// Capture stderr.
pub fn stderr_piped(mut self) -> Self {
self.cmd.stderr(Stdio::piped());
self
}
/// Capture both stdout and stderr.
pub fn capture_output(self) -> Self {
self.stdout_piped().stderr_piped()
}
/// Discard both stdout and stderr.
pub fn discard_output(mut self) -> Self {
self.cmd.stdout(Stdio::null());
self.cmd.stderr(Stdio::null());
self
}
/// Append any deferred positional arguments right before the command is
/// launched, so options added by the caller (e.g. `--console-socket`) land
/// before them.
fn apply_pending(&mut self) {
for arg in std::mem::take(&mut self.pending_positionals) {
self.cmd.arg(arg);
}
}
/// Spawn the command.
pub fn spawn(mut self) -> std::io::Result<std::process::Child> {
self.apply_pending();
self.cmd.spawn()
}
/// Run and wait for output.
pub fn output(mut self) -> std::io::Result<std::process::Output> {
self.apply_pending();
self.cmd.output()
}
/// Run and wait for status.
pub fn status(mut self) -> std::io::Result<std::process::ExitStatus> {
self.apply_pending();
self.cmd.status()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_container_path_value() {
assert!(DEFAULT_CONTAINER_PATH.contains("/usr/bin"));
assert!(DEFAULT_CONTAINER_PATH.contains("/bin"));
}
#[test]
fn test_ensure_path_in_env_adds_path_when_missing() {
let env = vec![("HOME".to_string(), "/root".to_string())];
let result = ensure_path_in_env(&env);
assert_eq!(result.len(), 2);
assert!(result
.iter()
.any(|(k, v)| k == "PATH" && v == DEFAULT_CONTAINER_PATH));
}
#[test]
fn test_ensure_path_in_env_preserves_existing_path() {
let custom_path = "/custom/bin:/other/bin";
let env = vec![("PATH".to_string(), custom_path.to_string())];
let result = ensure_path_in_env(&env);
assert_eq!(result.len(), 1);
assert_eq!(result[0], ("PATH".to_string(), custom_path.to_string()));
}
#[test]
fn test_ensure_path_in_env_empty_input() {
let env: Vec<(String, String)> = vec![];
let result = ensure_path_in_env(&env);
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "PATH");
}
#[test]
fn test_ensure_path_in_env_case_sensitive() {
// "path" (lowercase) should not be treated as PATH
let env = vec![("path".to_string(), "/lowercase".to_string())];
let result = ensure_path_in_env(&env);
assert_eq!(result.len(), 2);
assert!(result.iter().any(|(k, _)| k == "PATH"));
}
}
+291
View File
@@ -0,0 +1,291 @@
//! Guest-side CUDA forwarding wiring for the workload container.
//!
//! When a VM is launched with `--cuda`, the launcher sets `SMOLVM_CUDA_ZEROCOPY`
//! in the agent's (PID 1) environment. The workload container gets its env from
//! the image plus the request — not from the agent's own env — so the zero-copy
//! opt-in has to be forwarded into the container spec explicitly, the same way
//! [`crate::ssh_agent`] forwards `SSH_AUTH_SOCK`.
//!
//! With the flag set, the guest CUDA shim (`libcudart.so`) backs
//! `cudaHostAlloc`/`cudaMallocHost` with page-locked guest RAM whose
//! guest-physical frames it reads from `/proc/self/pagemap`, so a memcpy ships
//! only a guest-physical descriptor and the host DMAs straight from guest RAM.
//! It degrades to byte-shipping wherever that path is unavailable (no
//! `CAP_SYS_ADMIN`, older libkrun), so forwarding it is always safe.
/// The env var the launcher sets on the agent, and that the guest shim reads.
const ZEROCOPY_ENV: &str = "SMOLVM_CUDA_ZEROCOPY";
/// Whether CUDA guest-RAM zero-copy was requested for this VM.
pub fn zerocopy_enabled() -> bool {
std::env::var(ZEROCOPY_ENV).as_deref() == Ok("1")
}
/// Where the guest CUDA shims ship inside the VM rootfs (from the agent
/// rootfs). Absent on builds without CUDA shim bundling — every staging step
/// degrades to a no-op so `--cuda` still works in the manual-setup mode.
const GUEST_SHIM_DIR: &str = "/usr/local/lib/smolvm-cuda";
/// Where the shim dir is bind-mounted inside the workload container, and put
/// on `LD_LIBRARY_PATH` so the loader finds `libcuda.so.1` there.
const CONTAINER_SHIM_DIR: &str = "/opt/smolvm-cuda";
/// The runtime shim file inside [`GUEST_SHIM_DIR`] (one cdylib exports the
/// whole cudart/cuBLAS/cuBLASLt/cuDNN surface; it is staged under each soname).
const RUNTIME_SHIM: &str = "libcudart-shim.so";
/// The driver shim (`libcuda.so.1`) inside [`GUEST_SHIM_DIR`].
const DRIVER_SHIM: &str = "libcuda.so.1";
/// The pip-bundled NVIDIA sonames PyTorch's `libtorch_cuda.so` resolves via
/// DT_RPATH. RPATH is searched *before* `LD_LIBRARY_PATH`, so the only way to
/// interpose is to place the shim at these exact paths (a read-only bind mount
/// over each file). CUDA 12 and 11 wheel layouts.
const RPATH_PINNED_SONAMES: &[&str] = &[
"libcudart.so.12",
"libcublas.so.12",
"libcublasLt.so.12",
"libcudnn.so.9",
"libcudart.so.11.0",
"libcublas.so.11",
"libcublasLt.so.11",
"libcudnn.so.8",
];
/// Forward the CUDA opt-in into the workload container spec and, when the
/// guest shims are bundled, stage them so an unmodified PyTorch works with no
/// user setup: the shim dir rides `LD_LIBRARY_PATH` (covers `libcuda.so.1`,
/// which nothing RPATH-pins) and the runtime shim is bind-mounted over each
/// pip-bundled NVIDIA library found in the image rootfs (RPATH-pinned, so env
/// vars can't reach them). Used on the fresh-container path (`crun run`/
/// `create`). No-op unless CUDA was requested.
pub fn inject_into_container(spec: &mut crate::oci::OciSpec, rootfs: &std::path::Path) {
inject_into_container_if(spec, rootfs, zerocopy_enabled());
}
/// Testable core of [`inject_into_container`].
fn inject_into_container_if(
spec: &mut crate::oci::OciSpec,
rootfs: &std::path::Path,
enabled: bool,
) {
if !enabled {
return;
}
spec.add_env(ZEROCOPY_ENV, "1");
stage_shims(spec, rootfs, std::path::Path::new(GUEST_SHIM_DIR));
}
/// Bind-mount the bundled shims into the container. Split from the gate so
/// tests can point `shim_dir` at a fixture.
fn stage_shims(
spec: &mut crate::oci::OciSpec,
rootfs: &std::path::Path,
shim_dir: &std::path::Path,
) {
let runtime = shim_dir.join(RUNTIME_SHIM);
let driver = shim_dir.join(DRIVER_SHIM);
if !runtime.is_file() || !driver.is_file() {
return; // shims not bundled — manual staging still works
}
// Driver shim: the whole shim dir at a stable path + LD_LIBRARY_PATH.
// `libcuda.so.1` is resolved through the normal loader search (no RPATH in
// the way), and this also lets users link the runtime shim directly.
spec.add_bind_mount(&shim_dir.to_string_lossy(), CONTAINER_SHIM_DIR, true);
append_ld_library_path(&mut spec.process.env, CONTAINER_SHIM_DIR);
// Runtime shim over each RPATH-pinned pip-bundled NVIDIA library.
let runtime_src = runtime.to_string_lossy();
for hit in find_rpath_pinned_libs(rootfs) {
let dest = format!(
"/{}",
hit.strip_prefix(rootfs).unwrap_or(&hit).to_string_lossy()
);
spec.add_bind_mount(&runtime_src, &dest, true);
}
}
/// Append `dir` to the spec's `LD_LIBRARY_PATH`, preserving an image-provided
/// value; creates the variable if absent, skips if already present.
fn append_ld_library_path(env: &mut Vec<String>, dir: &str) {
for e in env.iter_mut() {
if let Some(v) = e.strip_prefix("LD_LIBRARY_PATH=") {
if v.split(':').any(|p| p == dir) {
return;
}
*e = format!("LD_LIBRARY_PATH={v}:{dir}");
return;
}
}
env.push(format!("LD_LIBRARY_PATH={dir}"));
}
/// Find pip-bundled NVIDIA libraries in the image rootfs: files named like the
/// RPATH-pinned sonames under a `site-packages`/`dist-packages` → `nvidia`
/// wheel layout. Bounded walk: skips pseudo-filesystems and never follows
/// symlinks (wheel layouts don't use them and cycles would hang the boot).
fn find_rpath_pinned_libs(rootfs: &std::path::Path) -> Vec<std::path::PathBuf> {
const SKIP_TOP: &[&str] = &["proc", "sys", "dev", "run", "tmp", "boot"];
const MAX_DEPTH: usize = 16;
let mut hits = Vec::new();
let mut stack: Vec<(std::path::PathBuf, usize)> = vec![(rootfs.to_path_buf(), 0)];
while let Some((dir, depth)) = stack.pop() {
if depth >= MAX_DEPTH {
continue;
}
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let ft = match entry.file_type() {
Ok(t) => t,
Err(_) => continue,
};
let name = entry.file_name();
let name = name.to_string_lossy();
if ft.is_dir() && !ft.is_symlink() {
if depth == 0 && SKIP_TOP.contains(&name.as_ref()) {
continue;
}
stack.push((entry.path(), depth + 1));
} else if ft.is_file() && RPATH_PINNED_SONAMES.contains(&name.as_ref()) {
let p = entry.path();
let s = p.to_string_lossy();
if s.contains("/nvidia/")
&& (s.contains("/site-packages/") || s.contains("/dist-packages/"))
{
hits.push(p);
}
}
}
}
hits.sort();
hits
}
/// Append the CUDA opt-in (and the shim dir's loader path) to an explicit exec
/// env when enabled. Used on the `crun exec` path (joining a persistent
/// machine's keep-alive container), where the workload env is passed via
/// `--env` rather than inherited from the container spec, so the spec injection
/// above doesn't reach it. The bind mounts themselves were established when the
/// keep-alive container was created. No-op when disabled or already present.
pub fn augment_exec_env(mut env: Vec<(String, String)>) -> Vec<(String, String)> {
if !zerocopy_enabled() {
return env;
}
if !env.iter().any(|(k, _)| k == ZEROCOPY_ENV) {
env.push((ZEROCOPY_ENV.to_string(), "1".to_string()));
}
match env.iter_mut().find(|(k, _)| k == "LD_LIBRARY_PATH") {
Some((_, v)) => {
if !v.split(':').any(|p| p == CONTAINER_SHIM_DIR) {
*v = format!("{v}:{CONTAINER_SHIM_DIR}");
}
}
None => env.push((
"LD_LIBRARY_PATH".to_string(),
CONTAINER_SHIM_DIR.to_string(),
)),
}
env
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oci::{OciSpec, ProcessIdentity};
fn spec() -> OciSpec {
OciSpec::new(
&["true".to_string()],
&[],
"/",
false,
&ProcessIdentity::root(),
false,
)
}
#[test]
fn injects_when_enabled() {
let mut s = spec();
inject_into_container_if(&mut s, std::path::Path::new("/nonexistent"), true);
assert!(s.process.env.iter().any(|e| e == "SMOLVM_CUDA_ZEROCOPY=1"));
}
#[test]
fn noop_when_disabled() {
let mut s = spec();
inject_into_container_if(&mut s, std::path::Path::new("/nonexistent"), false);
assert!(!s
.process
.env
.iter()
.any(|e| e.starts_with("SMOLVM_CUDA_ZEROCOPY")));
}
#[test]
fn augment_exec_env_is_idempotent() {
let base = vec![("SMOLVM_CUDA_ZEROCOPY".to_string(), "1".to_string())];
// Gate off in tests (env var unset) → unchanged.
assert_eq!(augment_exec_env(base.clone()), base);
}
#[test]
fn ld_library_path_append_preserves_existing() {
let mut env = vec!["LD_LIBRARY_PATH=/usr/local/nvidia/lib".to_string()];
append_ld_library_path(&mut env, CONTAINER_SHIM_DIR);
assert_eq!(
env[0],
format!("LD_LIBRARY_PATH=/usr/local/nvidia/lib:{CONTAINER_SHIM_DIR}")
);
// Idempotent.
append_ld_library_path(&mut env, CONTAINER_SHIM_DIR);
assert_eq!(env.len(), 1);
assert_eq!(env[0].matches(CONTAINER_SHIM_DIR).count(), 1);
}
#[test]
fn ld_library_path_created_when_absent() {
let mut env = vec!["PATH=/usr/bin".to_string()];
append_ld_library_path(&mut env, CONTAINER_SHIM_DIR);
assert!(env.contains(&format!("LD_LIBRARY_PATH={CONTAINER_SHIM_DIR}")));
}
#[test]
fn finds_and_overmounts_wheel_libs() {
let tmp = std::env::temp_dir().join(format!("cuda-stage-test-{}", std::process::id()));
let rootfs = tmp.join("rootfs");
let wheel = rootfs.join("usr/lib/python3.11/site-packages/nvidia/cublas/lib");
std::fs::create_dir_all(&wheel).unwrap();
std::fs::write(wheel.join("libcublas.so.12"), b"real").unwrap();
std::fs::write(wheel.join("libnvblas.so.12"), b"other").unwrap(); // not pinned
// A same-named file outside a wheel layout must NOT match.
let stray = rootfs.join("opt/other");
std::fs::create_dir_all(&stray).unwrap();
std::fs::write(stray.join("libcublas.so.12"), b"stray").unwrap();
let hits = find_rpath_pinned_libs(&rootfs);
assert_eq!(hits.len(), 1);
assert!(hits[0].ends_with("nvidia/cublas/lib/libcublas.so.12"));
// With a shim fixture present, staging adds the dir mount + overmount.
let shim_dir = tmp.join("shims");
std::fs::create_dir_all(&shim_dir).unwrap();
std::fs::write(shim_dir.join(RUNTIME_SHIM), b"shim").unwrap();
std::fs::write(shim_dir.join(DRIVER_SHIM), b"shim").unwrap();
let mut s = spec();
stage_shims(&mut s, &rootfs, &shim_dir);
assert!(s.mounts.iter().any(|m| m.destination == CONTAINER_SHIM_DIR));
assert!(s.mounts.iter().any(|m| m
.destination
.ends_with("nvidia/cublas/lib/libcublas.so.12")
&& m.source.ends_with(RUNTIME_SHIM)));
assert!(s
.process
.env
.iter()
.any(|e| e.starts_with("LD_LIBRARY_PATH=") && e.contains(CONTAINER_SHIM_DIR)));
std::fs::remove_dir_all(&tmp).ok();
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Guest-side DNS filtering proxy.
//!
//! Listens on `127.0.0.1:53` (both UDP and TCP) inside the VM and forwards
//! raw DNS packets to the host via vsock for filtering. The host decides
//! whether to resolve the query (domain is in the allowlist) or return
//! NXDOMAIN.
//!
//! This is a dumb bridge — no DNS parsing happens in the agent. The host
//! does all filtering, keeping the agent binary small.
//!
//! TCP/53 is required for:
//! - Responses that exceed 512 bytes (TC bit set in the UDP reply)
//! - Clients that prefer TCP (Go's `net` resolver, some systemd-resolved configs)
//!
//! Framing over the vsock stream (stream-oriented, so we need packet
//! boundaries):
//! [2-byte BE length] [raw DNS packet bytes]
//!
//! The host responds with the same framing. This matches the standard TCP DNS
//! wire format (RFC 1035 §4.2.2) exactly, so no extra wrapping is needed for
//! the TCP path.
use smolvm_protocol::guest_env;
use smolvm_protocol::ports;
use std::io::{self, Read, Write};
use std::net::{TcpListener, UdpSocket};
use std::thread;
/// Maximum DNS packet size over UDP (RFC 1035 §2.3.4).
const MAX_DNS_UDP_PACKET: usize = 512;
/// Maximum DNS response size over TCP (2-byte length field limit).
const MAX_DNS_TCP_PACKET: usize = 65535;
/// Start the guest-side DNS proxy in background threads.
///
/// Spawns one thread for UDP/53 and one for TCP/53. Both reuse the same
/// `forward_to_host` path — the vsock channel framing is identical for both
/// protocols since TCP DNS already uses 2-byte length prefixes.
///
/// Rewrites `/etc/resolv.conf` to point to localhost so all DNS queries
/// from guest applications go through this proxy.
pub fn start() {
// Rewrite resolv.conf to route DNS through our proxy
if let Err(e) = std::fs::write("/etc/resolv.conf", "nameserver 127.0.0.1\n") {
tracing::warn!(error = %e, "failed to rewrite /etc/resolv.conf for DNS filtering");
return;
}
thread::Builder::new()
.name("dns-proxy-udp".into())
.spawn(|| {
if let Err(e) = run_udp_proxy() {
tracing::warn!(error = %e, "guest UDP DNS proxy stopped");
}
})
.ok();
thread::Builder::new()
.name("dns-proxy-tcp".into())
.spawn(|| {
if let Err(e) = run_tcp_proxy() {
tracing::warn!(error = %e, "guest TCP DNS proxy stopped");
}
})
.ok();
}
/// Check if DNS filtering is enabled via environment variable.
/// The host sets this when `--allow-host` is used.
pub fn is_enabled() -> bool {
std::env::var(guest_env::DNS_FILTER).as_deref() == Ok("1")
}
fn run_udp_proxy() -> io::Result<()> {
let udp = UdpSocket::bind("127.0.0.1:53")?;
tracing::info!("guest DNS proxy listening on UDP 127.0.0.1:53");
let mut buf = [0u8; MAX_DNS_UDP_PACKET];
loop {
let (len, src_addr) = udp.recv_from(&mut buf)?;
if len == 0 {
continue;
}
let response = match forward_to_host(&buf[..len]) {
Ok(resp) => resp,
Err(e) => {
tracing::debug!(error = %e, "DNS forward to host failed, returning SERVFAIL");
build_servfail(&buf[..len])
}
};
if let Err(e) = udp.send_to(&response, src_addr) {
tracing::debug!(error = %e, "failed to send DNS response");
}
}
}
fn run_tcp_proxy() -> io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:53")?;
tracing::info!("guest DNS proxy listening on TCP 127.0.0.1:53");
for stream in listener.incoming() {
match stream {
Ok(mut stream) => {
thread::spawn(move || {
if let Err(e) = handle_tcp_connection(&mut stream) {
tracing::debug!(error = %e, "TCP DNS connection error");
}
});
}
Err(e) => tracing::debug!(error = %e, "TCP DNS accept error"),
}
}
Ok(())
}
/// Handle a single TCP DNS connection.
///
/// RFC 1035 §4.2.2 allows multiple queries on a single TCP connection
/// (pipelining). Each query is length-prefixed with a 2-byte BE value,
/// matching our vsock framing exactly — `forward_to_host` is used unchanged.
fn handle_tcp_connection(stream: &mut (impl Read + Write)) -> io::Result<()> {
loop {
let mut len_buf = [0u8; 2];
match stream.read_exact(&mut len_buf) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(e),
}
let query_len = u16::from_be_bytes(len_buf) as usize;
if query_len == 0 {
break;
}
let mut query = vec![0u8; query_len];
stream.read_exact(&mut query)?;
let response = match forward_to_host(&query) {
Ok(resp) => resp,
Err(e) => {
tracing::debug!(error = %e, "DNS forward to host failed, returning SERVFAIL");
build_servfail(&query)
}
};
let resp_len = response.len() as u16;
stream.write_all(&resp_len.to_be_bytes())?;
stream.write_all(&response)?;
stream.flush()?;
}
Ok(())
}
/// Forward a raw DNS packet to the host via vsock and return the response.
///
/// One vsock connection per query. The host may return a response larger than
/// 512 bytes if it performed a TCP retry on the guest's behalf (TC-bit path),
/// so the cap is the protocol maximum (65535) for both UDP and TCP callers.
fn forward_to_host(query: &[u8]) -> io::Result<Vec<u8>> {
let mut stream = super::vsock::connect(ports::DNS_FILTER)?;
// Send: [2-byte BE length] [query bytes]
let len = query.len() as u16;
stream.write_all(&len.to_be_bytes())?;
stream.write_all(query)?;
stream.flush()?;
// Receive: [2-byte BE length] [response bytes]
let mut len_buf = [0u8; 2];
stream.read_exact(&mut len_buf)?;
let resp_len = u16::from_be_bytes(len_buf) as usize;
if resp_len > MAX_DNS_TCP_PACKET {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("DNS response too large: {resp_len}"),
));
}
let mut resp = vec![0u8; resp_len];
stream.read_exact(&mut resp)?;
Ok(resp)
}
/// Build a minimal SERVFAIL response from a query.
/// Copies the query ID and question, sets the SERVFAIL rcode.
fn build_servfail(query: &[u8]) -> Vec<u8> {
if query.len() < 12 {
return vec![];
}
let mut resp = query.to_vec();
// Set QR bit (response), keep opcode, set RCODE=2 (SERVFAIL)
resp[2] = 0x80 | (resp[2] & 0x78); // QR=1, preserve opcode
resp[3] = (resp[3] & 0xF0) | 0x02; // RCODE=SERVFAIL
// Zero answer/authority/additional counts
resp[6..12].fill(0);
resp
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_servfail_preserves_id() {
// Minimal DNS query header (12 bytes)
let query = vec![
0xAB, 0xCD, // ID
0x01, 0x00, // Flags: standard query
0x00, 0x01, // QDCOUNT: 1
0x00, 0x00, // ANCOUNT: 0
0x00, 0x00, // NSCOUNT: 0
0x00, 0x00, // ARCOUNT: 0
];
let resp = build_servfail(&query);
assert_eq!(resp[0], 0xAB); // ID preserved
assert_eq!(resp[1], 0xCD);
assert_eq!(resp[2] & 0x80, 0x80); // QR=1 (response)
assert_eq!(resp[3] & 0x0F, 0x02); // RCODE=SERVFAIL
}
#[test]
fn test_build_servfail_short_query() {
let resp = build_servfail(&[0x00, 0x01]);
assert!(resp.is_empty());
}
}
File diff suppressed because it is too large Load Diff
+702
View File
@@ -0,0 +1,702 @@
//! Linux network configuration helpers for a given host NIC.
use std::net::{Ipv4Addr, Ipv6Addr};
/// `IFA_F_NODAD` from `linux/if_addr.h`: skip Duplicate Address Detection for a
/// statically assigned IPv6 address. On the two-node virtual link the addresses
/// are deterministic, and skipping DAD avoids the ~1s "tentative" window before
/// the guest can use its IPv6 address.
const IFA_F_NODAD: u8 = 0x02;
/// Configure a guest interface for the virtio-net.
///
/// High-level flow:
///
/// ```text
/// 1. find eth0's ifindex (SIOCGIFINDEX)
/// 2. set link-layer MAC address (SIOCSIFHWADDR)
/// 3. set MTU (SIOCSIFMTU)
/// 4. add IPv4 address/prefix (RTM_NEWADDR via NETLINK_ROUTE)
/// 5. mark the interface UP (SIOCGIFFLAGS + SIOCSIFFLAGS)
/// 6. add the default route (RTM_NEWROUTE via NETLINK_ROUTE)
/// 7. write /etc/resolv.conf (plain file write)
/// ```
///
/// Read these functions as:
///
/// ```text
/// set_mac_address() ~= ip link set dev eth0 address ...
/// set_mtu() ~= ip link set dev eth0 mtu ...
/// add_address_v4() ~= ip addr add ...
/// bring_interface_up()~= ip link set dev eth0 up
/// add_default_route() ~= ip route add default via ...
/// ```
///
/// Outcome:
/// - the guest ends up with a configured `eth0`
/// - traffic to non-local destinations is sent to `gateway`
/// - libc DNS resolution uses `dns_server`
///
/// Why this order:
/// - MAC and MTU are link attributes, so we set them before bringing the
/// interface fully up
/// - the IPv4 address and route are programmed explicitly instead of relying on
/// DHCP or helper tools
/// - the function is fail-fast: any kernel call failure aborts boot for the
/// requested virtio-net path
/// - the address is installed before the route so the kernel already knows the
/// guest's on-link subnet when the default gateway is added
///
/// The Linux kernel interfaces used here are all C ABI calls:
///
/// - `SIOCGIFINDEX`: asks the kernel for the numeric interface index for
/// `ifname`. Netlink messages use that numeric id, not the human-readable
/// string.
/// - `SIOCSIFHWADDR`: updates the NIC MAC address.
/// - `SIOCSIFMTU`: updates the NIC MTU.
/// - `SIOCGIFFLAGS` / `SIOCSIFFLAGS`: read-modify-write the interface flags so
/// we can set `IFF_UP`.
/// - `RTM_NEWADDR`: asks the kernel routing stack to add an IPv4 address.
/// - `RTM_NEWROUTE`: asks the kernel routing stack to install the default route.
#[allow(clippy::too_many_arguments)]
pub fn configure_interface(
ifname: &str,
mac: [u8; 6],
mtu: u16,
address: Ipv4Addr,
prefix_len: u8,
gateway: Ipv4Addr,
ipv6: Option<(Ipv6Addr, u8, Ipv6Addr)>,
dns_server: Ipv4Addr,
) -> Result<(), String> {
let ifindex = get_ifindex(ifname)?;
set_mac_address(ifname, &mac)?;
set_mtu(ifname, mtu)?;
add_address_v4(ifindex, address, prefix_len)?;
bring_interface_up(ifname)?;
add_default_route_v4(gateway)?;
if let Some((address6, prefix_len6, gateway6)) = ipv6 {
add_address_v6(ifindex, address6, prefix_len6)?;
add_default_route_v6(gateway6)?;
}
write_resolv_conf(dns_server)?;
Ok(())
}
/// Resolve the Linux interface index used by rtnetlink messages.
///
/// C ABI context:
/// - opens an `AF_INET` datagram socket only as an ioctl handle
/// - fills an `ifreq`
/// - calls `ioctl(..., SIOCGIFINDEX, ...)`
///
/// Usage:
/// - `ifname` is the human-readable name, such as `eth0`
///
/// Outcome:
/// - returns the kernel's numeric interface id, which is later embedded into
/// `RTM_NEWADDR`
///
/// Why this exists:
/// - `ioctl` interface operations identify the device by name in `ifreq`
/// - rtnetlink address operations identify the device by numeric index
/// - this is the bridge between those two APIs
fn get_ifindex(ifname: &str) -> Result<u32, String> {
// SAFETY: `ifreq` is plain old data; zeroed initialization is valid.
unsafe {
let mut ifr: libc::ifreq = std::mem::zeroed();
copy_ifname(&mut ifr, ifname)?;
let sock = socket_fd()?;
if libc::ioctl(sock, libc::SIOCGIFINDEX as _, &mut ifr) < 0 {
let err = std::io::Error::last_os_error();
libc::close(sock);
return Err(format!("SIOCGIFINDEX failed for {}: {}", ifname, err));
}
libc::close(sock);
Ok(ifr.ifr_ifru.ifru_ifindex as u32)
}
}
/// Program the NIC MAC address with `SIOCSIFHWADDR`.
///
/// C ABI context:
/// - `ifreq.ifru_hwaddr` carries a `sockaddr`-shaped payload
/// - `sa_family = ARPHRD_ETHER` tells the kernel the address is Ethernet
/// - the first 6 bytes of `sa_data` hold the MAC octets
///
/// Outcome:
/// - future packets emitted by this guest NIC use the requested source MAC
///
/// Shell equivalent:
///
/// ```text
/// ip link set dev <ifname> address <mac>
/// ```
fn set_mac_address(ifname: &str, mac: &[u8; 6]) -> Result<(), String> {
// SAFETY: `ifreq` is plain old data; zeroed initialization is valid.
unsafe {
let mut ifr: libc::ifreq = std::mem::zeroed();
copy_ifname(&mut ifr, ifname)?;
ifr.ifr_ifru.ifru_hwaddr.sa_family = libc::ARPHRD_ETHER;
ifr.ifr_ifru.ifru_hwaddr.sa_data[..6]
.copy_from_slice(&mac.map(|byte| byte as libc::c_char));
let sock = socket_fd()?;
if libc::ioctl(sock, libc::SIOCSIFHWADDR as _, &ifr) < 0 {
let err = std::io::Error::last_os_error();
libc::close(sock);
return Err(format!("SIOCSIFHWADDR failed for {}: {}", ifname, err));
}
libc::close(sock);
}
Ok(())
}
/// Program the link MTU with `SIOCSIFMTU`.
///
/// C ABI context:
/// - `ifreq.ifru_mtu` is interpreted by the kernel as the requested MTU value
///
/// Outcome:
/// - the kernel enforces this frame size for the interface
///
/// Shell equivalent:
///
/// ```text
/// ip link set dev <ifname> mtu <mtu>
/// ```
fn set_mtu(ifname: &str, mtu: u16) -> Result<(), String> {
// SAFETY: `ifreq` is plain old data; zeroed initialization is valid.
unsafe {
let mut ifr: libc::ifreq = std::mem::zeroed();
copy_ifname(&mut ifr, ifname)?;
ifr.ifr_ifru.ifru_mtu = mtu as libc::c_int;
let sock = socket_fd()?;
if libc::ioctl(sock, libc::SIOCSIFMTU as _, &ifr) < 0 {
let err = std::io::Error::last_os_error();
libc::close(sock);
return Err(format!("SIOCSIFMTU failed for {}: {}", ifname, err));
}
libc::close(sock);
}
Ok(())
}
/// Mark the interface `IFF_UP`.
///
/// C ABI context:
/// - `SIOCGIFFLAGS` reads the current flag word
/// - we OR in `IFF_UP`
/// - `SIOCSIFFLAGS` writes the updated flag word back
///
/// Outcome:
/// - the kernel considers the interface administratively up and will start
/// using the configured address and route
///
/// Shell equivalent:
///
/// ```text
/// ip link set dev <ifname> up
/// ```
fn bring_interface_up(ifname: &str) -> Result<(), String> {
// SAFETY: `ifreq` is plain old data; zeroed initialization is valid.
unsafe {
let mut ifr: libc::ifreq = std::mem::zeroed();
copy_ifname(&mut ifr, ifname)?;
let sock = socket_fd()?;
if libc::ioctl(sock, libc::SIOCGIFFLAGS as _, &mut ifr) < 0 {
let err = std::io::Error::last_os_error();
libc::close(sock);
return Err(format!("SIOCGIFFLAGS failed for {}: {}", ifname, err));
}
ifr.ifr_ifru.ifru_flags |= libc::IFF_UP as libc::c_short;
if libc::ioctl(sock, libc::SIOCSIFFLAGS as _, &ifr) < 0 {
let err = std::io::Error::last_os_error();
libc::close(sock);
return Err(format!("SIOCSIFFLAGS failed for {}: {}", ifname, err));
}
libc::close(sock);
}
Ok(())
}
/// Add the guest IPv4 address through rtnetlink.
///
/// This is the programmatic equivalent of:
///
/// ```text
/// ip addr add <address>/<prefix_len> dev <ifname>
/// ```
///
/// Outcome:
/// - the kernel records the IPv4 address on the interface identified by
/// `ifindex`
///
/// Why netlink here:
/// - there is no classic `ioctl` that cleanly expresses modern address
/// creation the way `ip addr add` does
/// - rtnetlink is the kernel's structured control plane for addresses and
/// routes
fn add_address_v4(ifindex: u32, address: Ipv4Addr, prefix_len: u8) -> Result<(), String> {
let address_bytes = address.octets();
netlink_newaddr(ifindex, libc::AF_INET as u8, 0, prefix_len, &address_bytes).map_err(|err| {
format!(
"failed to add IPv4 address {}/{}: {}",
address, prefix_len, err
)
})
}
/// Add the guest IPv6 address through rtnetlink (`ip -6 addr add ...`), with
/// DAD skipped (see [`IFA_F_NODAD`]).
fn add_address_v6(ifindex: u32, address: Ipv6Addr, prefix_len: u8) -> Result<(), String> {
let address_bytes = address.octets();
netlink_newaddr(
ifindex,
libc::AF_INET6 as u8,
IFA_F_NODAD,
prefix_len,
&address_bytes,
)
.map_err(|err| {
format!(
"failed to add IPv6 address {}/{}: {}",
address, prefix_len, err
)
})
}
/// Install the default IPv4 route through the provided gateway.
///
/// This is the programmatic equivalent of:
///
/// ```text
/// ip route add default via <gateway>
/// ```
///
/// Outcome:
/// - traffic for non-local destinations is sent to `gateway`
///
/// Why only a gateway attribute is needed here:
/// - the default route says "for destinations not matched by a more specific
/// route, send to this next hop"
/// - because the guest interface address was installed first, the kernel can
/// resolve that gateway as reachable on the connected subnet
fn add_default_route_v4(gateway: Ipv4Addr) -> Result<(), String> {
let gateway_bytes = gateway.octets();
netlink_newroute(libc::AF_INET as u8, &gateway_bytes)
.map_err(|err| format!("failed to add default route via {}: {}", gateway, err))
}
/// Install the default IPv6 route through the provided gateway
/// (`ip -6 route add default via ...`).
fn add_default_route_v6(gateway: Ipv6Addr) -> Result<(), String> {
let gateway_bytes = gateway.octets();
netlink_newroute(libc::AF_INET6 as u8, &gateway_bytes)
.map_err(|err| format!("failed to add default IPv6 route via {}: {}", gateway, err))
}
/// Replace `/etc/resolv.conf` with the gateway-side resolver.
///
/// Outcome:
/// - standard guest libc resolution (`getaddrinfo`, `nslookup`, etc.) sends DNS
/// traffic to the host-provided resolver path
///
/// This step is intentionally plain file I/O rather than a C networking API.
/// DNS configuration in a minimal Linux guest is usually conveyed through
/// `/etc/resolv.conf`, and that is enough for the MVP.
fn write_resolv_conf(dns_server: Ipv4Addr) -> Result<(), String> {
let contents = format!("nameserver {}\n", dns_server);
let direct_err = match std::fs::write("/etc/resolv.conf", &contents) {
Ok(()) => return Ok(()),
Err(err) => err,
};
// Read-only /etc (the Linux agent rootfs boots read-only — same trap class
// as the read-only /tmp): stage the file on the tmpfs /tmp and bind-mount
// it over /etc/resolv.conf. And in NO case may DNS config kill the boot —
// the interface, routes, and egress NAT are already up; a guest without
// resolv.conf still serves published ports and numeric egress — so any
// residual failure degrades to a WARN instead of an Err (which the caller
// treats as fatal for PID 1).
let staged = "/tmp/.smolvm-resolv.conf";
if let Err(e) = std::fs::write(staged, &contents) {
tracing::warn!(error = %e, original = %direct_err,
"resolv.conf unwritable and tmpfs staging failed; continuing without DNS config");
return Ok(());
}
let src = std::ffi::CString::new(staged).expect("static path");
let dst = std::ffi::CString::new("/etc/resolv.conf").expect("static path");
let rc = unsafe {
libc::mount(
src.as_ptr(),
dst.as_ptr(),
std::ptr::null(),
libc::MS_BIND,
std::ptr::null(),
)
};
if rc != 0 {
tracing::warn!(
error = %std::io::Error::last_os_error(),
original = %direct_err,
"resolv.conf bind-mount fallback failed; continuing without DNS config"
);
}
Ok(())
}
/// Create a datagram socket used only as an ioctl control handle.
///
/// C ABI context:
/// - many legacy interface ioctls operate on any socket fd from the right
/// address family
/// - this socket is not used for packet I/O
///
/// Outcome:
/// - returns an fd suitable for `SIOCGIFINDEX`, `SIOCSIFHWADDR`,
/// `SIOCSIFMTU`, and `SIOCSIFFLAGS`
///
/// Important distinction:
/// - this is not the data path for guest traffic
/// - it is just a capability handle the kernel accepts for network ioctls
fn socket_fd() -> Result<libc::c_int, String> {
// SAFETY: `socket` is a standard libc call with valid arguments.
let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) };
if fd < 0 {
return Err(format!(
"failed to create socket: {}",
std::io::Error::last_os_error()
));
}
Ok(fd)
}
/// Copy an interface name into `ifreq.ifr_name`.
///
/// C ABI context:
/// - most interface ioctls use `ifreq`
/// - the kernel matches the request to an interface through the fixed-width
/// `ifr_name` buffer
/// - `ifreq` was zero-initialized, so copying only the visible bytes leaves the
/// trailing NUL padding in place
fn copy_ifname(ifr: &mut libc::ifreq, ifname: &str) -> Result<(), String> {
let bytes = ifname.as_bytes();
if bytes.len() >= libc::IFNAMSIZ {
return Err(format!("interface name too long: {}", ifname));
}
// SAFETY: `ifr_name` is large enough because of the length check above.
unsafe {
std::ptr::copy_nonoverlapping(
bytes.as_ptr(),
ifr.ifr_name.as_mut_ptr().cast(),
bytes.len(),
);
}
Ok(())
}
/// Minimal Linux `ifaddrmsg` layout used by `RTM_NEWADDR`.
///
/// We define the struct locally instead of relying on higher-level helpers so
/// the agent stays self-contained in the guest environment.
///
/// Meaning of the fields we populate:
/// - `ifa_family = AF_INET`: this is an IPv4 address operation
/// - `ifa_prefixlen`: subnet length, for example `24`
/// - `ifa_scope = RT_SCOPE_UNIVERSE`: globally scoped address, not host-local
/// - `ifa_index`: target interface id returned by `SIOCGIFINDEX`
#[repr(C)]
struct IfAddrMsg {
ifa_family: u8,
ifa_prefixlen: u8,
ifa_flags: u8,
ifa_scope: u8,
ifa_index: u32,
}
/// Minimal Linux `rtmsg` layout used by `RTM_NEWROUTE`.
///
/// Meaning of the fields we populate:
/// - `rtm_family = AF_INET`: this is an IPv4 route
/// - `rtm_dst_len = 0`: zero-bit destination prefix, which means "default route"
/// - `rtm_table = RT_TABLE_MAIN`: install into the main routing table
/// - `rtm_protocol = RTPROT_BOOT`: route was installed during boot/runtime init
/// - `rtm_scope = RT_SCOPE_UNIVERSE`: globally reachable route
/// - `rtm_type = RTN_UNICAST`: normal unicast forwarding entry
#[repr(C)]
struct RtMsg {
rtm_family: u8,
rtm_dst_len: u8,
rtm_src_len: u8,
rtm_tos: u8,
rtm_table: u8,
rtm_protocol: u8,
rtm_scope: u8,
rtm_type: u8,
rtm_flags: u32,
}
const NLMSG_HDRLEN: usize = 16;
const IFADDRMSG_LEN: usize = 8;
const RTMSG_LEN: usize = 12;
const RTA_HDRLEN: usize = 4;
const _: () = assert!(std::mem::size_of::<libc::nlmsghdr>() == NLMSG_HDRLEN);
const _: () = assert!(std::mem::size_of::<IfAddrMsg>() == IFADDRMSG_LEN);
const _: () = assert!(std::mem::size_of::<RtMsg>() == RTMSG_LEN);
/// Build and send an `RTM_NEWADDR` netlink message.
///
/// C ABI context:
/// - `nlmsghdr` is the outer netlink envelope
/// - `IfAddrMsg` is the `RTM_NEWADDR` body
/// - `IFA_ADDRESS` and `IFA_LOCAL` are route attributes appended after the body
///
/// Outcome:
/// - asks the kernel to attach an IPv4 address/prefix to `ifindex`
///
/// Message shape:
///
/// ```text
/// nlmsghdr
/// type = RTM_NEWADDR
/// flags = REQUEST | ACK | CREATE | EXCL
/// ifaddrmsg
/// family = AF_INET
/// prefixlen = <prefix_len>
/// index = <ifindex>
/// rtattr IFA_ADDRESS = <IPv4 bytes>
/// rtattr IFA_LOCAL = <IPv4 bytes>
/// ```
///
/// Why both `IFA_ADDRESS` and `IFA_LOCAL` are present:
/// - for a plain unicast interface address, both effectively describe the same
/// address
/// - including both matches the common shape produced by tools like `ip`
/// for local interface address assignment
///
/// `family` selects IPv4 (`AF_INET`, 4-byte address) or IPv6 (`AF_INET6`,
/// 16-byte address); `flags` carries e.g. [`IFA_F_NODAD`].
fn netlink_newaddr(
ifindex: u32,
family: u8,
flags: u8,
prefix_len: u8,
address: &[u8],
) -> std::io::Result<()> {
let rta_len = rta_space(address.len());
let msg_len = NLMSG_HDRLEN + IFADDRMSG_LEN + (rta_len * 2);
let mut buf = vec![0u8; nlmsg_align(msg_len)];
let nlh = buf.as_mut_ptr().cast::<libc::nlmsghdr>();
// SAFETY: `buf` is large enough for `nlmsghdr`.
unsafe {
(*nlh).nlmsg_len = msg_len as u32;
(*nlh).nlmsg_type = libc::RTM_NEWADDR;
(*nlh).nlmsg_flags =
(libc::NLM_F_REQUEST | libc::NLM_F_ACK | libc::NLM_F_CREATE | libc::NLM_F_EXCL) as u16;
(*nlh).nlmsg_seq = 1;
}
let ifa = unsafe { buf.as_mut_ptr().add(NLMSG_HDRLEN).cast::<IfAddrMsg>() };
// SAFETY: `buf` is large enough for `IfAddrMsg`.
unsafe {
(*ifa).ifa_family = family;
(*ifa).ifa_prefixlen = prefix_len;
(*ifa).ifa_flags = flags;
(*ifa).ifa_scope = libc::RT_SCOPE_UNIVERSE;
(*ifa).ifa_index = ifindex;
}
let mut offset = NLMSG_HDRLEN + IFADDRMSG_LEN;
write_rta(&mut buf[offset..], libc::IFA_ADDRESS, address);
offset += rta_space(address.len());
write_rta(&mut buf[offset..], libc::IFA_LOCAL, address);
netlink_send(&buf)
}
/// Build and send an `RTM_NEWROUTE` netlink message for the default route.
///
/// C ABI context:
/// - `rtm_dst_len = 0` means "default route"
/// - `RTA_GATEWAY` carries the next-hop IPv4 address
///
/// Outcome:
/// - asks the kernel to install a unicast route in the main table
///
/// Message shape:
///
/// ```text
/// nlmsghdr
/// type = RTM_NEWROUTE
/// flags = REQUEST | ACK | CREATE | EXCL
/// rtmsg
/// family = AF_INET
/// dst_len = 0
/// table = RT_TABLE_MAIN
/// protocol = RTPROT_BOOT
/// scope = RT_SCOPE_UNIVERSE
/// type = RTN_UNICAST
/// rtattr RTA_GATEWAY = <gateway address bytes>
/// ```
///
/// `family` selects IPv4 (`AF_INET`, 4-byte gateway) or IPv6 (`AF_INET6`,
/// 16-byte gateway).
fn netlink_newroute(family: u8, gateway: &[u8]) -> std::io::Result<()> {
let rta_len = rta_space(gateway.len());
let msg_len = NLMSG_HDRLEN + RTMSG_LEN + rta_len;
let mut buf = vec![0u8; nlmsg_align(msg_len)];
let nlh = buf.as_mut_ptr().cast::<libc::nlmsghdr>();
// SAFETY: `buf` is large enough for `nlmsghdr`.
unsafe {
(*nlh).nlmsg_len = msg_len as u32;
(*nlh).nlmsg_type = libc::RTM_NEWROUTE;
(*nlh).nlmsg_flags =
(libc::NLM_F_REQUEST | libc::NLM_F_ACK | libc::NLM_F_CREATE | libc::NLM_F_EXCL) as u16;
(*nlh).nlmsg_seq = 2;
}
let rtm = unsafe { buf.as_mut_ptr().add(NLMSG_HDRLEN).cast::<RtMsg>() };
// SAFETY: `buf` is large enough for `RtMsg`.
unsafe {
(*rtm).rtm_family = family;
(*rtm).rtm_dst_len = 0;
(*rtm).rtm_src_len = 0;
(*rtm).rtm_tos = 0;
(*rtm).rtm_table = libc::RT_TABLE_MAIN;
(*rtm).rtm_protocol = libc::RTPROT_BOOT;
(*rtm).rtm_scope = libc::RT_SCOPE_UNIVERSE;
(*rtm).rtm_type = libc::RTN_UNICAST;
(*rtm).rtm_flags = 0;
}
let offset = NLMSG_HDRLEN + RTMSG_LEN;
write_rta(&mut buf[offset..], libc::RTA_GATEWAY, gateway);
netlink_send(&buf)
}
/// Send one netlink request and wait for the kernel ack.
///
/// Walkthrough:
///
/// ```text
/// userspace buffer
/// -> socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE)
/// -> bind(local netlink sockaddr)
/// -> send(message)
/// -> recv(ack)
/// -> inspect NLMSG_ERROR
/// ```
///
/// C ABI context:
/// - `socket(AF_NETLINK, SOCK_DGRAM, NETLINK_ROUTE)` opens a routing netlink
/// endpoint to the kernel
/// - `bind` attaches a local netlink address so the kernel can reply
/// - `send` transmits the prepared `nlmsghdr + payload`
/// - `recv` collects the kernel ack
/// - the ack payload for `NLMSG_ERROR` contains a signed errno:
/// - `0` means success
/// - a negative errno means failure
///
/// Outcome:
/// - returns `Ok(())` once the kernel accepts the request
/// - returns an `io::Error` if socket creation, send/recv, or the kernel ack
/// reports a failure
///
/// Important distinction from the virtio-net data path:
/// - this socket is not carrying guest Ethernet traffic
/// - it is a control-plane socket used only to ask the Linux kernel to mutate
/// routing/address state inside the guest
fn netlink_send(msg: &[u8]) -> std::io::Result<()> {
// SAFETY: all libc calls use valid buffers and checked lengths.
unsafe {
let sock = libc::socket(libc::AF_NETLINK, libc::SOCK_DGRAM, libc::NETLINK_ROUTE);
if sock < 0 {
return Err(std::io::Error::last_os_error());
}
let mut sockaddr: libc::sockaddr_nl = std::mem::zeroed();
sockaddr.nl_family = libc::AF_NETLINK as u16;
if libc::bind(
sock,
(&sockaddr as *const libc::sockaddr_nl).cast(),
std::mem::size_of::<libc::sockaddr_nl>() as u32,
) < 0
{
let err = std::io::Error::last_os_error();
libc::close(sock);
return Err(err);
}
if libc::send(sock, msg.as_ptr().cast(), msg.len(), 0) < 0 {
let err = std::io::Error::last_os_error();
libc::close(sock);
return Err(err);
}
let mut ack = [0u8; 1024];
let bytes = libc::recv(sock, ack.as_mut_ptr().cast(), ack.len(), 0);
libc::close(sock);
if bytes < 0 {
return Err(std::io::Error::last_os_error());
}
if (bytes as usize) >= NLMSG_HDRLEN + 4 {
let nlh = ack.as_ptr().cast::<libc::nlmsghdr>();
if (*nlh).nlmsg_type == libc::NLMSG_ERROR as u16 {
let err =
i32::from_ne_bytes(ack[NLMSG_HDRLEN..NLMSG_HDRLEN + 4].try_into().unwrap());
if err < 0 {
return Err(std::io::Error::from_raw_os_error(-err));
}
}
}
Ok(())
}
}
/// Netlink/rtnetlink attributes are 4-byte aligned.
///
/// The kernel expects both message bodies and nested attributes to start on
/// 4-byte boundaries. This helper rounds a length up to that alignment.
fn nlmsg_align(len: usize) -> usize {
(len + 3) & !3
}
/// Space consumed by one route attribute including alignment padding.
///
/// An `rtattr` is:
///
/// ```text
/// [u16 len][u16 type][payload bytes][optional padding]
/// ```
///
/// `rta_space` returns the full reserved byte count, not just the visible
/// header + payload.
fn rta_space(data_len: usize) -> usize {
nlmsg_align(RTA_HDRLEN + data_len)
}
/// Encode one `rtattr` header plus its payload bytes into `buf`.
///
/// `buf` is expected to be large enough for `rta_space(data.len())`. The caller
/// is responsible for advancing the offset by the aligned size so the next
/// attribute starts on a 4-byte boundary.
fn write_rta(buf: &mut [u8], rta_type: u16, data: &[u8]) {
let rta_len = (RTA_HDRLEN + data.len()) as u16;
buf[0..2].copy_from_slice(&rta_len.to_ne_bytes());
buf[2..4].copy_from_slice(&rta_type.to_ne_bytes());
buf[RTA_HDRLEN..RTA_HDRLEN + data.len()].copy_from_slice(data);
}
+250
View File
@@ -0,0 +1,250 @@
//! Guest-side virtio-net configuration from `SMOLVM_NETWORK_*`.
//!
//! Context
//! =======
//!
//! The host side of the virtio-net design decides whether a VM should use:
//! - the legacy TSI networking path, or
//! - a real virtio-net device exposed to the guest
//!
//! When virtio-net is selected, the launcher does not run guest shell
//! commands like `ip link`, `ip addr`, or `ip route`. Instead it passes a
//! small, explicit configuration contract into the guest as environment
//! variables. The agent reads those values very early in boot and programs
//! the kernel network state directly.
//!
//! That gives us a narrow host/guest boundary:
//!
//! ```text
//! host launcher
//! -> decides backend = virtio-net
//! -> chooses guest IP / gateway / DNS / MAC
//! -> exports SMOLVM_NETWORK_* env
//! -> starts guest agent
//!
//! guest agent
//! -> parses SMOLVM_NETWORK_* env
//! -> configures eth0 inside the guest kernel
//! -> continues normal boot
//! ```
//!
//! In shell terms, the Linux implementation in `linux.rs` is effectively a
//! built-in replacement for this class of commands:
//!
//! ```text
//! ip link set dev eth0 address <mac>
//! ip link set dev eth0 mtu <mtu>
//! ip addr add <guest_ip>/<prefix> dev eth0
//! ip link set dev eth0 up
//! ip route add default via <gateway>
//! printf 'nameserver <dns>\n' > /etc/resolv.conf
//! ```
//!
//! We do it inside the agent rather than by spawning external tools because the
//! guest image is intentionally small and boots before we can assume userspace
//! helpers are present.
//!
//! The Linux-specific implementation lives in `linux.rs`. Non-Linux guests
//! currently return an explicit error instead of attempting a partial setup.
use smolvm_protocol::guest_env;
use std::net::{Ipv4Addr, Ipv6Addr};
/// Configure the guest network interface from host-provided environment.
///
/// Returns `Ok(false)` when virtio-net is not enabled for this boot.
///
/// Environment contract
/// --------------------
///
/// The host launcher currently provides:
/// - `SMOLVM_NETWORK_BACKEND=virtio-net`
/// - `SMOLVM_NETWORK_GUEST_IP`
/// - `SMOLVM_NETWORK_GATEWAY`
/// - `SMOLVM_NETWORK_PREFIX_LEN`
/// - `SMOLVM_NETWORK_GUEST_MAC`
/// - `SMOLVM_NETWORK_DNS`
/// - `SMOLVM_NETWORK_GUEST_IP6` / `SMOLVM_NETWORK_GATEWAY6` /
/// `SMOLVM_NETWORK_PREFIX_LEN6` (optional trio — absent means IPv4-only)
///
/// Example:
///
/// ```text
/// SMOLVM_NETWORK_BACKEND=virtio-net
/// SMOLVM_NETWORK_GUEST_IP=10.0.2.15
/// SMOLVM_NETWORK_GATEWAY=10.0.2.2
/// SMOLVM_NETWORK_PREFIX_LEN=24
/// SMOLVM_NETWORK_GUEST_MAC=02:53:4d:00:00:02
/// SMOLVM_NETWORK_DNS=10.0.2.2
/// SMOLVM_NETWORK_GUEST_IP6=fd53:4d00::2
/// SMOLVM_NETWORK_GATEWAY6=fd53:4d00::1
/// SMOLVM_NETWORK_PREFIX_LEN6=64
/// ```
///
/// What this function does
/// -----------------------
///
/// 1. Decide whether the current boot even wants guest virtio networking.
/// 2. Parse the environment strings into typed values.
/// 3. Call the Linux backend to program `eth0`.
///
/// Outcome
/// -------
///
/// - `Ok(false)`: no virtio-net request was present, so the agent leaves the
/// guest network untouched.
/// - `Ok(true)`: `eth0` was configured successfully.
/// - `Err(...)`: virtio-net was requested but the configuration was incomplete
/// or malformed, so boot should fail instead of continuing with a
/// half-configured NIC.
pub fn configure_from_env() -> Result<bool, String> {
let backend = match std::env::var(guest_env::BACKEND) {
Ok(value) if !value.is_empty() => value,
_ => return Ok(false),
};
if backend != guest_env::BACKEND_VIRTIO_NET {
return Err(format!(
"unsupported {} value: {}",
guest_env::BACKEND,
backend
));
}
let guest_ip = env_ipv4(guest_env::GUEST_IP)?;
let gateway = env_ipv4(guest_env::GATEWAY)?;
let prefix_len = env_u8(guest_env::PREFIX_LEN)?;
let guest_mac = env_mac(guest_env::GUEST_MAC)?;
let dns_server = env_ipv4(guest_env::DNS)?;
let ipv6 = env_ipv6_config()?;
linux::configure_interface(
"eth0", guest_mac, 1500, guest_ip, prefix_len, gateway, ipv6, dns_server,
)?;
Ok(true)
}
/// Parse the optional IPv6 trio. All three vars must be present together; a
/// partial set is a malformed contract and fails the boot rather than leaving a
/// half-configured stack.
fn env_ipv6_config() -> Result<Option<(Ipv6Addr, u8, Ipv6Addr)>, String> {
let vars = [
guest_env::GUEST_IP6,
guest_env::GATEWAY6,
guest_env::PREFIX_LEN6,
];
let present = vars
.iter()
.filter(|name| std::env::var(name).is_ok_and(|v| !v.is_empty()))
.count();
match present {
0 => Ok(None),
3 => Ok(Some((
env_ipv6(guest_env::GUEST_IP6)?,
env_u8(guest_env::PREFIX_LEN6)?,
env_ipv6(guest_env::GATEWAY6)?,
))),
_ => Err(format!(
"incomplete IPv6 network config: {} / {} / {} must be set together",
guest_env::GUEST_IP6,
guest_env::GATEWAY6,
guest_env::PREFIX_LEN6
)),
}
}
fn env_ipv4(name: &str) -> Result<Ipv4Addr, String> {
let value = std::env::var(name).map_err(|_| format!("missing {}", name))?;
value
.parse::<Ipv4Addr>()
.map_err(|_| format!("invalid IPv4 address for {}: {}", name, value))
}
fn env_ipv6(name: &str) -> Result<Ipv6Addr, String> {
let value = std::env::var(name).map_err(|_| format!("missing {}", name))?;
value
.parse::<Ipv6Addr>()
.map_err(|_| format!("invalid IPv6 address for {}: {}", name, value))
}
fn env_u8(name: &str) -> Result<u8, String> {
let value = std::env::var(name).map_err(|_| format!("missing {}", name))?;
value
.parse::<u8>()
.map_err(|_| format!("invalid integer for {}: {}", name, value))
}
fn env_mac(name: &str) -> Result<[u8; 6], String> {
let value = std::env::var(name).map_err(|_| format!("missing {}", name))?;
parse_mac(&value)
}
/// Parse a colon-separated MAC address into six raw octets.
///
/// The guest kernel APIs do not consume the string form directly. They expect
/// the six raw Ethernet octets, so we translate:
///
/// ```text
/// 02:53:4d:00:00:02
/// -> [0x02, 0x53, 0x4d, 0x00, 0x00, 0x02]
/// ```
///
/// This parser is intentionally strict: exactly six hex octets separated by
/// `:` and nothing else.
fn parse_mac(value: &str) -> Result<[u8; 6], String> {
let mut mac = [0u8; 6];
let mut count = 0usize;
for (index, part) in value.split(':').enumerate() {
if index >= 6 {
return Err(format!("invalid MAC address: {}", value));
}
mac[index] =
u8::from_str_radix(part, 16).map_err(|_| format!("invalid MAC octet: {}", part))?;
count = index + 1;
}
if count != 6 {
return Err(format!("invalid MAC address: {}", value));
}
Ok(mac)
}
#[cfg(target_os = "linux")]
mod linux;
#[cfg(not(target_os = "linux"))]
mod linux {
use std::net::{Ipv4Addr, Ipv6Addr};
#[allow(clippy::too_many_arguments)]
pub fn configure_interface(
_ifname: &str,
_mac: [u8; 6],
_mtu: u16,
_address: Ipv4Addr,
_prefix_len: u8,
_gateway: Ipv4Addr,
_ipv6: Option<(Ipv6Addr, u8, Ipv6Addr)>,
_dns_server: Ipv4Addr,
) -> Result<(), String> {
Err("guest virtio networking is only supported on Linux".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_mac_accepts_six_octets() {
assert_eq!(
parse_mac("02:53:4d:00:00:02").unwrap(),
[0x02, 0x53, 0x4d, 0x00, 0x00, 0x02]
);
}
#[test]
fn parse_mac_rejects_invalid_input() {
assert!(parse_mac("02:53:4d").is_err());
assert!(parse_mac("zz:53:4d:00:00:02").is_err());
}
}
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
//! Centralized path constants and helpers for the smolvm agent.
//!
//! All filesystem paths used by the agent are defined here for consistency
//! and easy modification.
use std::path::PathBuf;
// =============================================================================
// Binary Paths
// =============================================================================
/// Path to crun OCI runtime binary.
pub const CRUN_PATH: &str = "/usr/bin/crun";
/// crun state root directory.
/// Stored on the persistent storage disk instead of `/run/crun` because
/// `/run` may not be writable under the overlayfs rootfs.
pub const CRUN_ROOT_DIR: &str = "/storage/containers/crun";
/// crun cgroup manager setting.
/// Set to "disabled" because libkrun mounts cgroup2 as read-only.
/// Without this, crun create/start hang trying to create container cgroups.
pub const CRUN_CGROUP_MANAGER: &str = "disabled";
// =============================================================================
// Mount Paths
// =============================================================================
/// Root directory for virtiofs mounts from the host.
pub const VIRTIOFS_MOUNT_ROOT: &str = "/mnt/virtiofs";
/// Guest path at which the shared workspace is exposed inside containers.
/// Used both when mounting /storage/workspace as the fallback workspace and
/// when checking whether a user-provided volume already claims this path.
pub const WORKSPACE_GUEST_PATH: &str = "/workspace";
// =============================================================================
// Storage Paths
// =============================================================================
/// Root directory for all persistent storage.
pub const STORAGE_ROOT: &str = "/storage";
/// Directory for overlay filesystems.
pub const OVERLAYS_DIR: &str = "/storage/overlays";
// =============================================================================
// Container Runtime Paths
// =============================================================================
/// Directory for per-container runtime state (pidfile, etc).
pub const CONTAINERS_RUN_DIR: &str = "/storage/containers/run";
/// Directory for container logs.
pub const CONTAINERS_LOGS_DIR: &str = "/storage/containers/logs";
/// Directory for container exit code files.
pub const CONTAINERS_EXIT_DIR: &str = "/storage/containers/exit";
// =============================================================================
// Path Helper Functions
// =============================================================================
/// Get the runtime directory for a specific container.
pub fn container_run_dir(container_id: &str) -> PathBuf {
PathBuf::from(CONTAINERS_RUN_DIR).join(container_id)
}
/// Get the log file path for a container.
pub fn container_log_path(container_id: &str) -> PathBuf {
PathBuf::from(CONTAINERS_LOGS_DIR).join(format!("{}.log", container_id))
}
/// Get the exit code file path for a container.
pub fn container_exit_path(container_id: &str) -> PathBuf {
PathBuf::from(CONTAINERS_EXIT_DIR).join(container_id)
}
/// Get the overlay directory for a workload.
pub fn overlay_dir(workload_id: &str) -> PathBuf {
PathBuf::from(OVERLAYS_DIR).join(workload_id)
}
/// Get the bundle directory for a workload.
pub fn bundle_dir(workload_id: &str) -> PathBuf {
overlay_dir(workload_id).join("bundle")
}
/// Path to the file recording the main container ID for a persistent overlay.
/// Written when a detached container is started; read on every subsequent exec.
pub fn main_container_id_path(workload_id: &str) -> PathBuf {
overlay_dir(workload_id).join("main_container_id")
}
// =============================================================================
// Filesystem Helpers
// =============================================================================
/// Check if a path is a mountpoint by reading /proc/mounts.
///
/// Returns true if the path appears as a mount destination in /proc/mounts.
#[cfg(target_os = "linux")]
pub fn is_mount_point(path: &std::path::Path) -> bool {
if let Ok(mounts) = std::fs::read_to_string("/proc/mounts") {
let path_str = path.to_string_lossy();
for line in mounts.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 && parts[1] == path_str {
return true;
}
}
}
false
}
/// Stub for non-Linux platforms.
#[cfg(not(target_os = "linux"))]
pub fn is_mount_point(_path: &std::path::Path) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_container_paths() {
let id = "abc123";
assert_eq!(
container_run_dir(id),
PathBuf::from("/storage/containers/run/abc123")
);
assert_eq!(
container_log_path(id),
PathBuf::from("/storage/containers/logs/abc123.log")
);
assert_eq!(
container_exit_path(id),
PathBuf::from("/storage/containers/exit/abc123")
);
}
#[test]
fn test_overlay_paths() {
let wl = "workload-123";
assert_eq!(
overlay_dir(wl),
PathBuf::from("/storage/overlays/workload-123")
);
assert_eq!(
bundle_dir(wl),
PathBuf::from("/storage/overlays/workload-123/bundle")
);
}
#[test]
fn test_main_container_id_path() {
assert_eq!(
main_container_id_path("persistent-myvm"),
PathBuf::from("/storage/overlays/persistent-myvm/main_container_id")
);
}
}
+645
View File
@@ -0,0 +1,645 @@
//! Process execution utilities for the smolvm agent.
//!
//! This module provides common helpers for spawning and managing child processes,
//! including timeout handling and output capture.
use std::io::Read;
use std::process::Child;
use std::time::{Duration, Instant};
/// Exit code used when a command is killed due to timeout.
pub const TIMEOUT_EXIT_CODE: i32 = 124;
/// Map a process exit status to a numeric exit code.
///
/// Normal exit → the process's exit code. Terminated by a signal (where
/// `ExitStatus::code()` is `None`) → `128 + signal`, matching shell
/// convention (SIGINT → 130, SIGTERM → 143), so callers and scripts can
/// distinguish a signal kill from a normal exit instead of seeing an
/// opaque 255.
pub fn exit_code_from_status(status: &std::process::ExitStatus) -> i32 {
use std::os::unix::process::ExitStatusExt;
if let Some(code) = status.code() {
code
} else if let Some(sig) = status.signal() {
128 + sig
} else {
-1
}
}
/// Per-stream output cap for non-interactive exec. Vec<u8> is base64-encoded
/// in JSON frames (4/3 expansion). Two streams at this cap must fit within the
/// 32 MB frame limit with room for JSON overhead:
/// 11 MiB × 2 × 4/3 ≈ 29.3 MiB encoded + ~2.7 MiB JSON headroom.
pub const MAX_EXEC_OUTPUT: usize = 11 * 1024 * 1024;
/// Maximum time to wait for reader threads to finish after the child is killed.
/// Guards against pathological cases where an inherited fd keeps a pipe open.
const READER_JOIN_TIMEOUT: Duration = Duration::from_secs(5);
/// Captured output from a child process.
#[derive(Debug, Default)]
pub struct ChildOutput {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
/// Result of waiting for a child process.
#[derive(Debug)]
pub enum WaitResult {
/// Process completed with the given exit code.
Completed { exit_code: i32, output: ChildOutput },
/// Process was killed due to timeout.
TimedOut {
output: ChildOutput,
timeout_ms: u64,
},
/// Process was killed because the requesting client disconnected.
/// Used to free the accept loop when the client gives up mid-exec.
ClientDisconnected { output: ChildOutput },
}
/// Check whether the peer on `fd` has closed the connection.
///
/// Uses `recv(MSG_PEEK | MSG_DONTWAIT)` which is more reliable than `poll()`
/// on vsock — vsock's poll implementation doesn't always propagate POLLHUP
/// when the peer closes, but a zero-length peek is the canonical way to
/// detect half-closed sockets.
///
/// Returns `true` if the peer has closed OR the socket is in an error state.
/// Returns `false` if the socket is still alive OR we can't determine (fail
/// open — a bogus fd shouldn't cause us to kill a healthy child).
#[cfg(target_os = "linux")]
pub fn is_peer_closed(fd: std::os::unix::io::RawFd) -> bool {
if fd < 0 {
return false;
}
let mut buf = [0u8; 1];
// SAFETY: buf is a valid write target, MSG_PEEK doesn't consume data.
let rc = unsafe {
libc::recv(
fd,
buf.as_mut_ptr() as *mut libc::c_void,
buf.len(),
libc::MSG_PEEK | libc::MSG_DONTWAIT,
)
};
if rc == 0 {
// Peer performed orderly shutdown (FIN received).
return true;
}
if rc < 0 {
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
// EAGAIN = no data but connection alive → peer still there. (EWOULDBLOCK
// is the same value as EAGAIN on Linux.) Any other error (ECONNRESET,
// ENOTCONN, EBADF, etc.) → peer gone.
return !matches!(errno, libc::EAGAIN);
}
// rc > 0: there's data in the buffer — connection is alive.
false
}
#[cfg(not(target_os = "linux"))]
pub fn is_peer_closed(_fd: std::os::unix::io::RawFd) -> bool {
false
}
/// Capture stdout and stderr from a child process.
///
/// Reads raw bytes — preserves binary output (image bytes, tarballs, etc.)
/// that `read_to_string` would truncate at the first non-UTF-8 byte.
///
/// # Safety note
///
/// Call this only AFTER the process has already exited (or been killed).
/// If the process is still running and has filled the pipe buffer (~64 KB on
/// Linux), reading blocks until the process exits — which it cannot do while
/// the pipe is full. Prefer `spawn_pipe_drains` + `join_pipe_drains` when
/// the process is still running.
pub fn capture_child_output(child: &mut Child) -> ChildOutput {
let mut output = ChildOutput::default();
if let Some(mut stdout) = child.stdout.take() {
let _ = stdout.read_to_end(&mut output.stdout);
}
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_end(&mut output.stderr);
}
output
}
/// Start background threads that drain the child's stdout and stderr pipes.
///
/// This must be called BEFORE the wait loop so the pipes are continuously
/// drained while the process runs. If a process fills the ~64 KB Linux pipe
/// buffer and nobody is reading, its next `write(2)` blocks and the process
/// can never exit — a classic pipe-deadlock.
///
/// Returns `(stdout_drain, stderr_drain)` join handles. Call
/// `join_pipe_drains` to collect the output after the process has exited.
pub fn spawn_pipe_drains(
child: &mut Child,
) -> (
Option<std::thread::JoinHandle<Vec<u8>>>,
Option<std::thread::JoinHandle<Vec<u8>>>,
) {
let stdout_handle = child.stdout.take().map(|mut pipe| {
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = pipe.read_to_end(&mut buf);
buf
})
});
let stderr_handle = child.stderr.take().map(|mut pipe| {
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = pipe.read_to_end(&mut buf);
buf
})
});
(stdout_handle, stderr_handle)
}
/// Collect output from pipe-drain threads started by `spawn_pipe_drains`.
pub fn join_pipe_drains(
stdout_handle: Option<std::thread::JoinHandle<Vec<u8>>>,
stderr_handle: Option<std::thread::JoinHandle<Vec<u8>>>,
) -> ChildOutput {
ChildOutput {
stdout: stdout_handle
.and_then(|h| h.join().ok())
.unwrap_or_default(),
stderr: stderr_handle
.and_then(|h| h.join().ok())
.unwrap_or_default(),
}
}
/// Wait for a child process with optional timeout.
///
/// If timeout_ms is Some, the process will be killed after the timeout
/// and WaitResult::TimedOut will be returned.
///
/// The poll_interval_ms parameter controls how often we check for completion
/// (default: 10ms).
///
/// Handles EINTR (interrupted system call) by retrying the wait.
pub fn wait_with_timeout(
child: &mut Child,
timeout_ms: Option<u64>,
poll_interval_ms: Option<u64>,
) -> std::io::Result<WaitResult> {
let poll_interval = Duration::from_millis(poll_interval_ms.unwrap_or(10));
let deadline = timeout_ms.map(|ms| Instant::now() + Duration::from_millis(ms));
// Drain pipes in background threads so they never fill up and deadlock the child.
let (stdout_drain, stderr_drain) = spawn_pipe_drains(child);
loop {
match try_wait_with_eintr(child) {
Ok(Some(status)) => {
// Process completed — join drains to get full output.
let output = join_pipe_drains(stdout_drain, stderr_drain);
let exit_code = exit_code_from_status(&status);
return Ok(WaitResult::Completed { exit_code, output });
}
Ok(None) => {
// Still running - check timeout
if let Some(deadline) = deadline {
if Instant::now() >= deadline {
// Kill the process
let _ = child.kill();
let _ = child.wait();
// Collect any partial output from the drain threads.
let output = join_pipe_drains(stdout_drain, stderr_drain);
return Ok(WaitResult::TimedOut {
output,
timeout_ms: timeout_ms.unwrap_or(0),
});
}
}
// Sleep before checking again
std::thread::sleep(poll_interval);
}
Err(e) => return Err(e),
}
}
}
/// Try to wait for a child process, handling EINTR by retrying.
///
/// EINTR can occur when a signal is delivered during the wait syscall.
/// This is not a real error - we should just retry the wait.
fn try_wait_with_eintr(child: &mut Child) -> std::io::Result<Option<std::process::ExitStatus>> {
loop {
match child.try_wait() {
Ok(status) => return Ok(status),
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
// EINTR - signal interrupted the syscall, retry
continue;
}
Err(e) => return Err(e),
}
}
}
/// Wait for a child process with timeout and custom timeout handler.
///
/// The on_timeout callback is called when the process times out, before
/// killing it. This allows for custom cleanup (e.g., killing containers).
///
/// Handles EINTR (interrupted system call) by retrying the wait.
pub fn wait_with_timeout_and_cleanup<F>(
child: &mut Child,
timeout_ms: Option<u64>,
on_timeout: F,
) -> std::io::Result<WaitResult>
where
F: FnOnce(),
{
wait_with_timeout_cleanup_and_liveness(child, timeout_ms, None, on_timeout)
}
/// Wait for a child process, killing it if the timeout expires OR if the
/// requesting client disconnects (indicated by `client_fd`, which is polled
/// each iteration).
///
/// Stdout and stderr are drained concurrently in background threads to prevent
/// pipe deadlock: if the child writes more than the OS pipe buffer (~64KB),
/// it blocks on write() while the agent blocks waiting for exit — neither side
/// makes progress. The background threads consume pipe data continuously,
/// preventing backpressure from stalling the child.
///
/// The client-disconnect check is the short-term mitigation for BUG-12/20:
/// when the host-side exec client is SIGTERM'd or times out, the agent's
/// accept loop was left blocked on the still-running child. Now we kill the
/// child as soon as we detect the peer has closed the connection, freeing
/// the accept loop for the next request.
pub fn wait_with_timeout_cleanup_and_liveness<F>(
child: &mut Child,
timeout_ms: Option<u64>,
client_fd: Option<std::os::unix::io::RawFd>,
on_timeout: F,
) -> std::io::Result<WaitResult>
where
F: FnOnce(),
{
use std::sync::mpsc;
const CHUNK_SIZE: usize = 64 * 1024;
// Drain stdout/stderr in background threads BEFORE waiting for exit.
// Threads send chunks via channels so the parent accumulates data
// incrementally. On timeout/disconnect, already-received chunks are
// preserved even if the reader thread is still blocked on a pipe that
// hasn't reached EOF (e.g., background process inherited stdio).
let (stdout_tx, stdout_rx) = mpsc::channel::<Vec<u8>>();
let (stderr_tx, stderr_rx) = mpsc::channel::<Vec<u8>>();
let stdout_handle = child.stdout.take().and_then(|mut out| {
std::thread::Builder::new()
.name("crun-stdout".into())
.spawn(move || {
let mut total = 0usize;
loop {
let mut chunk = vec![0u8; CHUNK_SIZE];
match out.read(&mut chunk) {
Ok(0) => break, // EOF
Ok(n) => {
total += n;
chunk.truncate(n);
if stdout_tx.send(chunk).is_err() {
break; // receiver dropped
}
if total >= MAX_EXEC_OUTPUT {
break; // cap reached
}
}
Err(_) => break,
}
}
})
.ok()
});
let stderr_handle = child.stderr.take().and_then(|mut err| {
std::thread::Builder::new()
.name("crun-stderr".into())
.spawn(move || {
let mut total = 0usize;
loop {
let mut chunk = vec![0u8; CHUNK_SIZE];
match err.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
total += n;
chunk.truncate(n);
if stderr_tx.send(chunk).is_err() {
break;
}
if total >= MAX_EXEC_OUTPUT {
break;
}
}
Err(_) => break,
}
}
})
.ok()
});
// Accumulated output — grows as reader threads send chunks.
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let poll_interval = Duration::from_millis(10);
let deadline = timeout_ms.map(|ms| Instant::now() + Duration::from_millis(ms));
// Drain any available chunks from the channels into local buffers.
let drain_channels = |stdout_rx: &mpsc::Receiver<Vec<u8>>,
stderr_rx: &mpsc::Receiver<Vec<u8>>,
stdout_buf: &mut Vec<u8>,
stderr_buf: &mut Vec<u8>| {
for chunk in stdout_rx.try_iter() {
stdout_buf.extend_from_slice(&chunk);
}
for chunk in stderr_rx.try_iter() {
stderr_buf.extend_from_slice(&chunk);
}
};
loop {
// Drain available chunks each iteration so local buffers stay current.
drain_channels(&stdout_rx, &stderr_rx, &mut stdout_buf, &mut stderr_buf);
match try_wait_with_eintr(child) {
Ok(Some(status)) => {
// Child exited — give reader threads a bounded window to finish.
// After the child dies, pipe write ends close and readers see EOF.
// Use is_finished() on handles to detect completion without consuming
// chunks (try_recv as a probe races and can drop data).
let join_deadline = Instant::now() + READER_JOIN_TIMEOUT;
while Instant::now() < join_deadline {
drain_channels(&stdout_rx, &stderr_rx, &mut stdout_buf, &mut stderr_buf);
let stdout_done = stdout_handle.as_ref().map_or(true, |h| h.is_finished());
let stderr_done = stderr_handle.as_ref().map_or(true, |h| h.is_finished());
if stdout_done && stderr_done {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
// Final drain after threads are done (or timed out).
drain_channels(&stdout_rx, &stderr_rx, &mut stdout_buf, &mut stderr_buf);
let exit_code = exit_code_from_status(&status);
return Ok(WaitResult::Completed {
exit_code,
output: ChildOutput {
stdout: stdout_buf,
stderr: stderr_buf,
},
});
}
Ok(None) => {
if let Some(fd) = client_fd {
if is_peer_closed(fd) {
let _ = child.kill();
let _ = child.wait();
drain_channels(&stdout_rx, &stderr_rx, &mut stdout_buf, &mut stderr_buf);
return Ok(WaitResult::ClientDisconnected {
output: ChildOutput {
stdout: stdout_buf,
stderr: stderr_buf,
},
});
}
}
if let Some(deadline) = deadline {
if Instant::now() >= deadline {
on_timeout();
let _ = child.kill();
let _ = child.wait();
drain_channels(&stdout_rx, &stderr_rx, &mut stdout_buf, &mut stderr_buf);
return Ok(WaitResult::TimedOut {
output: ChildOutput {
stdout: stdout_buf,
stderr: stderr_buf,
},
timeout_ms: timeout_ms.unwrap_or(0),
});
}
}
std::thread::sleep(poll_interval);
}
Err(e) => return Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[test]
fn test_timeout_exit_code_value() {
// Matches the standard timeout command exit code
assert_eq!(TIMEOUT_EXIT_CODE, 124);
}
#[test]
fn test_child_output_default() {
let output = ChildOutput::default();
assert!(output.stdout.is_empty());
assert!(output.stderr.is_empty());
}
#[test]
fn test_capture_child_output_stdout() {
let mut child = Command::new("echo")
.arg("hello world")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
child.wait().unwrap();
let output = capture_child_output(&mut child);
assert!(output
.stdout
.windows(b"hello world".len())
.any(|w| w == b"hello world"));
assert!(output.stderr.is_empty());
}
#[test]
fn test_capture_child_output_stderr() {
let mut child = Command::new("sh")
.args(["-c", "echo error >&2"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
child.wait().unwrap();
let output = capture_child_output(&mut child);
assert!(output.stdout.is_empty());
assert!(output.stderr.windows(b"error".len()).any(|w| w == b"error"));
}
#[test]
fn test_wait_completes_success() {
let mut child = Command::new("echo")
.arg("hello")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let result = wait_with_timeout(&mut child, Some(5000), None).unwrap();
match result {
WaitResult::Completed { exit_code, output } => {
assert_eq!(exit_code, 0);
assert!(output.stdout.windows(b"hello".len()).any(|w| w == b"hello"));
}
WaitResult::TimedOut { .. } => panic!("unexpected timeout"),
WaitResult::ClientDisconnected { .. } => panic!("unexpected client disconnect"),
}
}
#[test]
fn test_wait_completes_with_nonzero_exit() {
let mut child = Command::new("sh")
.args(["-c", "exit 42"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let result = wait_with_timeout(&mut child, Some(5000), None).unwrap();
match result {
WaitResult::Completed { exit_code, .. } => {
assert_eq!(exit_code, 42);
}
WaitResult::TimedOut { .. } => panic!("unexpected timeout"),
WaitResult::ClientDisconnected { .. } => panic!("unexpected client disconnect"),
}
}
#[test]
fn test_wait_no_timeout() {
// With timeout_ms = None, should wait indefinitely (process completes quickly)
let mut child = Command::new("echo")
.arg("quick")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let result = wait_with_timeout(&mut child, None, None).unwrap();
match result {
WaitResult::Completed { exit_code, output } => {
assert_eq!(exit_code, 0);
assert!(output.stdout.windows(b"quick".len()).any(|w| w == b"quick"));
}
WaitResult::TimedOut { .. } => panic!("unexpected timeout"),
WaitResult::ClientDisconnected { .. } => panic!("unexpected client disconnect"),
}
}
#[test]
fn test_wait_timeout() {
let mut child = Command::new("sleep")
.arg("10")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let result = wait_with_timeout(&mut child, Some(50), None).unwrap();
match result {
WaitResult::TimedOut { timeout_ms, .. } => {
assert_eq!(timeout_ms, 50);
}
WaitResult::Completed { .. } => panic!("expected timeout"),
WaitResult::ClientDisconnected { .. } => panic!("unexpected client disconnect"),
}
}
#[test]
fn test_wait_custom_poll_interval() {
let mut child = Command::new("echo")
.arg("fast")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
// Use a custom poll interval of 1ms
let result = wait_with_timeout(&mut child, Some(5000), Some(1)).unwrap();
assert!(matches!(result, WaitResult::Completed { .. }));
}
#[test]
fn test_wait_with_cleanup_calls_callback() {
let callback_called = Arc::new(AtomicBool::new(false));
let callback_called_clone = callback_called.clone();
let mut child = Command::new("sleep")
.arg("10")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let result = wait_with_timeout_and_cleanup(&mut child, Some(50), || {
callback_called_clone.store(true, Ordering::SeqCst);
})
.unwrap();
assert!(matches!(result, WaitResult::TimedOut { .. }));
assert!(
callback_called.load(Ordering::SeqCst),
"cleanup callback should be called"
);
}
#[test]
fn test_wait_with_cleanup_no_callback_on_success() {
let callback_called = Arc::new(AtomicBool::new(false));
let callback_called_clone = callback_called.clone();
let mut child = Command::new("echo")
.arg("done")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let result = wait_with_timeout_and_cleanup(&mut child, Some(5000), || {
callback_called_clone.store(true, Ordering::SeqCst);
})
.unwrap();
assert!(matches!(result, WaitResult::Completed { .. }));
assert!(
!callback_called.load(Ordering::SeqCst),
"cleanup callback should not be called on success"
);
}
}
+277
View File
@@ -0,0 +1,277 @@
//! PTY (pseudo-terminal) helpers for interactive VM exec.
//!
//! Uses POSIX `openpty()` to allocate a PTY pair and provides RAII
//! management of the master fd. The slave side is attached to a child
//! process via `Command::pre_exec`.
use std::io;
use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
/// Monotonic counter making each console-socket path unique, so concurrent
/// sessions that share a container id (e.g. multiple `crun exec` into one
/// running container) never collide on the same socket file.
static CONSOLE_SOCKET_SEQ: AtomicU64 = AtomicU64::new(0);
/// RAII wrapper around the master side of a PTY pair.
///
/// Closes the fd on drop. Provides read/write and window-size control.
pub struct PtyMaster {
fd: OwnedFd,
}
impl PtyMaster {
/// Read bytes from the PTY master.
pub fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
let n = unsafe { libc::read(self.fd.as_raw_fd(), buf.as_mut_ptr() as *mut _, buf.len()) };
if n < 0 {
Err(io::Error::last_os_error())
} else {
Ok(n as usize)
}
}
/// Write all bytes to the PTY master.
pub fn write_all(&self, mut data: &[u8]) -> io::Result<()> {
while !data.is_empty() {
let n =
unsafe { libc::write(self.fd.as_raw_fd(), data.as_ptr() as *const _, data.len()) };
if n < 0 {
return Err(io::Error::last_os_error());
}
data = &data[n as usize..];
}
Ok(())
}
/// Set the terminal window size via `TIOCSWINSZ`.
pub fn set_window_size(&self, cols: u16, rows: u16) -> io::Result<()> {
let ws = libc::winsize {
ws_row: rows,
ws_col: cols,
ws_xpixel: 0,
ws_ypixel: 0,
};
// SAFETY: TIOCSWINSZ with a valid winsize struct on our owned fd.
let ret = unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCSWINSZ, &ws) };
if ret < 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
pub fn as_raw_fd(&self) -> RawFd {
self.fd.as_raw_fd()
}
/// Wrap an already-open master fd — e.g. one received from crun over a
/// `--console-socket` — as a `PtyMaster`.
pub fn from_owned_fd(fd: OwnedFd) -> Self {
PtyMaster { fd }
}
}
/// A listening AF_UNIX socket that an OCI runtime connects to (via
/// `--console-socket`) to hand back the master fd of the container's PTY.
///
/// crun/runc, when `terminal: true`, create the console PTY themselves and — in
/// foreground mode — relay byte data between that PTY and their own stdio but do
/// NOT propagate window-size changes. Taking the master directly over this
/// socket means the agent owns the container's real console, so
/// [`PtyMaster::set_window_size`] (and resize events) reach the process's tty.
pub struct ConsoleSocket {
path: PathBuf,
listener: UnixListener,
}
impl ConsoleSocket {
/// Bind a uniquely-named console socket. `tag` (e.g. the container id) is a
/// human-readable hint; a process-global sequence number guarantees
/// uniqueness so concurrent sessions sharing a container id (multiple
/// `crun exec` into one container) never collide. Only the tag's tail is
/// used, to stay within the ~108-byte AF_UNIX path limit.
pub fn bind(tag: &str) -> io::Result<Self> {
let tail: String = {
let s = tag.trim_matches(|c: char| !c.is_ascii_alphanumeric());
let start = s.len().saturating_sub(16);
s[start..].to_string()
};
let seq = CONSOLE_SOCKET_SEQ.fetch_add(1, Ordering::Relaxed);
// The guest root filesystem (and thus /tmp) is read-only, so bind the
// socket under the writable storage disk. crun shares this mount
// namespace, so it can connect to the same path.
let dir = Path::new(crate::paths::STORAGE_ROOT);
let path = dir.join(format!("smolvm-con-{}-{}.sock", tail, seq));
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path)?;
Ok(Self { path, listener })
}
pub fn path(&self) -> &Path {
&self.path
}
/// Accept the runtime's connection and receive the PTY master fd via
/// `SCM_RIGHTS`. Bounded by `timeout` so a runtime that never connects (a
/// crun failure) can't hang the interactive session.
pub fn recv_master(&self, timeout: Duration) -> io::Result<PtyMaster> {
let mut pfd = libc::pollfd {
fd: self.listener.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let ms = timeout.as_millis().min(i32::MAX as u128) as i32;
let pr = unsafe { libc::poll(&mut pfd, 1, ms) };
if pr == 0 {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"console socket: runtime did not connect",
));
}
if pr < 0 {
return Err(io::Error::last_os_error());
}
let (conn, _) = self.listener.accept()?;
recv_fd(conn.as_raw_fd()).map(PtyMaster::from_owned_fd)
}
}
impl Drop for ConsoleSocket {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
/// Receive a single fd from a connected unix socket via `SCM_RIGHTS`.
fn recv_fd(sock_fd: RawFd) -> io::Result<OwnedFd> {
// 8-byte-aligned control buffer (cmsghdr requires size_t alignment).
#[repr(C, align(8))]
struct AlignedCmsg([u8; 32]);
let mut cmsg = AlignedCmsg([0u8; 32]);
let mut data = [0u8; 1];
let mut iov = libc::iovec {
iov_base: data.as_mut_ptr() as *mut libc::c_void,
iov_len: data.len(),
};
// SAFETY: zeroed msghdr is valid; pointers below reference live local buffers.
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1;
msg.msg_control = cmsg.0.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg.0.len() as _;
let n = unsafe { libc::recvmsg(sock_fd, &mut msg, 0) };
if n < 0 {
return Err(io::Error::last_os_error());
}
let cptr = unsafe { libc::CMSG_FIRSTHDR(&msg) };
if cptr.is_null() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"console socket: no SCM_RIGHTS control message",
));
}
// SAFETY: cptr is non-null and points into our cmsg buffer.
let chdr = unsafe { &*cptr };
if chdr.cmsg_level != libc::SOL_SOCKET || chdr.cmsg_type != libc::SCM_RIGHTS {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"console socket: unexpected control message",
));
}
let mut fd: RawFd = -1;
// SAFETY: CMSG_DATA points to at least size_of::<RawFd>() bytes here.
unsafe {
std::ptr::copy_nonoverlapping(
libc::CMSG_DATA(cptr),
&mut fd as *mut RawFd as *mut u8,
std::mem::size_of::<RawFd>(),
);
}
if fd < 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"console socket: invalid fd received",
));
}
// SAFETY: fd was just received from the kernel and is owned by us now.
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
/// Allocate a new PTY pair with the given initial window size.
///
/// Returns `(master, slave_fd)`. The caller must close `slave_fd` in the
/// parent process after the child has been spawned.
pub fn open_pty(cols: u16, rows: u16) -> io::Result<(PtyMaster, OwnedFd)> {
let mut master_raw: RawFd = -1;
let mut slave_raw: RawFd = -1;
// SAFETY: openpty writes into our pointers; we check the return value.
let ret = unsafe {
libc::openpty(
&mut master_raw,
&mut slave_raw,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if ret < 0 {
return Err(io::Error::last_os_error());
}
// Wrap in OwnedFd immediately so they get closed on error paths.
// SAFETY: openpty returned successfully, so these are valid fds.
let master_fd = unsafe { OwnedFd::from_raw_fd(master_raw) };
let slave_fd = unsafe { OwnedFd::from_raw_fd(slave_raw) };
let master = PtyMaster { fd: master_fd };
// Set initial window size.
master.set_window_size(cols, rows)?;
Ok((master, slave_fd))
}
/// Build a `pre_exec` closure that makes the child the session leader
/// and attaches the slave PTY as its controlling terminal + stdio.
///
/// # Safety
///
/// The returned closure is meant for `Command::pre_exec` which runs
/// between `fork()` and `exec()`. It calls only async-signal-safe
/// functions (`setsid`, `ioctl`, `dup2`, `close`).
pub fn slave_pre_exec(slave_fd: RawFd) -> impl FnMut() -> io::Result<()> {
move || {
// Create a new session (detach from parent's controlling terminal).
if unsafe { libc::setsid() } < 0 {
return Err(io::Error::last_os_error());
}
// Make the slave our controlling terminal.
// SAFETY: TIOCSCTTY with arg 0 on a valid pty slave fd after setsid().
if unsafe { libc::ioctl(slave_fd, libc::TIOCSCTTY as _, 0) } < 0 {
return Err(io::Error::last_os_error());
}
// Dup slave fd onto stdin/stdout/stderr.
for &target in &[0, 1, 2] {
if slave_fd != target {
if unsafe { libc::dup2(slave_fd, target) } < 0 {
return Err(io::Error::last_os_error());
}
}
}
// Close the original slave fd if it wasn't one of 0/1/2.
if slave_fd > 2 {
unsafe { libc::close(slave_fd) };
}
Ok(())
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Retry utilities for transient failure recovery.
//!
//! This module re-exports retry utilities from smolvm-protocol for use
//! in the agent. The shared implementation ensures consistent retry
//! behavior across host and guest.
// Re-export everything from the protocol's retry module
pub use smolvm_protocol::retry::*;
+253
View File
@@ -0,0 +1,253 @@
//! Guest-side Rosetta 2 setup.
//!
//! When the host attaches the RosettaLinux runtime (virtiofs tag
//! [`smolvm_protocol::ROSETTA_TAG`]) and signals [`guest_env::ROSETTA`], this
//! module mounts that runtime at [`smolvm_protocol::ROSETTA_GUEST_PATH`] and
//! registers the ptrace wrapper (`/usr/bin/rosetta-wrapper`, shipped in the
//! agent rootfs) with `binfmt_misc` as the interpreter for x86_64 ELF binaries.
//!
//! libkrun runs under Hypervisor.framework, not Virtualization.framework, so
//! Rosetta's runtime-validation ioctl fails; the wrapper intercepts that ioctl
//! via ptrace, returns the expected magic, then detaches for full-speed
//! execution. The `F` (fix-binary) flag pins the wrapper fd at registration
//! time so it keeps working inside crun containers whose mount namespaces don't
//! include the agent rootfs.
use smolvm_protocol::guest_env;
/// Guest path of the ptrace wrapper (installed into the agent rootfs by
/// `scripts/build-agent-rootfs.sh`).
#[cfg(target_os = "linux")]
const WRAPPER_PATH: &str = "/usr/bin/rosetta-wrapper";
/// binfmt_misc control directory and its registration file.
#[cfg(target_os = "linux")]
const BINFMT_MISC_DIR: &str = "/proc/sys/fs/binfmt_misc";
#[cfg(target_os = "linux")]
const BINFMT_REGISTER_FILE: &str = "/proc/sys/fs/binfmt_misc/register";
/// binfmt_misc registration line: match x86_64 ELF — both `ET_EXEC` and `ET_DYN`
/// (PIE), via the `\xfe` byte in the e_type mask — and route to the wrapper. The
/// magic/mask are literal `\xNN` escapes; the kernel's binfmt parser unescapes
/// them. Same magic/mask qemu-user uses for `qemu-x86_64`.
#[cfg(target_os = "linux")]
const BINFMT_REGISTER: &str = ":rosetta:M::\\x7fELF\\x02\\x01\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x02\\x00\\x3e\\x00:\\xff\\xff\\xff\\xff\\xff\\xfe\\xfe\\x00\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xff\\xfe\\xff\\xff\\xff:/usr/bin/rosetta-wrapper:F";
/// Whether the host requested Rosetta translation for this VM.
pub fn is_enabled() -> bool {
std::env::var(guest_env::ROSETTA).as_deref() == Ok(guest_env::VALUE_ON)
}
/// Bind-mount the Rosetta runtime into a workload container so the wrapper's
/// `execve("/mnt/rosetta/rosetta")` resolves inside the container's mount
/// namespace. binfmt_misc's `F` flag pins the wrapper fd itself, but the
/// translator it execs is an ordinary path lookup in the container's ns, so the
/// runtime must be visible there too. No-op unless Rosetta is enabled.
pub fn inject_into_container(spec: &mut crate::oci::OciSpec) {
inject_into_container_if(spec, is_enabled());
}
/// Testable core of [`inject_into_container`]: adds the read-only runtime
/// bind-mount when `enabled`. Split out so tests don't touch the process-wide
/// `SMOLVM_ROSETTA` env var.
fn inject_into_container_if(spec: &mut crate::oci::OciSpec, enabled: bool) {
if !enabled {
return;
}
// Read-only: the container only execs the translator, never writes it. Skip
// if the workload already mounts something at this path so a user mount wins.
if spec
.mounts
.iter()
.any(|m| m.destination == smolvm_protocol::ROSETTA_GUEST_PATH)
{
return;
}
spec.add_bind_mount(
smolvm_protocol::ROSETTA_GUEST_PATH,
smolvm_protocol::ROSETTA_GUEST_PATH,
true,
);
}
/// Mount the Rosetta runtime and register the `binfmt_misc` handler.
///
/// Best-effort: every failure is logged and swallowed so a Rosetta problem never
/// blocks the VM from booting (x86_64 workloads simply won't translate). A no-op
/// on non-Linux (the agent builds a macOS stub for host-side unit tests).
pub fn setup() {
#[cfg(target_os = "linux")]
{
use smolvm_protocol::ROSETTA_GUEST_PATH;
if let Err(e) = mount_runtime() {
tracing::warn!(error = %e, "rosetta: runtime mount failed; x86_64 translation unavailable");
return;
}
if !std::path::Path::new(WRAPPER_PATH).exists() {
tracing::warn!(
wrapper = WRAPPER_PATH,
"rosetta: wrapper missing from rootfs; x86_64 translation unavailable"
);
return;
}
if let Err(e) = register_binfmt() {
tracing::warn!(error = %e, "rosetta: binfmt_misc registration failed; x86_64 translation unavailable");
return;
}
tracing::info!(
mount = ROSETTA_GUEST_PATH,
"rosetta: x86_64 translation enabled"
);
}
}
/// Mount virtiofs tag [`ROSETTA_TAG`] at [`ROSETTA_GUEST_PATH`]. Idempotent: if
/// the translator is already visible there, the mount is left as-is.
#[cfg(target_os = "linux")]
fn mount_runtime() -> std::io::Result<()> {
use smolvm_protocol::{ROSETTA_GUEST_PATH, ROSETTA_TAG};
use std::ffi::CString;
std::fs::create_dir_all(ROSETTA_GUEST_PATH)?;
if std::path::Path::new(ROSETTA_GUEST_PATH)
.join("rosetta")
.exists()
{
return Ok(());
}
let src = CString::new(ROSETTA_TAG).expect("rosetta tag has no null byte");
let dst = CString::new(ROSETTA_GUEST_PATH).expect("rosetta path has no null byte");
let fstype = CString::new("virtiofs").expect("literal has no null byte");
// SAFETY: all args are valid null-terminated C strings; virtiofs takes no
// mount data (matches storage::setup_packed_layers).
let rc = unsafe {
libc::mount(
src.as_ptr(),
dst.as_ptr(),
fstype.as_ptr(),
0,
std::ptr::null(),
)
};
if rc != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
/// Mount `binfmt_misc` if needed and write the x86_64→wrapper registration.
/// Idempotent: skips if a `rosetta` handler is already registered on this kernel.
#[cfg(target_os = "linux")]
fn register_binfmt() -> std::io::Result<()> {
use std::ffi::CString;
// The /register control file only exists once binfmt_misc is mounted. Some
// kernels auto-mount it; mount it ourselves otherwise.
if !std::path::Path::new(BINFMT_REGISTER_FILE).exists() {
std::fs::create_dir_all(BINFMT_MISC_DIR)?;
let src = CString::new("binfmt_misc").unwrap();
let dst = CString::new(BINFMT_MISC_DIR).unwrap();
let fstype = CString::new("binfmt_misc").unwrap();
// SAFETY: valid C strings; binfmt_misc takes no mount data.
let rc = unsafe {
libc::mount(
src.as_ptr(),
dst.as_ptr(),
fstype.as_ptr(),
0,
std::ptr::null(),
)
};
if rc != 0 {
return Err(std::io::Error::last_os_error());
}
}
if std::path::Path::new(BINFMT_MISC_DIR)
.join("rosetta")
.exists()
{
return Ok(());
}
std::fs::write(BINFMT_REGISTER_FILE, BINFMT_REGISTER)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oci::{OciSpec, ProcessIdentity};
fn empty_spec() -> OciSpec {
OciSpec::new(
&["true".to_string()],
&[],
"/",
false,
&ProcessIdentity::root(),
false,
)
}
#[test]
fn inject_is_noop_when_disabled() {
let mut spec = empty_spec();
let mounts_before = spec.mounts.len();
inject_into_container_if(&mut spec, false);
assert_eq!(spec.mounts.len(), mounts_before);
}
#[test]
fn inject_adds_readonly_runtime_mount_when_enabled() {
let mut spec = empty_spec();
inject_into_container_if(&mut spec, true);
let mount = spec
.mounts
.iter()
.find(|m| m.destination == smolvm_protocol::ROSETTA_GUEST_PATH)
.expect("rosetta runtime bind mount not found");
assert_eq!(mount.source, smolvm_protocol::ROSETTA_GUEST_PATH);
assert_eq!(mount.mount_type.as_deref(), Some("bind"));
// Read-only: the container only execs the translator.
assert!(mount.options.iter().any(|o| o == "ro"));
assert!(mount.options.iter().any(|o| o == "bind"));
}
#[test]
fn inject_does_not_duplicate_existing_mount() {
let mut spec = empty_spec();
// A user mount already claims the path; injection must not add a second.
spec.add_bind_mount(
smolvm_protocol::ROSETTA_GUEST_PATH,
smolvm_protocol::ROSETTA_GUEST_PATH,
false,
);
let mounts_before = spec.mounts.len();
inject_into_container_if(&mut spec, true);
assert_eq!(spec.mounts.len(), mounts_before);
}
#[test]
fn is_enabled_reflects_env_sentinel() {
// Save/restore to avoid cross-test env bleed.
let prev = std::env::var(guest_env::ROSETTA).ok();
std::env::remove_var(guest_env::ROSETTA);
assert!(!is_enabled(), "unset must be disabled");
std::env::set_var(guest_env::ROSETTA, guest_env::VALUE_ON);
assert!(is_enabled(), "sentinel value must enable");
std::env::set_var(guest_env::ROSETTA, "0");
assert!(!is_enabled(), "non-sentinel value must be disabled");
match prev {
Some(v) => std::env::set_var(guest_env::ROSETTA, v),
None => std::env::remove_var(guest_env::ROSETTA),
}
}
}
+421
View File
@@ -0,0 +1,421 @@
//! Guest-side SSH agent bridge.
//!
//! Listens on a Unix socket inside the VM and relays connections to the
//! host's SSH agent via vsock. Guest applications (git, ssh) connect to
//! this socket transparently via `SSH_AUTH_SOCK`.
use smolvm_protocol::ports;
use std::io;
use std::os::unix::net::UnixListener;
use std::thread;
/// Guest-side path for the SSH agent socket.
pub const GUEST_SSH_AUTH_SOCK: &str = "/tmp/ssh-agent.sock";
/// Start the guest-side SSH agent bridge in a background thread.
///
/// Creates a Unix socket at [`GUEST_SSH_AUTH_SOCK`] and, for each incoming
/// connection, opens a vsock connection to the host-side bridge on
/// [`ports::SSH_AGENT`] and relays bytes bidirectionally.
pub fn start() {
thread::Builder::new()
.name("ssh-agent-guest".into())
.spawn(|| {
if let Err(e) = run_bridge() {
tracing::warn!(error = %e, "guest SSH agent bridge stopped");
}
})
.ok();
}
/// Check if SSH agent forwarding is enabled via environment variable.
pub fn is_enabled() -> bool {
std::env::var("SMOLVM_SSH_AGENT").as_deref() == Ok("1")
}
/// Inject SSH agent forwarding into an OCI container spec.
///
/// When forwarding is enabled (`SMOLVM_SSH_AGENT=1`), bind-mount the
/// guest-side bridge socket into the container at the same path and set
/// `SSH_AUTH_SOCK` so tools inside the container can find it. No-op when
/// forwarding is disabled.
///
/// Rationale: the container lives in its own mount namespace (so
/// `/tmp/ssh-agent.sock` from the VM rootfs is not visible) and gets env
/// from the image + request (not from the agent's own env), so both the
/// file and the variable have to be wired in explicitly.
pub fn inject_into_container(spec: &mut crate::oci::OciSpec) {
inject_into_container_if(spec, is_enabled());
}
/// Testable core of [`inject_into_container`]. Bind-mounts the bridge
/// socket and sets the env var when `enabled` is true; no-op otherwise.
/// Split out so tests can exercise the injection logic without mutating
/// the process-wide `SMOLVM_SSH_AGENT` env variable.
fn inject_into_container_if(spec: &mut crate::oci::OciSpec, enabled: bool) {
if !enabled {
return;
}
// Bind-mount the socket file; rw because SSH agent protocol is bidirectional.
spec.add_bind_mount(GUEST_SSH_AUTH_SOCK, GUEST_SSH_AUTH_SOCK, false);
spec.add_env("SSH_AUTH_SOCK", GUEST_SSH_AUTH_SOCK);
}
/// Add `SSH_AUTH_SOCK` to a command's env list when forwarding is enabled.
///
/// [`inject_into_container`] wires the socket + env into a *container spec*, but a
/// command run via `crun exec` into the persistent keep-alive container — the path
/// non-interactive exec + run take since #542 — gets a fresh process env, not the
/// container's, so the spec injection never reaches it. This wires the variable
/// into that exec/run env. No-op when disabled; never overrides an existing value
/// (e.g. a user-supplied `-e SSH_AUTH_SOCK`).
pub fn inject_into_env(env: &mut Vec<(String, String)>) {
inject_into_env_if(env, is_enabled());
}
/// Testable core of [`inject_into_env`].
fn inject_into_env_if(env: &mut Vec<(String, String)>, enabled: bool) {
if enabled && !env.iter().any(|(k, _)| k == "SSH_AUTH_SOCK") {
env.push(("SSH_AUTH_SOCK".to_string(), GUEST_SSH_AUTH_SOCK.to_string()));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oci::{OciSpec, ProcessIdentity};
// Regression guard for #542: non-interactive exec + run go through the
// keep-alive container's `crun exec`, whose process env is built fresh (not
// inherited from the container), so SSH_AUTH_SOCK must be injected into that
// env explicitly — the container-spec injection alone doesn't reach it.
#[test]
fn inject_into_env_adds_ssh_auth_sock_only_when_enabled() {
// Enabled → injected.
let mut env = vec![("PATH".to_string(), "/usr/bin".to_string())];
inject_into_env_if(&mut env, true);
assert!(
env.iter()
.any(|(k, v)| k == "SSH_AUTH_SOCK" && v == GUEST_SSH_AUTH_SOCK),
"SSH_AUTH_SOCK must be injected into the exec/run env when forwarding is enabled"
);
// Disabled → no-op.
let mut env = vec![("PATH".to_string(), "/usr/bin".to_string())];
inject_into_env_if(&mut env, false);
assert!(!env.iter().any(|(k, _)| k == "SSH_AUTH_SOCK"));
// Never overrides a user-supplied value.
let mut env = vec![("SSH_AUTH_SOCK".to_string(), "/custom.sock".to_string())];
inject_into_env_if(&mut env, true);
assert_eq!(env.iter().filter(|(k, _)| k == "SSH_AUTH_SOCK").count(), 1);
assert_eq!(env[0].1, "/custom.sock");
}
#[test]
fn inject_is_noop_when_disabled() {
let mut spec = OciSpec::new(
&["true".to_string()],
&[],
"/",
false,
&ProcessIdentity::root(),
false,
);
let mounts_before = spec.mounts.len();
let envs_before = spec.process.env.len();
inject_into_container_if(&mut spec, false);
assert_eq!(spec.mounts.len(), mounts_before);
assert_eq!(spec.process.env.len(), envs_before);
assert!(!spec
.process
.env
.iter()
.any(|e| e.starts_with("SSH_AUTH_SOCK=")));
}
#[test]
fn inject_adds_env_and_mount_when_enabled() {
let mut spec = OciSpec::new(
&["true".to_string()],
&[],
"/",
false,
&ProcessIdentity::root(),
false,
);
inject_into_container_if(&mut spec, true);
// Env must point at the guest-side bridge socket.
assert!(spec
.process
.env
.iter()
.any(|e| e == &format!("SSH_AUTH_SOCK={}", GUEST_SSH_AUTH_SOCK)));
// Mount must bind the socket at the same path inside the container.
let mount = spec
.mounts
.iter()
.find(|m| m.destination == GUEST_SSH_AUTH_SOCK)
.expect("bind mount for SSH agent socket not found");
assert_eq!(mount.source, GUEST_SSH_AUTH_SOCK);
assert_eq!(mount.mount_type.as_deref(), Some("bind"));
// rw: the SSH agent protocol is bidirectional.
assert!(!mount.options.iter().any(|o| o == "ro"));
assert!(mount.options.iter().any(|o| o == "bind"));
}
#[test]
fn inject_replaces_existing_ssh_auth_sock() {
// Simulates an image whose config already exports a stale
// SSH_AUTH_SOCK: we must replace it, not duplicate it — two entries
// for the same key leaves the effective value shell-dependent.
let mut spec = OciSpec::new(
&["true".to_string()],
&[],
"/",
false,
&ProcessIdentity::root(),
false,
);
spec.process
.env
.push("SSH_AUTH_SOCK=/stale/path".to_string());
inject_into_container_if(&mut spec, true);
let matches: Vec<_> = spec
.process
.env
.iter()
.filter(|e| e.starts_with("SSH_AUTH_SOCK="))
.collect();
assert_eq!(matches.len(), 1, "duplicate SSH_AUTH_SOCK entries");
assert_eq!(
matches[0],
&format!("SSH_AUTH_SOCK={}", GUEST_SSH_AUTH_SOCK)
);
}
}
fn run_bridge() -> io::Result<()> {
let sock_path = std::path::Path::new(GUEST_SSH_AUTH_SOCK);
// Clean up stale socket
let _ = std::fs::remove_file(sock_path);
// Ensure parent directory exists
if let Some(parent) = sock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let listener = UnixListener::bind(sock_path)?;
// Make socket accessible to all users in the VM
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(sock_path, std::fs::Permissions::from_mode(0o777))?;
}
tracing::info!(
path = GUEST_SSH_AUTH_SOCK,
vsock_port = ports::SSH_AGENT,
"guest SSH agent bridge listening"
);
for stream in listener.incoming() {
match stream {
Ok(local_conn) => {
thread::Builder::new()
.name("ssh-agent-fwd".into())
.spawn(move || {
if let Err(e) = relay_to_host(local_conn) {
tracing::debug!(error = %e, "SSH agent relay ended");
}
})
.ok();
}
Err(e) => {
tracing::debug!(error = %e, "guest SSH agent accept error");
if e.kind() == io::ErrorKind::InvalidInput {
break;
}
}
}
}
Ok(())
}
/// Connect to the host SSH agent via vsock and relay bytes.
/// Bidirectional relay between a local Unix socket and a vsock connection.
///
/// Uses `poll()` to multiplex reads on both sides, forwarding data in
/// whichever direction is ready. This handles fragmented messages and
/// concurrent I/O correctly — no assumptions about request/response ordering.
#[cfg(target_os = "linux")]
fn relay_to_host(local: std::os::unix::net::UnixStream) -> io::Result<()> {
use std::os::unix::io::AsRawFd;
let mut vsock_conn = vsock_connect(ports::SSH_AGENT)?;
let mut local = local;
let local_fd = local.as_raw_fd();
let vsock_fd = vsock_conn.as_raw_fd();
let mut buf = [0u8; 16384];
loop {
let mut poll_fds = [
libc::pollfd {
fd: local_fd,
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: vsock_fd,
events: libc::POLLIN,
revents: 0,
},
];
let ret = unsafe { libc::poll(poll_fds.as_mut_ptr(), 2, 30_000) };
if ret < 0 {
return Err(io::Error::last_os_error());
}
if ret == 0 {
// Timeout — SSH agent connections are short-lived, clean up
break;
}
// local → vsock
if poll_fds[0].revents & (libc::POLLIN | libc::POLLHUP) != 0 {
let n = io::Read::read(&mut local, &mut buf)?;
if n == 0 {
break;
}
io::Write::write_all(&mut vsock_conn, &buf[..n])?;
}
// vsock → local
if poll_fds[1].revents & (libc::POLLIN | libc::POLLHUP) != 0 {
let n = io::Read::read(&mut vsock_conn, &mut buf)?;
if n == 0 {
break;
}
io::Write::write_all(&mut local, &buf[..n])?;
}
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn relay_to_host(_local: std::os::unix::net::UnixStream) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"SSH agent forwarding only supported on Linux guests",
))
}
// ============================================================================
// vsock client connect (guest → host)
// ============================================================================
/// Wrapper around a vsock file descriptor that implements Read + Write.
#[cfg(target_os = "linux")]
struct VsockStream {
fd: std::os::unix::io::OwnedFd,
}
#[cfg(target_os = "linux")]
impl std::os::unix::io::AsRawFd for VsockStream {
fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
self.fd.as_raw_fd()
}
}
#[cfg(target_os = "linux")]
impl io::Read for VsockStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
use std::os::fd::AsRawFd;
unsafe {
let n = libc::read(self.fd.as_raw_fd(), buf.as_mut_ptr() as *mut _, buf.len());
if n < 0 {
Err(io::Error::last_os_error())
} else {
Ok(n as usize)
}
}
}
}
#[cfg(target_os = "linux")]
impl io::Write for VsockStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
use std::os::fd::AsRawFd;
unsafe {
let n = libc::write(self.fd.as_raw_fd(), buf.as_ptr() as *const _, buf.len());
if n < 0 {
Err(io::Error::last_os_error())
} else {
Ok(n as usize)
}
}
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
/// Connect to a vsock port on the host (CID 2).
#[cfg(target_os = "linux")]
fn vsock_connect(port: u32) -> io::Result<VsockStream> {
use std::mem;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
const AF_VSOCK: libc::c_int = 40;
const HOST_CID: u32 = 2;
#[repr(C)]
struct sockaddr_vm {
svm_family: libc::sa_family_t,
svm_reserved1: u16,
svm_port: u32,
svm_cid: u32,
svm_zero: [u8; 4],
}
unsafe {
let fd = libc::socket(AF_VSOCK, libc::SOCK_STREAM, 0);
if fd < 0 {
return Err(io::Error::last_os_error());
}
let fd = OwnedFd::from_raw_fd(fd);
let addr = sockaddr_vm {
svm_family: AF_VSOCK as u16,
svm_reserved1: 0,
svm_port: port,
svm_cid: HOST_CID,
svm_zero: [0; 4],
};
if libc::connect(
fd.as_raw_fd(),
&addr as *const sockaddr_vm as *const libc::sockaddr,
mem::size_of::<sockaddr_vm>() as libc::socklen_t,
) < 0
{
return Err(io::Error::last_os_error());
}
Ok(VsockStream { fd })
}
}
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
//! Guest wall-clock sync from the host over a vsock DGRAM (port 123).
//!
//! On hypervisors without a guest-readable paravirt clock (macOS/HVF and
//! Windows/WHP), libkrun's host-side `TimesyncThread` pushes the host's
//! `CLOCK_REALTIME` to the guest — at boot, every 60s, and (critically) as soon
//! as it detects the host was suspended (lid closed / sleep). During host sleep
//! the VM is frozen, so the guest clock stops; without a receiver it falls
//! behind by the accumulated sleep and eventually breaks TLS / signed package
//! indexes inside the guest (issue #521). The agent runs as PID-1 with
//! `CAP_SYS_TIME`, so — unlike the unprivileged container — it can apply the fix.
//!
//! Each host datagram is an 8-byte little-endian u64: host `CLOCK_REALTIME` in
//! nanoseconds (see libkrun `vsock/timesync.rs::write_time_sync`). Mirrors
//! libkrun's own guest reader (`init/src/timesync.rs`), but as a thread since
//! the agent is long-lived (it never `exec`s away, so it needs no `fork`).
use std::mem;
const AF_VSOCK: libc::c_int = 40;
const VMADDR_CID_ANY: u32 = u32::MAX;
const TSYNC_PORT: u32 = 123;
const NANOS_IN_SECOND: u64 = 1_000_000_000;
/// Ignore sub-100ms drift — matches libkrun's guest reader.
const DELTA_SYNC_NS: u64 = 100_000_000;
#[repr(C)]
struct sockaddr_vm {
svm_family: libc::sa_family_t,
svm_reserved1: u16,
svm_port: u32,
svm_cid: u32,
svm_zero: [u8; 4],
}
/// Spawn the host→guest clock-sync receiver as a background thread. Best-effort:
/// on any setup error the thread exits and the boot-seeded clock stands.
pub fn spawn() {
let _ = std::thread::Builder::new()
.name("timesync".into())
.spawn(run);
}
fn run() {
// AF_VSOCK DGRAM bound to port 123 — the port libkrun's TimesyncThread sends
// to. VMADDR_CID_ANY: accept from the host CID.
let fd = unsafe { libc::socket(AF_VSOCK, libc::SOCK_DGRAM, 0) };
if fd < 0 {
return;
}
let addr = sockaddr_vm {
svm_family: AF_VSOCK as libc::sa_family_t,
svm_reserved1: 0,
svm_port: TSYNC_PORT,
svm_cid: VMADDR_CID_ANY,
svm_zero: [0; 4],
};
let rc = unsafe {
libc::bind(
fd,
&addr as *const sockaddr_vm as *const libc::sockaddr,
mem::size_of::<sockaddr_vm>() as libc::socklen_t,
)
};
if rc < 0 {
unsafe { libc::close(fd) };
return;
}
let mut buf = [0u8; 8];
loop {
let n = unsafe { libc::recv(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len(), 0) };
if n < 0 {
break; // socket error — give up (the boot clock stands)
}
if n != 8 {
continue; // malformed datagram — ignore
}
let host_ns = u64::from_le_bytes(buf);
if host_ns.abs_diff(current_realtime_ns()) > DELTA_SYNC_NS {
let ts = libc::timespec {
tv_sec: (host_ns / NANOS_IN_SECOND) as libc::time_t,
tv_nsec: (host_ns % NANOS_IN_SECOND) as libc::c_long,
};
// SAFETY: ts is a valid timespec; the agent is PID-1 with CAP_SYS_TIME.
unsafe { libc::clock_settime(libc::CLOCK_REALTIME, &ts) };
}
}
unsafe { libc::close(fd) };
}
fn current_realtime_ns() -> u64 {
let mut ts = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
// SAFETY: ts is a valid, writable timespec.
unsafe { libc::clock_gettime(libc::CLOCK_REALTIME, &mut ts) };
ts.tv_sec as u64 * NANOS_IN_SECOND + ts.tv_nsec as u64
}
+240
View File
@@ -0,0 +1,240 @@
//! vsock support for the helper daemon.
//!
//! This module provides vsock server functionality for Linux guests.
use std::io::{Read, Write};
#[cfg(target_os = "linux")]
use std::os::fd::FromRawFd;
use std::os::fd::OwnedFd;
use std::os::unix::io::AsRawFd;
/// vsock listener.
pub struct VsockListener {
#[allow(dead_code)] // Accessed via AsRawFd trait
fd: OwnedFd,
}
/// vsock stream (connection).
pub struct VsockStream {
#[allow(dead_code)] // Accessed via AsRawFd trait
fd: OwnedFd,
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
use std::mem;
// vsock constants
const AF_VSOCK: libc::c_int = 40;
const VMADDR_CID_ANY: u32 = u32::MAX;
#[repr(C)]
struct sockaddr_vm {
svm_family: libc::sa_family_t,
svm_reserved1: u16,
svm_port: u32,
svm_cid: u32,
svm_zero: [u8; 4],
}
impl VsockListener {
/// Create a new vsock listener on the given port.
pub fn bind(port: u32) -> std::io::Result<Self> {
unsafe {
// Create socket
let fd = libc::socket(AF_VSOCK, libc::SOCK_STREAM, 0);
if fd < 0 {
return Err(std::io::Error::last_os_error());
}
let fd = OwnedFd::from_raw_fd(fd);
// Bind to port
let addr = sockaddr_vm {
svm_family: AF_VSOCK as u16,
svm_reserved1: 0,
svm_port: port,
svm_cid: VMADDR_CID_ANY,
svm_zero: [0; 4],
};
if libc::bind(
fd.as_raw_fd(),
&addr as *const sockaddr_vm as *const libc::sockaddr,
mem::size_of::<sockaddr_vm>() as libc::socklen_t,
) < 0
{
return Err(std::io::Error::last_os_error());
}
// Listen
if libc::listen(fd.as_raw_fd(), 1) < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(Self { fd })
}
}
/// Accept a new connection.
pub fn accept(&self) -> std::io::Result<VsockStream> {
unsafe {
let fd = libc::accept(
self.fd.as_raw_fd(),
std::ptr::null_mut(),
std::ptr::null_mut(),
);
if fd < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(VsockStream {
fd: OwnedFd::from_raw_fd(fd),
})
}
}
}
}
#[cfg(not(target_os = "linux"))]
mod stub {
use super::*;
impl VsockListener {
pub fn bind(_port: u32) -> std::io::Result<Self> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"vsock only supported on Linux",
))
}
pub fn accept(&self) -> std::io::Result<VsockStream> {
unreachable!()
}
}
}
#[cfg(target_os = "linux")]
impl AsRawFd for VsockStream {
fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
self.fd.as_raw_fd()
}
}
#[cfg(target_os = "linux")]
impl Read for VsockStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
unsafe {
let n = libc::read(self.fd.as_raw_fd(), buf.as_mut_ptr() as *mut _, buf.len());
if n < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(n as usize)
}
}
}
}
#[cfg(target_os = "linux")]
impl Write for VsockStream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
unsafe {
let n = libc::write(self.fd.as_raw_fd(), buf.as_ptr() as *const _, buf.len());
if n < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(n as usize)
}
}
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[cfg(not(target_os = "linux"))]
impl AsRawFd for VsockStream {
fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
unreachable!("vsock only supported on Linux")
}
}
#[cfg(not(target_os = "linux"))]
impl Read for VsockStream {
fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
unreachable!("vsock only supported on Linux")
}
}
#[cfg(not(target_os = "linux"))]
impl Write for VsockStream {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
unreachable!("vsock only supported on Linux")
}
fn flush(&mut self) -> std::io::Result<()> {
unreachable!("vsock only supported on Linux")
}
}
/// Listen on a vsock port.
pub fn listen(port: u32) -> std::io::Result<VsockListener> {
VsockListener::bind(port)
}
/// Connect to a vsock port on the host (CID 2).
#[cfg(target_os = "linux")]
pub fn connect(port: u32) -> std::io::Result<VsockStream> {
use std::mem;
use std::os::fd::FromRawFd;
const AF_VSOCK: libc::c_int = 40;
const HOST_CID: u32 = 2;
#[repr(C)]
struct sockaddr_vm {
svm_family: libc::sa_family_t,
svm_reserved1: u16,
svm_port: u32,
svm_cid: u32,
svm_zero: [u8; 4],
}
unsafe {
let fd = libc::socket(AF_VSOCK, libc::SOCK_STREAM, 0);
if fd < 0 {
return Err(std::io::Error::last_os_error());
}
let fd = OwnedFd::from_raw_fd(fd);
let addr = sockaddr_vm {
svm_family: AF_VSOCK as u16,
svm_reserved1: 0,
svm_port: port,
svm_cid: HOST_CID,
svm_zero: [0; 4],
};
if libc::connect(
fd.as_raw_fd(),
&addr as *const sockaddr_vm as *const libc::sockaddr,
mem::size_of::<sockaddr_vm>() as libc::socklen_t,
) < 0
{
return Err(std::io::Error::last_os_error());
}
Ok(VsockStream { fd })
}
}
/// Connect stub for non-Linux platforms.
#[cfg(not(target_os = "linux"))]
pub fn connect(_port: u32) -> std::io::Result<VsockStream> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"vsock only supported on Linux",
))
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "smolvm-cuda-codegen"
version = "1.5.2"
edition = "2021"
description = "Generates forward-to-host-lib marshaling (guest stubs + host dispatch) from a function spec"
license = "Apache-2.0"
[[bin]]
name = "smolvm-cuda-codegen"
path = "src/main.rs"
+839
View File
@@ -0,0 +1,839 @@
//! Forward-to-host-lib marshaling generator.
//!
//! CUDA's library surface is hundreds of functions across cudart, cuBLAS,
//! cuDNN, … Hand-writing the guest stub + host dispatch + (de)serialization for
//! each is unmaintainable. This tool takes a compact per-function spec and emits
//! both sides of the marshaling over the generic `LibCall` transport
//! (`smolvm_cuda::proto::Op::LibCall`), so adding a function is a few lines of
//! spec, not code across five files.
//!
//! Parameter kinds capture the one thing a C signature can't: whether a pointer
//! is an opaque host **Handle**, a **DevPtr** (real device address), or a
//! **HostIn** scalar to read and ship by value. Everything else follows.
//!
//! Emits two files:
//! - `<out>/<lib>_guest.rs` — `extern "C"` stubs, `include!`d by the guest shim.
//! - `<out>/<lib>_host.rs` — dlsym table + `dispatch(func, args)`, `include!`d by
//! the host backend.
//!
//! This is a spec-driven generator; the specs below are hand-authored today, but
//! nothing stops a future front-end from emitting them from the CUDA headers.
use std::fmt::Write as _;
/// How one parameter is marshaled.
#[derive(Clone, Copy)]
enum Kind {
/// A machine scalar passed by value. `.0` is the Rust scalar type.
Scalar(&'static str),
/// An opaque host handle (cuBLAS/cuDNN handle, …) — pass the pointer
/// value by reference; the guest never dereferences it.
Handle,
/// A `cudaStream_t`. Streams are session-mapped small ids on the wire (the
/// host mints them at StreamCreate), so the host must translate through the
/// session stream table — NOT vh_resolve — or the raw id reaches the real
/// library as a pointer and crashes it. 0 and the legacy constants
/// (0x1/0x2) pass through.
Stream,
/// A real device address — pass by value.
DevPtr,
/// A host input scalar behind a pointer (cuBLAS alpha/beta) — read `*p` on
/// the guest, ship the value, rebuild a pointer to a local on the host.
HostInScalar(&'static str),
/// A host output scalar behind a pointer (cuDNN workspace size) — the host
/// fills a local and ships it back; the guest writes it through `*p`. `.0` is
/// the wire scalar type; the pointee type comes from the param's `cty`.
HostOutScalar(&'static str),
}
/// The pointee of a `*mut T` C type (for building a local of the right type).
fn pointee(cty: &str) -> &str {
cty.strip_prefix("*mut ").unwrap_or(cty)
}
/// Wire size in bytes of a scalar type name.
fn wire_size(t: &str) -> usize {
match t {
"u16" => 2, // __half forwarded bitwise
"i32" | "u32" | "f32" => 4,
_ => 8,
}
}
/// One parameter: its name, the Rust type in the `extern "C"` signature, and how
/// to marshal it.
struct P {
name: &'static str,
cty: &'static str,
kind: Kind,
}
const fn p(name: &'static str, cty: &'static str, kind: Kind) -> P {
P { name, cty, kind }
}
/// One function to generate.
struct Fun {
/// Exported symbol name (what the guest program links).
sym: &'static str,
/// Real host symbol to dlsym (usually the same).
real: &'static str,
params: Vec<P>,
}
/// A library: an id (matches the guest shim's `LIB_*` constant) and its
/// functions, indexed in order (the index is the `func` id on the wire).
struct Lib {
name: &'static str,
id: u8,
/// Default soname to dlopen on the host if the env override is unset.
/// Sonames to probe via `open_host_lib`, most-specific last.
default_sos: &'static [&'static str],
funcs: Vec<Fun>,
}
fn cublas_spec() -> Lib {
use Kind::*;
let handle = || p("handle", "*mut c_void", Handle);
let i = |n| p(n, "c_int", Scalar("i32"));
// Column-major GEMM shared shape for S/D, differing only in element type.
let gemm = |sym, real, ety: &'static str| Fun {
sym,
real,
params: vec![
handle(),
i("transa"),
i("transb"),
i("m"),
i("n"),
i("k"),
p("alpha", leak(format!("*const {ety}")), HostInScalar(ety)),
p("A", leak(format!("*const {ety}")), DevPtr),
i("lda"),
p("B", leak(format!("*const {ety}")), DevPtr),
i("ldb"),
p("beta", leak(format!("*const {ety}")), HostInScalar(ety)),
p("C", leak(format!("*mut {ety}")), DevPtr),
i("ldc"),
],
};
// Strided-batched GEMM (PyTorch's `bmm`): same as gemm plus per-operand
// strides (i64) and a batch count.
let s64 = |n| p(n, "i64", Scalar("i64"));
let gemm_strided = |sym, real, ety: &'static str| Fun {
sym,
real,
params: vec![
handle(),
i("transa"),
i("transb"),
i("m"),
i("n"),
i("k"),
p("alpha", leak(format!("*const {ety}")), HostInScalar(ety)),
p("A", leak(format!("*const {ety}")), DevPtr),
i("lda"),
s64("strideA"),
p("B", leak(format!("*const {ety}")), DevPtr),
i("ldb"),
s64("strideB"),
p("beta", leak(format!("*const {ety}")), HostInScalar(ety)),
p("C", leak(format!("*mut {ety}")), DevPtr),
i("ldc"),
s64("strideC"),
i("batchCount"),
],
};
// ---- BLAS Level 1/2/3 (device-pointer ops with host scalars) ----
// Reduction ops (dot/nrm2/asum/amax) are omitted deliberately: their
// result pointer is host- or device-resident depending on the handle's
// pointer mode, which this uniform marshaling can't disambiguate — they
// stay honest NOT_SUPPORTED stubs rather than risk a silently-wrong result.
let ic = i; // alias for closures below
let dp = |n, ety: &'static str, m: bool| {
p(
n,
leak(format!("*{} {ety}", if m { "mut" } else { "const" })),
DevPtr,
)
};
let hin = |n, ety: &'static str| p(n, leak(format!("*const {ety}")), HostInScalar(ety));
let axpy = |sym, real, ety: &'static str| Fun {
sym,
real,
params: vec![
handle(),
ic("n"),
hin("alpha", ety),
dp("x", ety, false),
ic("incx"),
dp("y", ety, true),
ic("incy"),
],
};
let scal = |sym, real, ety: &'static str| Fun {
sym,
real,
params: vec![
handle(),
ic("n"),
hin("alpha", ety),
dp("x", ety, true),
ic("incx"),
],
};
let copyswap = |sym, real, ety: &'static str| Fun {
sym,
real,
params: vec![
handle(),
ic("n"),
dp("x", ety, true),
ic("incx"),
dp("y", ety, true),
ic("incy"),
],
};
let gemv = |sym, real, ety: &'static str| Fun {
sym,
real,
params: vec![
handle(),
ic("trans"),
ic("m"),
ic("n"),
hin("alpha", ety),
dp("A", ety, false),
ic("lda"),
dp("x", ety, false),
ic("incx"),
hin("beta", ety),
dp("y", ety, true),
ic("incy"),
],
};
let ger = |sym, real, ety: &'static str| Fun {
sym,
real,
params: vec![
handle(),
ic("m"),
ic("n"),
hin("alpha", ety),
dp("x", ety, false),
ic("incx"),
dp("y", ety, false),
ic("incy"),
dp("A", ety, true),
ic("lda"),
],
};
let geam = |sym, real, ety: &'static str| Fun {
sym,
real,
params: vec![
handle(),
ic("transa"),
ic("transb"),
ic("m"),
ic("n"),
hin("alpha", ety),
dp("A", ety, false),
ic("lda"),
hin("beta", ety),
dp("B", ety, false),
ic("ldb"),
dp("C", ety, true),
ic("ldc"),
],
};
let syrk = |sym, real, ety: &'static str| Fun {
sym,
real,
params: vec![
handle(),
ic("uplo"),
ic("trans"),
ic("n"),
ic("k"),
hin("alpha", ety),
dp("A", ety, false),
ic("lda"),
hin("beta", ety),
dp("C", ety, true),
ic("ldc"),
],
};
let symm = |sym, real, ety: &'static str| Fun {
sym,
real,
params: vec![
handle(),
ic("side"),
ic("uplo"),
ic("m"),
ic("n"),
hin("alpha", ety),
dp("A", ety, false),
ic("lda"),
dp("B", ety, false),
ic("ldb"),
hin("beta", ety),
dp("C", ety, true),
ic("ldc"),
],
};
let trsm = |sym, real, ety: &'static str| Fun {
sym,
real,
params: vec![
handle(),
ic("side"),
ic("uplo"),
ic("trans"),
ic("diag"),
ic("m"),
ic("n"),
hin("alpha", ety),
dp("A", ety, false),
ic("lda"),
dp("B", ety, true),
ic("ldb"),
],
};
Lib {
name: "cublas",
id: 1,
default_sos: &["libcublas.so", "libcublas.so.13", "libcublas.so.12"],
funcs: vec![
Fun {
sym: "cublasCreate_v2",
real: "cublasCreate_v2",
params: vec![p("handle_out", "*mut *mut c_void", Handle)], // out-handle: special-cased below
},
Fun {
sym: "cublasDestroy_v2",
real: "cublasDestroy_v2",
params: vec![handle()],
},
gemm("cublasSgemm_v2", "cublasSgemm_v2", "f32"),
// __half forwarded bitwise as u16 (the value is never interpreted
// guest-side; the host passes a pointer to the same 2 bytes).
gemm("cublasHgemm", "cublasHgemm", "u16"),
gemm("cublasDgemm_v2", "cublasDgemm_v2", "f64"),
gemm_strided(
"cublasSgemmStridedBatched",
"cublasSgemmStridedBatched",
"f32",
),
gemm_strided(
"cublasDgemmStridedBatched",
"cublasDgemmStridedBatched",
"f64",
),
// Stream + config that PyTorch sets up before a matmul.
Fun {
sym: "cublasSetStream_v2",
real: "cublasSetStream_v2",
params: vec![handle(), p("stream", "*mut c_void", Stream)],
},
Fun {
sym: "cublasSetWorkspace_v2",
real: "cublasSetWorkspace_v2",
params: vec![
handle(),
p("workspace", "*mut c_void", DevPtr),
p("size", "usize", Scalar("u64")),
],
},
Fun {
sym: "cublasSetMathMode",
real: "cublasSetMathMode",
params: vec![handle(), i("mode")],
},
Fun {
sym: "cublasGetMathMode",
real: "cublasGetMathMode",
params: vec![handle(), p("mode", "*mut c_int", HostOutScalar("i32"))],
},
Fun {
sym: "cublasGetProperty",
real: "cublasGetProperty",
params: vec![
i("prop_type"),
p("value", "*mut c_int", HostOutScalar("i32")),
],
},
// cublasGemmEx — PyTorch's general GEMM. alpha/beta are the compute
// type (f32 for the common CUBLAS_COMPUTE_32F path).
Fun {
sym: "cublasGemmEx",
real: "cublasGemmEx",
params: vec![
handle(),
i("transa"),
i("transb"),
i("m"),
i("n"),
i("k"),
p("alpha", "*const f32", HostInScalar("f32")),
p("A", "*const c_void", DevPtr),
i("Atype"),
i("lda"),
p("B", "*const c_void", DevPtr),
i("Btype"),
i("ldb"),
p("beta", "*const f32", HostInScalar("f32")),
p("C", "*mut c_void", DevPtr),
i("Ctype"),
i("ldc"),
i("computeType"),
i("algo"),
],
},
// cublasGemmStridedBatchedEx — PyTorch's mixed-precision batched GEMM
// (fp16/bf16 `bmm`). alpha/beta are the compute type (f32 common path).
Fun {
sym: "cublasGemmStridedBatchedEx",
real: "cublasGemmStridedBatchedEx",
params: vec![
handle(),
i("transa"),
i("transb"),
i("m"),
i("n"),
i("k"),
p("alpha", "*const f32", HostInScalar("f32")),
p("A", "*const c_void", DevPtr),
i("Atype"),
i("lda"),
s64("strideA"),
p("B", "*const c_void", DevPtr),
i("Btype"),
i("ldb"),
s64("strideB"),
p("beta", "*const f32", HostInScalar("f32")),
p("C", "*mut c_void", DevPtr),
i("Ctype"),
i("ldc"),
s64("strideC"),
i("batchCount"),
i("computeType"),
i("algo"),
],
},
// cublasGemmBatchedEx — pointer-array batched GEMM (llama.cpp's
// ggml-cuda attention batching). The A/B/C arrays are arrays OF
// device pointers living in device memory, so they forward as
// plain device pointers.
Fun {
sym: "cublasGemmBatchedEx",
real: "cublasGemmBatchedEx",
params: vec![
handle(),
i("transa"),
i("transb"),
i("m"),
i("n"),
i("k"),
p("alpha", "*const f32", HostInScalar("f32")),
p("Aarray", "*const c_void", DevPtr),
i("Atype"),
i("lda"),
p("Barray", "*const c_void", DevPtr),
i("Btype"),
i("ldb"),
p("beta", "*const f32", HostInScalar("f32")),
p("Carray", "*mut c_void", DevPtr),
i("Ctype"),
i("ldc"),
i("batchCount"),
i("computeType"),
i("algo"),
],
},
// BLAS Level 1/2/3 — appended (indices grow; existing ones stable).
axpy("cublasSaxpy_v2", "cublasSaxpy_v2", "f32"),
axpy("cublasDaxpy_v2", "cublasDaxpy_v2", "f64"),
scal("cublasSscal_v2", "cublasSscal_v2", "f32"),
scal("cublasDscal_v2", "cublasDscal_v2", "f64"),
copyswap("cublasScopy_v2", "cublasScopy_v2", "f32"),
copyswap("cublasDcopy_v2", "cublasDcopy_v2", "f64"),
copyswap("cublasSswap_v2", "cublasSswap_v2", "f32"),
copyswap("cublasDswap_v2", "cublasDswap_v2", "f64"),
gemv("cublasSgemv_v2", "cublasSgemv_v2", "f32"),
gemv("cublasDgemv_v2", "cublasDgemv_v2", "f64"),
ger("cublasSger_v2", "cublasSger_v2", "f32"),
ger("cublasDger_v2", "cublasDger_v2", "f64"),
geam("cublasSgeam", "cublasSgeam", "f32"),
geam("cublasDgeam", "cublasDgeam", "f64"),
syrk("cublasSsyrk_v2", "cublasSsyrk_v2", "f32"),
syrk("cublasDsyrk_v2", "cublasDsyrk_v2", "f64"),
symm("cublasSsymm_v2", "cublasSsymm_v2", "f32"),
symm("cublasDsymm_v2", "cublasDsymm_v2", "f64"),
trsm("cublasStrsm_v2", "cublasStrsm_v2", "f32"),
trsm("cublasDtrsm_v2", "cublasDtrsm_v2", "f64"),
],
}
}
/// An out-handle constructor (`fn(T* out)`), e.g. `cudnnCreateTensorDescriptor`.
fn create(name: &'static str) -> Fun {
Fun {
sym: name,
real: name,
params: vec![p("out", "*mut *mut c_void", Kind::Handle)],
}
}
/// Core cuDNN legacy convolution path — the surface a basic conv forward needs.
/// Descriptors are opaque handles; tensors are device pointers; alpha/beta are
/// host scalars; the workspace size is a host output.
fn cudnn_spec() -> Lib {
use Kind::*;
let h = || p("handle", "*mut c_void", Handle);
let d = |n| p(n, "*mut c_void", Handle);
let dev = |n| p(n, "*mut c_void", DevPtr);
let i = |n| p(n, "c_int", Scalar("i32"));
let f = |sym, params| Fun {
sym,
real: sym,
params,
};
Lib {
name: "cudnn",
id: 2,
default_sos: &["libcudnn.so", "libcudnn.so.9", "libcudnn.so.8"],
funcs: vec![
create("cudnnCreate"),
f("cudnnDestroy", vec![h()]),
f(
"cudnnSetStream",
vec![h(), p("stream", "*mut c_void", Stream)],
),
create("cudnnCreateTensorDescriptor"),
f(
"cudnnSetTensor4dDescriptor",
vec![d("t"), i("fmt"), i("dtype"), i("n"), i("c"), i("h"), i("w")],
),
f("cudnnDestroyTensorDescriptor", vec![d("t")]),
create("cudnnCreateFilterDescriptor"),
f(
"cudnnSetFilter4dDescriptor",
vec![d("f"), i("dtype"), i("fmt"), i("k"), i("c"), i("h"), i("w")],
),
f("cudnnDestroyFilterDescriptor", vec![d("f")]),
create("cudnnCreateConvolutionDescriptor"),
f(
"cudnnSetConvolution2dDescriptor",
vec![
d("cv"),
i("pad_h"),
i("pad_w"),
i("u"),
i("v"),
i("dil_h"),
i("dil_w"),
i("mode"),
i("ctype"),
],
),
f("cudnnDestroyConvolutionDescriptor", vec![d("cv")]),
f(
"cudnnGetConvolutionForwardWorkspaceSize",
vec![
h(),
d("x"),
d("w"),
d("cv"),
d("y"),
i("algo"),
p("size", "*mut usize", HostOutScalar("u64")),
],
),
f(
"cudnnConvolutionForward",
vec![
h(),
p("alpha", "*const f32", HostInScalar("f32")),
d("xDesc"),
dev("x"),
d("wDesc"),
dev("w"),
d("convDesc"),
i("algo"),
dev("workspace"),
p("wsSize", "usize", Scalar("u64")),
p("beta", "*const f32", HostInScalar("f32")),
d("yDesc"),
dev("y"),
],
),
],
}
}
/// Leak a String to `&'static str` (generator is short-lived).
fn leak(s: String) -> &'static str {
Box::leak(s.into_boxed_str())
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| ".".into());
for lib in [cublas_spec(), cudnn_spec()] {
std::fs::write(format!("{out}/{}_guest.rs", lib.name), gen_guest(&lib)).unwrap();
std::fs::write(format!("{out}/{}_host.rs", lib.name), gen_host(&lib)).unwrap();
eprintln!(
"generated {} functions for lib '{}' (id {})",
lib.funcs.len(),
lib.name,
lib.id
);
}
}
/// Is this the create/out-handle special case (first param is `*mut *mut`)?
fn is_create(f: &Fun) -> bool {
f.params.len() == 1 && f.params[0].cty.starts_with("*mut *mut")
}
fn gen_guest(lib: &Lib) -> String {
let mut s = String::new();
let _ = writeln!(
s,
"// @generated by smolvm-cuda-codegen — do not edit. lib='{}' id={}",
lib.name, lib.id
);
let _ = writeln!(s, "const LIB_ID: u8 = {};", lib.id);
for (idx, f) in lib.funcs.iter().enumerate() {
if is_create(f) {
// out-handle create: no args in; returns a handle (u64) in `out`.
let _ = writeln!(
s,
"#[no_mangle]\npub extern \"C\" fn {}(handle_out: *mut *mut c_void) -> c_int {{\n \
if handle_out.is_null() {{ return 1; }}\n \
// Fire-and-forget create: return a guest-assigned virtual id\n \
// immediately; the host materializes the real descriptor and\n \
// maps the id (see vh_resolve on the host side).\n \
let vh = super::alloc_vhandle();\n \
match with_client(|c| c.lib_call_deferred(LIB_ID, {idx}, vh.to_le_bytes().to_vec())) {{\n \
Ok(()) => {{ unsafe {{ *handle_out = vh as *mut c_void }}; 0 }}\n \
Err(_) => 1,\n }}\n}}",
f.sym
);
continue;
}
// signature
let sig = f
.params
.iter()
.map(|p| format!("{}: {}", p.name, p.cty))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(
s,
"#[no_mangle]\npub extern \"C\" fn {}({sig}) -> c_int {{",
f.sym
);
let _ = writeln!(s, " let mut a: Vec<u8> = Vec::new();");
for p in &f.params {
match p.kind {
Kind::Scalar(t) => {
let _ = writeln!(
s,
" a.extend_from_slice(&({} as {t}).to_le_bytes());",
p.name
);
}
Kind::Handle | Kind::Stream | Kind::DevPtr => {
let _ = writeln!(
s,
" a.extend_from_slice(&({} as u64).to_le_bytes());",
p.name
);
}
Kind::HostInScalar(t) => {
let _ = writeln!(
s,
" if {n}.is_null() {{ return 1; }}\n a.extend_from_slice(&(unsafe {{ *{n} }} as {t}).to_le_bytes());",
n = p.name
);
}
Kind::HostOutScalar(_) => {} // output-only: nothing to send
}
}
// Output params (in declaration order) come back in `out`.
let outs: Vec<(&str, &str)> = f
.params
.iter()
.filter_map(|p| match p.kind {
Kind::HostOutScalar(t) => Some((p.name, t)),
_ => None,
})
.collect();
if outs.is_empty() {
let _ = writeln!(
s,
" // No output params: fire-and-forget (failures surface as sticky async errors).\n match with_client(|c| c.lib_call_deferred(LIB_ID, {idx}, a)) {{ Ok(()) => 0, Err(_) => 1 }}\n}}"
);
} else {
let _ = writeln!(
s,
" match with_client(|c| c.lib_call(LIB_ID, {idx}, a)) {{\n Ok((st, out)) => {{ let mut o = 0usize;"
);
for (name, t) in &outs {
let sz = wire_size(t);
let _ = writeln!(
s,
" if !{name}.is_null() && out.len() >= o + {sz} {{ unsafe {{ *{name} = {t}::from_le_bytes(out[o..o + {sz}].try_into().unwrap()) as _ }}; }} o += {sz};"
);
}
let _ = writeln!(
s,
" let _ = o; st }}\n Err(_) => 1,\n }}\n}}"
);
}
}
s
}
fn gen_host(lib: &Lib) -> String {
let mut s = String::new();
let _ = writeln!(
s,
"// @generated by smolvm-cuda-codegen — do not edit. lib='{}' id={}",
lib.name, lib.id
);
// The resolved-symbols struct.
let _ = writeln!(s, "pub struct GenLib {{ _lib: Library,");
for f in &lib.funcs {
let cargs = f
.params
.iter()
.map(|p| p.cty)
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(
s,
" f_{}: unsafe extern \"C\" fn({cargs}) -> c_int,",
f.real
);
}
let _ = writeln!(s, "}}");
// Loader.
let _ = writeln!(
s,
"impl GenLib {{\n pub fn load() -> Result<GenLib, String> {{\n unsafe {{\n let lib = super::open_host_lib(\"SMOLVM_{}_LIB\", &[{}])?;\n Ok(GenLib {{",
lib.name.to_uppercase(),
lib.default_sos
.iter()
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(", ")
);
for f in &lib.funcs {
let _ = writeln!(
s,
" f_{r}: sym(&lib, b\"{r}\\0\")?,",
r = f.real
);
}
let _ = writeln!(
s,
" _lib: lib,\n }})\n }}\n }}"
);
// Dispatch.
let _ = writeln!(
s,
" pub fn dispatch(&self, func: u16, args: &[u8], __vh: &mut std::collections::HashMap<u64, u64>, __streams: &std::collections::HashMap<u64, u64>) -> (i32, Vec<u8>) {{\n let mut __c = GenCur {{ b: args, p: 0 }};\n let _ = __streams;\n match func {{"
);
for (idx, f) in lib.funcs.iter().enumerate() {
if is_create(f) {
let _ = writeln!(
s,
" {idx} => {{ let mut h: *mut c_void = std::ptr::null_mut(); let st = unsafe {{ (self.f_{})(&mut h) }}; if st == 0 && args.len() >= 8 {{ let id = u64::from_le_bytes(args[..8].try_into().unwrap()); if id & super::VHANDLE_TAG != 0 {{ __vh.insert(id, h as u64); }} }} (st, (h as u64).to_le_bytes().to_vec()) }}",
f.real
);
continue;
}
let mut binds = String::new();
let mut call = Vec::new();
let mut outs = String::new(); // append out-params after the call
for p in &f.params {
match p.kind {
Kind::Scalar(t) => {
let _ = writeln!(binds, " let {} = __c.{t}();", p.name);
call.push(format!("{} as {}", p.name, p.cty));
}
Kind::Handle => {
// May be a guest-assigned virtual id — resolve to the real
// pointer (untagged values pass through). Destroy calls
// also retire the mapping so the table doesn't grow.
if f.sym.contains("Destroy") {
let _ = writeln!(
binds,
" let __raw = __c.u64();\n let {} = super::vh_resolve(__vh, __raw) as {};\n if __raw & super::VHANDLE_TAG != 0 {{ __vh.remove(&__raw); }}",
p.name, p.cty
);
} else {
let _ = writeln!(
binds,
" let {} = super::vh_resolve(__vh, __c.u64()) as {};",
p.name, p.cty
);
}
call.push(p.name.to_string());
}
Kind::Stream => {
let _ = writeln!(
binds,
" let {} = super::stream_resolve(__streams, __c.u64()) as {};",
p.name, p.cty
);
call.push(p.name.to_string());
}
Kind::DevPtr => {
let _ = writeln!(
binds,
" let {} = __c.u64() as {};",
p.name, p.cty
);
call.push(p.name.to_string());
}
Kind::HostInScalar(t) => {
let _ = writeln!(binds, " let {}_v = __c.{t}();", p.name);
call.push(format!("&{}_v", p.name));
}
Kind::HostOutScalar(t) => {
let pt = pointee(p.cty);
let _ = writeln!(
binds,
" let mut {}_v: {pt} = 0 as {pt};",
p.name
);
call.push(format!("&mut {}_v", p.name));
let _ = writeln!(
outs,
" out.extend_from_slice(&({}_v as {t}).to_le_bytes());",
p.name
);
}
}
}
let _ = writeln!(
s,
" {idx} => {{\n{binds} let mut out = Vec::new();\n let st = unsafe {{ (self.f_{})({}) }};\n{outs} (st, out)\n }}",
f.real,
call.join(", ")
);
}
let _ = writeln!(
s,
" _ => (super::super::CUDA_ERROR_NOT_FOUND, Vec::new()),\n }}\n }}\n}}"
);
// A tiny cursor with the scalar readers the generated code uses.
let _ = writeln!(
s,
"struct GenCur<'a> {{ b: &'a [u8], p: usize }}\nimpl GenCur<'_> {{\n fn take(&mut self, n: usize) -> [u8; 8] {{ let mut o = [0u8; 8]; let end = (self.p + n).min(self.b.len()); o[..end - self.p].copy_from_slice(&self.b[self.p..end]); self.p = end; o }}\n fn u16(&mut self) -> u16 {{ u16::from_le_bytes(self.take(2)[..2].try_into().unwrap()) }}\n fn i32(&mut self) -> i32 {{ i32::from_le_bytes(self.take(4)[..4].try_into().unwrap()) }}\n fn i64(&mut self) -> i64 {{ i64::from_le_bytes(self.take(8)) }}\n fn u64(&mut self) -> u64 {{ u64::from_le_bytes(self.take(8)) }}\n fn f32(&mut self) -> f32 {{ f32::from_le_bytes(self.take(4)[..4].try_into().unwrap()) }}\n fn f64(&mut self) -> f64 {{ f64::from_le_bytes(self.take(8)) }}\n}}"
);
s
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "smolvm-cuda-guest"
version = "1.5.2"
edition = "2021"
description = "Guest-side CUDA-over-vsock runner for smolvm microVMs (Linux)"
license = "Apache-2.0"
[[bin]]
name = "smolvm-cuda-run"
path = "src/main.rs"
[dependencies]
smolvm-cuda = { path = "../smolvm-cuda", default-features = false }
# vsock is Linux-only; the guest binary is meaningful only inside a Linux
# microVM. Off Linux the binary compiles as a stub so the workspace still builds.
[target.'cfg(target_os = "linux")'.dependencies]
vsock = "0.5"
+125
View File
@@ -0,0 +1,125 @@
//! Guest-side CUDA-over-vsock runner.
//!
//! Connects out to the host CUDA server over `AF_VSOCK` (host CID 2, the smolvm
//! CUDA port) and drives a real Driver-API workload through the RPC client:
//! load an arbitrary PTX module, look up a named kernel, allocate device
//! buffers, copy in, launch, synchronize, copy out, and verify. This exercises
//! the *generalized* protocol (arbitrary module + named launch), not a baked-in
//! single operation — the same path a real driver-API program takes.
//!
//! On success it prints `SMOLVM-CUDA-OK`; on any failure it prints the error and
//! exits non-zero so a `machine exec` caller sees the result.
/// smolvm's reserved guest→host CUDA vsock port (mirrors `smolvm_protocol::ports::CUDA`).
#[cfg(target_os = "linux")]
const CUDA_PORT: u32 = 7000;
/// `VMADDR_CID_HOST` — the host end of the vsock.
#[cfg(target_os = "linux")]
const HOST_CID: u32 = 2;
/// A hand-written vector-add kernel. `.target sm_52` JITs forward-compatibly to
/// any newer GPU (e.g. the RTX 3070's sm_86). No nvcc required.
#[cfg(target_os = "linux")]
const VECADD_PTX: &str = r#".version 7.0
.target sm_52
.address_size 64
.visible .entry vecadd(.param .u64 a, .param .u64 b, .param .u64 c, .param .u32 n)
{ .reg .pred %p<2>; .reg .f32 %f<4>; .reg .b32 %r<6>; .reg .b64 %rd<11>;
ld.param.u64 %rd1,[a]; ld.param.u64 %rd2,[b]; ld.param.u64 %rd3,[c]; ld.param.u32 %r2,[n];
mov.u32 %r3,%ntid.x; mov.u32 %r4,%ctaid.x; mov.u32 %r5,%tid.x; mad.lo.s32 %r1,%r4,%r3,%r5;
setp.ge.u32 %p1,%r1,%r2; @%p1 bra $E;
cvta.to.global.u64 %rd4,%rd1; cvta.to.global.u64 %rd5,%rd2; cvta.to.global.u64 %rd6,%rd3;
mul.wide.u32 %rd7,%r1,4; add.s64 %rd8,%rd4,%rd7; add.s64 %rd9,%rd5,%rd7; add.s64 %rd10,%rd6,%rd7;
ld.global.f32 %f1,[%rd8]; ld.global.f32 %f2,[%rd9]; add.f32 %f3,%f1,%f2; st.global.f32 [%rd10],%f3;
$E: ret; }
"#;
#[cfg(target_os = "linux")]
fn run() -> Result<(), Box<dyn std::error::Error>> {
use smolvm_cuda::client::Client;
use vsock::VsockStream;
let stream = VsockStream::connect_with_cid_port(HOST_CID, CUDA_PORT)
.map_err(|e| format!("connect vsock {HOST_CID}:{CUDA_PORT}: {e}"))?;
let mut cu = Client::new(stream);
cu.init()?;
let count = cu.device_get_count()?;
if count < 1 {
return Err("no CUDA devices reported by host".into());
}
let name = cu.device_get_name(0)?;
let _ctx = cu.ctx_create(0)?;
println!("smolvm-cuda: host device 0 = {name}");
// Load an arbitrary module + look up the kernel by name (the general path).
let module = cu.module_load_data(VECADD_PTX.as_bytes())?;
let func = cu.module_get_function(module, "vecadd")?;
let n: usize = 1024;
let bytes = (n * 4) as u64;
let a: Vec<f32> = (0..n).map(|i| i as f32).collect();
let b: Vec<f32> = (0..n).map(|i| (2 * i) as f32).collect();
let da = cu.mem_alloc(bytes)?;
let db = cu.mem_alloc(bytes)?;
let dc = cu.mem_alloc(bytes)?;
cu.memcpy_htod(da, as_bytes(&a), 0)?;
cu.memcpy_htod(db, as_bytes(&b), 0)?;
// kernelParams: one little-endian blob per arg, in declaration order.
let params = vec![
da.to_le_bytes().to_vec(),
db.to_le_bytes().to_vec(),
dc.to_le_bytes().to_vec(),
(n as u32).to_le_bytes().to_vec(),
];
let block = 256u32;
let grid = (n as u32).div_ceil(block);
cu.launch_kernel(func, [grid, 1, 1], [block, 1, 1], 0, 0, &params)?;
cu.ctx_synchronize()?;
let out = cu.memcpy_dtoh(dc, bytes, 0)?;
let c: Vec<f32> = out
.chunks_exact(4)
.map(|p| f32::from_le_bytes(p.try_into().unwrap()))
.collect();
for i in 0..n {
let expect = a[i] + b[i];
if (c[i] - expect).abs() > 1e-3 {
return Err(format!("mismatch at {i}: got {} want {expect}", c[i]).into());
}
}
cu.mem_free(da)?;
cu.mem_free(db)?;
cu.mem_free(dc)?;
println!(
"smolvm-cuda: vecadd n={n} verified (c[1]={}, c[{}]={})",
c[1],
n - 1,
c[n - 1]
);
println!("SMOLVM-CUDA-OK");
Ok(())
}
#[cfg(target_os = "linux")]
fn as_bytes(v: &[f32]) -> &[u8] {
// SAFETY: f32 has no padding/invalid bit patterns; reading its bytes is sound.
unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) }
}
#[cfg(target_os = "linux")]
fn main() {
if let Err(e) = run() {
eprintln!("smolvm-cuda: ERROR: {e}");
std::process::exit(1);
}
}
#[cfg(not(target_os = "linux"))]
fn main() {
eprintln!("smolvm-cuda-run runs only inside a Linux guest microVM");
std::process::exit(1);
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "smolvm-cuda-shim"
version = "1.5.2"
edition = "2021"
description = "Guest-side libcuda.so.1 drop-in: CUDA Driver API over smolvm's vsock RPC"
license = "Apache-2.0"
[lib]
name = "cuda"
crate-type = ["cdylib"]
[dependencies]
smolvm-cuda = { path = "../smolvm-cuda", default-features = false }
# vsock is the production transport (guest → host CID 2). Off Linux the crate
# still compiles (tcp/unix transports only) so the workspace builds everywhere.
[target.'cfg(target_os = "linux")'.dependencies]
vsock = "0.5"
+10
View File
@@ -0,0 +1,10 @@
fn main() {
// The library must announce itself as libcuda.so.1 so the dynamic linker
// satisfies programs linked against (or dlopen-ing) the real driver's
// soname. The build artifact is libcuda.so; installers symlink/rename to
// libcuda.so.1.
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("linux") {
println!("cargo:rustc-cdylib-link-arg=-Wl,-soname,libcuda.so.1");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
/* Unmodified CUDA Driver-API program — the acceptance test for the smolvm
* libcuda.so.1 shim. Nothing here is smolvm-specific: it is written exactly as
* a program against the real driver would be (hand-declared prototypes stand
* in for cuda.h so no CUDA toolkit is needed to compile it).
*
* Exercises: init, device queries, primary context (the cudart pattern),
* module load from PTX, kernel launch with kernelParams, memcpys, memset,
* device-to-device copy, streams, events, mem info, error strings — and
* resolves a second round of entry points through cuGetProcAddress the way
* the CUDA runtime does.
*
* Build: gcc vecadd.c -o vecadd -L<shimdir> -lcuda -Wl,-rpath,<shimdir>
* Run: ./vecadd (prints SHIM-CUDA-OK on success, exit 0)
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef int CUresult;
typedef int CUdevice;
typedef void *CUcontext;
typedef void *CUmodule;
typedef void *CUfunction;
typedef void *CUstream;
typedef void *CUevent;
typedef unsigned long long CUdeviceptr;
extern CUresult cuInit(unsigned int flags);
extern CUresult cuDriverGetVersion(int *version);
extern CUresult cuDeviceGetCount(int *count);
extern CUresult cuDeviceGet(CUdevice *dev, int ordinal);
extern CUresult cuDeviceGetName(char *name, int len, CUdevice dev);
extern CUresult cuDeviceTotalMem_v2(size_t *bytes, CUdevice dev);
extern CUresult cuDeviceGetAttribute(int *pi, int attrib, CUdevice dev);
extern CUresult cuDevicePrimaryCtxRetain(CUcontext *pctx, CUdevice dev);
extern CUresult cuDevicePrimaryCtxRelease_v2(CUdevice dev);
extern CUresult cuCtxSetCurrent(CUcontext ctx);
extern CUresult cuCtxGetCurrent(CUcontext *pctx);
extern CUresult cuCtxSynchronize(void);
extern CUresult cuModuleLoadData(CUmodule *module, const void *image);
extern CUresult cuModuleGetFunction(CUfunction *fn, CUmodule module, const char *name);
extern CUresult cuModuleUnload(CUmodule module);
extern CUresult cuMemAlloc_v2(CUdeviceptr *dptr, size_t bytes);
extern CUresult cuMemFree_v2(CUdeviceptr dptr);
extern CUresult cuMemcpyHtoD_v2(CUdeviceptr dst, const void *src, size_t bytes);
extern CUresult cuMemcpyDtoH_v2(void *dst, CUdeviceptr src, size_t bytes);
extern CUresult cuMemcpyDtoD_v2(CUdeviceptr dst, CUdeviceptr src, size_t bytes);
extern CUresult cuMemsetD8_v2(CUdeviceptr dptr, unsigned char value, size_t n);
extern CUresult cuMemGetInfo_v2(size_t *free_b, size_t *total_b);
extern CUresult cuLaunchKernel(CUfunction fn, unsigned gx, unsigned gy, unsigned gz,
unsigned bx, unsigned by, unsigned bz,
unsigned shared, CUstream stream,
void **params, void **extra);
extern CUresult cuStreamCreate(CUstream *stream, unsigned flags);
extern CUresult cuStreamSynchronize(CUstream stream);
extern CUresult cuStreamDestroy_v2(CUstream stream);
extern CUresult cuEventCreate(CUevent *ev, unsigned flags);
extern CUresult cuEventRecord(CUevent ev, CUstream stream);
extern CUresult cuEventSynchronize(CUevent ev);
extern CUresult cuEventElapsedTime(float *ms, CUevent start, CUevent end);
extern CUresult cuEventDestroy_v2(CUevent ev);
extern CUresult cuGetErrorName(CUresult code, const char **pstr);
extern CUresult cuGetProcAddress_v2(const char *symbol, void **pfn, int version,
unsigned long long flags, int *status);
#define CHECK(call) \
do { \
CUresult _rc = (call); \
if (_rc != 0) { \
const char *_name = "?"; \
cuGetErrorName(_rc, &_name); \
fprintf(stderr, "FAIL %s -> %d (%s) at line %d\n", #call, _rc, \
_name, __LINE__); \
exit(1); \
} \
} while (0)
static const char *VECADD_PTX =
".version 7.0\n"
".target sm_52\n"
".address_size 64\n"
".visible .entry vecadd(.param .u64 a, .param .u64 b, .param .u64 c, .param .u32 n)\n"
"{ .reg .pred %p<2>; .reg .f32 %f<4>; .reg .b32 %r<6>; .reg .b64 %rd<11>;\n"
" ld.param.u64 %rd1,[a]; ld.param.u64 %rd2,[b]; ld.param.u64 %rd3,[c]; ld.param.u32 %r2,[n];\n"
" mov.u32 %r3,%ntid.x; mov.u32 %r4,%ctaid.x; mov.u32 %r5,%tid.x; mad.lo.s32 %r1,%r4,%r3,%r5;\n"
" setp.ge.u32 %p1,%r1,%r2; @%p1 bra $E;\n"
" cvta.to.global.u64 %rd4,%rd1; cvta.to.global.u64 %rd5,%rd2; cvta.to.global.u64 %rd6,%rd3;\n"
" mul.wide.u32 %rd7,%r1,4; add.s64 %rd8,%rd4,%rd7; add.s64 %rd9,%rd5,%rd7; add.s64 %rd10,%rd6,%rd7;\n"
" ld.global.f32 %f1,[%rd8]; ld.global.f32 %f2,[%rd9]; add.f32 %f3,%f1,%f2; st.global.f32 [%rd10],%f3;\n"
"$E: ret; }\n";
enum { N = 4096 };
int main(void) {
CHECK(cuInit(0));
int version = 0, count = 0;
CHECK(cuDriverGetVersion(&version));
CHECK(cuDeviceGetCount(&count));
if (count < 1) {
fprintf(stderr, "FAIL no devices\n");
return 1;
}
CUdevice dev;
CHECK(cuDeviceGet(&dev, 0));
char name[256];
CHECK(cuDeviceGetName(name, sizeof name, dev));
size_t total = 0;
CHECK(cuDeviceTotalMem_v2(&total, dev));
int cc_major = 0;
CHECK(cuDeviceGetAttribute(&cc_major, /*COMPUTE_CAPABILITY_MAJOR*/ 75, dev));
printf("device 0: %s (%zu MiB, cc %d.x, driver %d)\n", name,
total >> 20, cc_major, version);
/* Primary context, exactly as the CUDA runtime does it. */
CUcontext ctx = NULL;
CHECK(cuDevicePrimaryCtxRetain(&ctx, dev));
CHECK(cuCtxSetCurrent(ctx));
CUcontext cur = NULL;
CHECK(cuCtxGetCurrent(&cur));
if (cur != ctx) {
fprintf(stderr, "FAIL current ctx mismatch\n");
return 1;
}
CUmodule module;
CUfunction vecadd;
CHECK(cuModuleLoadData(&module, VECADD_PTX));
CHECK(cuModuleGetFunction(&vecadd, module, "vecadd"));
static float a[N], b[N], c[N];
for (int i = 0; i < N; i++) {
a[i] = (float)i;
b[i] = 2.0f * (float)i;
}
CUdeviceptr da, db, dc, dscratch;
CHECK(cuMemAlloc_v2(&da, sizeof a));
CHECK(cuMemAlloc_v2(&db, sizeof b));
CHECK(cuMemAlloc_v2(&dc, sizeof c));
CHECK(cuMemAlloc_v2(&dscratch, sizeof c));
CHECK(cuMemcpyHtoD_v2(da, a, sizeof a));
CHECK(cuMemcpyHtoD_v2(db, b, sizeof b));
CHECK(cuMemsetD8_v2(dc, 0, sizeof c));
size_t mem_free = 0, mem_total = 0;
CHECK(cuMemGetInfo_v2(&mem_free, &mem_total));
/* Launch on a created stream, timed with events. */
CUstream stream;
CUevent ev_start, ev_end;
CHECK(cuStreamCreate(&stream, 0));
CHECK(cuEventCreate(&ev_start, 0));
CHECK(cuEventCreate(&ev_end, 0));
unsigned block = 256, grid = (N + block - 1) / block;
unsigned n = N;
void *params[] = { &da, &db, &dc, &n };
CHECK(cuEventRecord(ev_start, stream));
CHECK(cuLaunchKernel(vecadd, grid, 1, 1, block, 1, 1, 0, stream, params, NULL));
CHECK(cuEventRecord(ev_end, stream));
CHECK(cuStreamSynchronize(stream));
CHECK(cuEventSynchronize(ev_end));
float ms = -1.0f;
CHECK(cuEventElapsedTime(&ms, ev_start, ev_end));
/* Round-trip the result through a device-to-device copy. */
CHECK(cuMemcpyDtoD_v2(dscratch, dc, sizeof c));
CHECK(cuMemcpyDtoH_v2(c, dscratch, sizeof c));
for (int i = 0; i < N; i++) {
float want = a[i] + b[i];
if (c[i] < want - 1e-2f || c[i] > want + 1e-2f) {
fprintf(stderr, "FAIL mismatch at %d: got %f want %f\n", i, c[i], want);
return 1;
}
}
printf("vecadd n=%d verified (kernel %.3f ms, %zu/%zu MiB free)\n", N, ms,
mem_free >> 20, mem_total >> 20);
/* Second pass resolving entry points via cuGetProcAddress — the path the
* CUDA runtime takes for every driver call since 11.3. */
CUresult (*p_cuCtxSynchronize)(void) = NULL;
CUresult (*p_cuMemcpyDtoH)(void *, CUdeviceptr, size_t) = NULL;
int status = -1;
CHECK(cuGetProcAddress_v2("cuCtxSynchronize", (void **)&p_cuCtxSynchronize,
12000, 0, &status));
CHECK(cuGetProcAddress_v2("cuMemcpyDtoH", (void **)&p_cuMemcpyDtoH,
12000, 0, &status));
if (!p_cuCtxSynchronize || !p_cuMemcpyDtoH) {
fprintf(stderr, "FAIL cuGetProcAddress returned NULL fn\n");
return 1;
}
CHECK(p_cuCtxSynchronize());
memset(c, 0, sizeof c);
CHECK(p_cuMemcpyDtoH(c, dc, sizeof c));
if (c[N - 1] != a[N - 1] + b[N - 1]) {
fprintf(stderr, "FAIL proc-address path mismatch\n");
return 1;
}
/* Unknown symbols must be reported, not crash. */
void *bogus = (void *)1;
if (cuGetProcAddress_v2("cuDefinitelyNotReal", &bogus, 12000, 0, &status) == 0 ||
bogus != NULL) {
fprintf(stderr, "FAIL unknown symbol not rejected\n");
return 1;
}
CHECK(cuEventDestroy_v2(ev_start));
CHECK(cuEventDestroy_v2(ev_end));
CHECK(cuStreamDestroy_v2(stream));
CHECK(cuMemFree_v2(da));
CHECK(cuMemFree_v2(db));
CHECK(cuMemFree_v2(dc));
CHECK(cuMemFree_v2(dscratch));
CHECK(cuModuleUnload(module));
CHECK(cuDevicePrimaryCtxRelease_v2(dev));
printf("SHIM-CUDA-OK: %s\n", name);
return 0;
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "smolvm-cuda"
version = "1.5.2"
edition = "2021"
description = "CUDA Driver-API remoting over vsock for smolvm guests"
license = "Apache-2.0"
[features]
# host: server-side dispatch + CPU emulation backend (std only).
# gpu: real driver backend via libloading (implies host).
default = ["host", "gpu"]
host = []
gpu = ["host", "dep:libloading"]
[dependencies]
libloading = { version = "0.8", optional = true }
libc = "0.2"
+37
View File
@@ -0,0 +1,37 @@
//! Emit `SMOLVM_PROTO_HASH`: a fingerprint of the wire-defining source, so a
//! shim and a server built from different source (the classic "rebuilt one
//! binary, not the other" stale-binary trap that silently corrupts results)
//! fail the connect handshake loudly instead. The hash covers only files both
//! the client (shim) and the host (server) compile, i.e. the wire contract —
//! not host-only backend code, which doesn't change the byte protocol.
//!
//! FNV-1a (not `DefaultHasher`) so the value is reproducible across toolchains
//! and platforms: a guest-built shim and a host-built server must agree.
fn fnv1a(seed: u64, bytes: &[u8]) -> u64 {
let mut h = seed;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
fn main() {
let files = [
"src/proto.rs",
"src/client.rs",
"src/ring.rs",
"src/generated/cublas_guest.rs",
"src/generated/cudnn_guest.rs",
];
// A manual epoch to force a bump on wire changes the file set misses.
const PROTO_EPOCH: &[u8] = b"epoch-1";
let mut h = fnv1a(0xcbf2_9ce4_8422_2325, PROTO_EPOCH);
for f in files {
println!("cargo:rerun-if-changed={f}");
let bytes = std::fs::read(f).unwrap_or_default();
h = fnv1a(h, &bytes);
}
println!("cargo:rustc-env=SMOLVM_PROTO_HASH={h:016x}");
}
+129
View File
@@ -0,0 +1,129 @@
//! GPU live-verification harness.
//!
//! Exercises the *real* CUDA stack — `GpuBackend` (driver via `nvcuda.dll` /
//! `libcuda.so.1`), the `host::serve` dispatch, the wire `proto`, and the guest
//! `Client` — over a local TCP loopback socket, running an arbitrary-module +
//! named-kernel workload on the host GPU. This is the same code path production
//! uses; only the vsock/microVM transport is swapped for loopback so it can run
//! directly on a GPU host (e.g. the RTX 3070) without booting a VM.
//!
//! Run on a machine with an NVIDIA driver:
//! cargo run --release --example gpu_loopback --features gpu
//!
//! Prints the device name and `GPU-VERIFY-OK` on success; exits 2 if no GPU
//! driver loads, 3 on a result mismatch.
use smolvm_cuda::client::Client;
use smolvm_cuda::host::{serve, Backend, GpuBackend};
use std::net::{TcpListener, TcpStream};
const VECADD_PTX: &str = r#".version 7.0
.target sm_52
.address_size 64
.visible .entry vecadd(.param .u64 a, .param .u64 b, .param .u64 c, .param .u32 n)
{ .reg .pred %p<2>; .reg .f32 %f<4>; .reg .b32 %r<6>; .reg .b64 %rd<11>;
ld.param.u64 %rd1,[a]; ld.param.u64 %rd2,[b]; ld.param.u64 %rd3,[c]; ld.param.u32 %r2,[n];
mov.u32 %r3,%ntid.x; mov.u32 %r4,%ctaid.x; mov.u32 %r5,%tid.x; mad.lo.s32 %r1,%r4,%r3,%r5;
setp.ge.u32 %p1,%r1,%r2; @%p1 bra $E;
cvta.to.global.u64 %rd4,%rd1; cvta.to.global.u64 %rd5,%rd2; cvta.to.global.u64 %rd6,%rd3;
mul.wide.u32 %rd7,%r1,4; add.s64 %rd8,%rd4,%rd7; add.s64 %rd9,%rd5,%rd7; add.s64 %rd10,%rd6,%rd7;
ld.global.f32 %f1,[%rd8]; ld.global.f32 %f2,[%rd9]; add.f32 %f3,%f1,%f2; st.global.f32 [%rd10],%f3;
$E: ret; }
"#;
fn main() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
let addr = listener.local_addr().unwrap();
// Host side: real driver backend, served on the accepting thread (the CUDA
// context is created and stays current on this one thread).
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().expect("accept");
let mut backend: Box<dyn Backend> = match GpuBackend::load() {
Ok(b) => Box::new(b),
Err(e) => {
eprintln!("gpu_loopback: no CUDA driver: {e}");
std::process::exit(2);
}
};
let _ = serve(stream, backend.as_mut());
});
let mut cu = Client::new(TcpStream::connect(addr).expect("connect"));
cu.init().expect("cuInit");
let count = cu.device_get_count().expect("device count");
let name = cu.device_get_name(0).expect("device name");
let vram = cu.device_total_mem(0).expect("total mem");
println!(
"gpu_loopback: {count} device(s); device 0 = {name} ({} MiB)",
vram / (1024 * 1024)
);
let _ctx = cu.ctx_create(0).expect("ctx create");
// Arbitrary module + named kernel — the general path, not a baked op.
let module = cu
.module_load_data(VECADD_PTX.as_bytes())
.expect("module load");
let func = cu
.module_get_function(module, "vecadd")
.expect("get function");
let n: usize = 1 << 16; // 65536 elements
let bytes = (n * 4) as u64;
let a: Vec<f32> = (0..n).map(|i| i as f32).collect();
let b: Vec<f32> = (0..n).map(|i| (3 * i) as f32).collect();
let da = cu.mem_alloc(bytes).expect("alloc a");
let db = cu.mem_alloc(bytes).expect("alloc b");
let dc = cu.mem_alloc(bytes).expect("alloc c");
cu.memcpy_htod(da, bytemuck(&a), 0).expect("h2d a");
cu.memcpy_htod(db, bytemuck(&b), 0).expect("h2d b");
let block = 256u32;
let grid = (n as u32).div_ceil(block);
cu.launch_kernel(
func,
[grid, 1, 1],
[block, 1, 1],
0,
0,
&[
da.to_le_bytes().to_vec(),
db.to_le_bytes().to_vec(),
dc.to_le_bytes().to_vec(),
(n as u32).to_le_bytes().to_vec(),
],
)
.expect("launch");
cu.ctx_synchronize().expect("sync");
let out = cu.memcpy_dtoh(dc, bytes, 0).expect("d2h");
let c: Vec<f32> = out
.chunks_exact(4)
.map(|p| f32::from_le_bytes(p.try_into().unwrap()))
.collect();
for i in 0..n {
let expect = a[i] + b[i]; // i + 3i = 4i
if (c[i] - expect).abs() > 1e-2 {
eprintln!("gpu_loopback: MISMATCH at {i}: got {} want {expect}", c[i]);
std::process::exit(3);
}
}
cu.mem_free(da).ok();
cu.mem_free(db).ok();
cu.mem_free(dc).ok();
drop(cu);
let _ = server.join();
println!(
"gpu_loopback: vecadd n={n} verified on GPU (c[1]={}, c[{}]={})",
c[1],
n - 1,
c[n - 1]
);
println!("GPU-VERIFY-OK: {name}");
}
/// Reinterpret `&[f32]` as bytes (f32 has no invalid bit patterns).
fn bytemuck(v: &[f32]) -> &[u8] {
// SAFETY: f32 is plain-old-data; reading its bytes is sound.
unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) }
}
@@ -0,0 +1,40 @@
//! Host-side CUDA RPC server on TCP loopback, for exercising the
//! `smolvm-cuda-shim` ABI without a VM.
//!
//! Serves the real GPU backend when the driver loads, else CPU emulation, one
//! connection at a time (each on its own thread — the CUDA context binds to
//! the serving thread, mirroring the production per-connection model).
//!
//! cargo run --release --example shim_server -p smolvm-cuda --features gpu -- [addr]
//!
//! Default addr 127.0.0.1:7901. Prints `SHIM-SERVER-READY <addr> <backend>` once
//! listening, so scripts can wait for the line then point a shim-linked binary
//! at it via `SMOLVM_CUDA_RPC=tcp:<addr>`.
use smolvm_cuda::host::{serve, Backend, CpuBackend, GpuBackend};
use std::net::TcpListener;
fn main() {
let addr = std::env::args()
.nth(1)
.unwrap_or_else(|| "127.0.0.1:7901".into());
let listener = TcpListener::bind(&addr).expect("bind");
let backend_kind = match GpuBackend::load() {
Ok(_) => "gpu",
Err(_) => "cpu-emulation",
};
println!("SHIM-SERVER-READY {addr} {backend_kind}");
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let __r = stream.set_nodelay(true); // low-latency request/response
std::thread::spawn(move || {
let mut backend: Box<dyn Backend> = match GpuBackend::load() {
Ok(b) => Box::new(b),
Err(_) => Box::<CpuBackend>::default(),
};
if let Err(e) = serve(stream, backend.as_mut()) {
eprintln!("[conn-err] {e}");
}
});
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,606 @@
// @generated by smolvm-cuda-codegen — do not edit. lib='cublas' id=1
const LIB_ID: u8 = 1;
#[no_mangle]
pub extern "C" fn cublasCreate_v2(handle_out: *mut *mut c_void) -> c_int {
if handle_out.is_null() { return 1; }
// Fire-and-forget create: return a guest-assigned virtual id
// immediately; the host materializes the real descriptor and
// maps the id (see vh_resolve on the host side).
let vh = super::alloc_vhandle();
match with_client(|c| c.lib_call_deferred(LIB_ID, 0, vh.to_le_bytes().to_vec())) {
Ok(()) => { unsafe { *handle_out = vh as *mut c_void }; 0 }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cublasDestroy_v2(handle: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 1, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSgemm_v2(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f32, A: *const f32, lda: c_int, B: *const f32, ldb: c_int, beta: *const f32, C: *mut f32, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 2, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasHgemm(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const u16, A: *const u16, lda: c_int, B: *const u16, ldb: c_int, beta: *const u16, C: *mut u16, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as u16).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as u16).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 3, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDgemm_v2(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f64, A: *const f64, lda: c_int, B: *const f64, ldb: c_int, beta: *const f64, C: *mut f64, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 4, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSgemmStridedBatched(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f32, A: *const f32, lda: c_int, strideA: i64, B: *const f32, ldb: c_int, strideB: i64, beta: *const f32, C: *mut f32, ldc: c_int, strideC: i64, batchCount: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(strideA as i64).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
a.extend_from_slice(&(strideB as i64).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
a.extend_from_slice(&(strideC as i64).to_le_bytes());
a.extend_from_slice(&(batchCount as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 5, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDgemmStridedBatched(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f64, A: *const f64, lda: c_int, strideA: i64, B: *const f64, ldb: c_int, strideB: i64, beta: *const f64, C: *mut f64, ldc: c_int, strideC: i64, batchCount: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(strideA as i64).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
a.extend_from_slice(&(strideB as i64).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
a.extend_from_slice(&(strideC as i64).to_le_bytes());
a.extend_from_slice(&(batchCount as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 6, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSetStream_v2(handle: *mut c_void, stream: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(stream as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 7, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSetWorkspace_v2(handle: *mut c_void, workspace: *mut c_void, size: usize) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(workspace as u64).to_le_bytes());
a.extend_from_slice(&(size as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 8, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSetMathMode(handle: *mut c_void, mode: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(mode as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 9, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasGetMathMode(handle: *mut c_void, mode: *mut c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
match with_client(|c| c.lib_call(LIB_ID, 10, a)) {
Ok((st, out)) => { let mut o = 0usize;
if !mode.is_null() && out.len() >= o + 4 { unsafe { *mode = i32::from_le_bytes(out[o..o + 4].try_into().unwrap()) as _ }; } o += 4;
let _ = o; st }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cublasGetProperty(prop_type: c_int, value: *mut c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(prop_type as i32).to_le_bytes());
match with_client(|c| c.lib_call(LIB_ID, 11, a)) {
Ok((st, out)) => { let mut o = 0usize;
if !value.is_null() && out.len() >= o + 4 { unsafe { *value = i32::from_le_bytes(out[o..o + 4].try_into().unwrap()) as _ }; } o += 4;
let _ = o; st }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cublasGemmEx(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f32, A: *const c_void, Atype: c_int, lda: c_int, B: *const c_void, Btype: c_int, ldb: c_int, beta: *const f32, C: *mut c_void, Ctype: c_int, ldc: c_int, computeType: c_int, algo: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(Atype as i32).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(Btype as i32).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(Ctype as i32).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
a.extend_from_slice(&(computeType as i32).to_le_bytes());
a.extend_from_slice(&(algo as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 12, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasGemmStridedBatchedEx(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f32, A: *const c_void, Atype: c_int, lda: c_int, strideA: i64, B: *const c_void, Btype: c_int, ldb: c_int, strideB: i64, beta: *const f32, C: *mut c_void, Ctype: c_int, ldc: c_int, strideC: i64, batchCount: c_int, computeType: c_int, algo: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(Atype as i32).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(strideA as i64).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(Btype as i32).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
a.extend_from_slice(&(strideB as i64).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(Ctype as i32).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
a.extend_from_slice(&(strideC as i64).to_le_bytes());
a.extend_from_slice(&(batchCount as i32).to_le_bytes());
a.extend_from_slice(&(computeType as i32).to_le_bytes());
a.extend_from_slice(&(algo as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 13, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasGemmBatchedEx(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f32, Aarray: *const c_void, Atype: c_int, lda: c_int, Barray: *const c_void, Btype: c_int, ldb: c_int, beta: *const f32, Carray: *mut c_void, Ctype: c_int, ldc: c_int, batchCount: c_int, computeType: c_int, algo: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(Aarray as u64).to_le_bytes());
a.extend_from_slice(&(Atype as i32).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(Barray as u64).to_le_bytes());
a.extend_from_slice(&(Btype as i32).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(Carray as u64).to_le_bytes());
a.extend_from_slice(&(Ctype as i32).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
a.extend_from_slice(&(batchCount as i32).to_le_bytes());
a.extend_from_slice(&(computeType as i32).to_le_bytes());
a.extend_from_slice(&(algo as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 14, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSaxpy_v2(handle: *mut c_void, n: c_int, alpha: *const f32, x: *const f32, incx: c_int, y: *mut f32, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 15, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDaxpy_v2(handle: *mut c_void, n: c_int, alpha: *const f64, x: *const f64, incx: c_int, y: *mut f64, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 16, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSscal_v2(handle: *mut c_void, n: c_int, alpha: *const f32, x: *mut f32, incx: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 17, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDscal_v2(handle: *mut c_void, n: c_int, alpha: *const f64, x: *mut f64, incx: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 18, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasScopy_v2(handle: *mut c_void, n: c_int, x: *mut f32, incx: c_int, y: *mut f32, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 19, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDcopy_v2(handle: *mut c_void, n: c_int, x: *mut f64, incx: c_int, y: *mut f64, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 20, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSswap_v2(handle: *mut c_void, n: c_int, x: *mut f32, incx: c_int, y: *mut f32, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 21, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDswap_v2(handle: *mut c_void, n: c_int, x: *mut f64, incx: c_int, y: *mut f64, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 22, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSgemv_v2(handle: *mut c_void, trans: c_int, m: c_int, n: c_int, alpha: *const f32, A: *const f32, lda: c_int, x: *const f32, incx: c_int, beta: *const f32, y: *mut f32, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 23, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDgemv_v2(handle: *mut c_void, trans: c_int, m: c_int, n: c_int, alpha: *const f64, A: *const f64, lda: c_int, x: *const f64, incx: c_int, beta: *const f64, y: *mut f64, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 24, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSger_v2(handle: *mut c_void, m: c_int, n: c_int, alpha: *const f32, x: *const f32, incx: c_int, y: *const f32, incy: c_int, A: *mut f32, lda: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 25, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDger_v2(handle: *mut c_void, m: c_int, n: c_int, alpha: *const f64, x: *const f64, incx: c_int, y: *const f64, incy: c_int, A: *mut f64, lda: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 26, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSgeam(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, alpha: *const f32, A: *const f32, lda: c_int, beta: *const f32, B: *const f32, ldb: c_int, C: *mut f32, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 27, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDgeam(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, alpha: *const f64, A: *const f64, lda: c_int, beta: *const f64, B: *const f64, ldb: c_int, C: *mut f64, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 28, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSsyrk_v2(handle: *mut c_void, uplo: c_int, trans: c_int, n: c_int, k: c_int, alpha: *const f32, A: *const f32, lda: c_int, beta: *const f32, C: *mut f32, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 29, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDsyrk_v2(handle: *mut c_void, uplo: c_int, trans: c_int, n: c_int, k: c_int, alpha: *const f64, A: *const f64, lda: c_int, beta: *const f64, C: *mut f64, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 30, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSsymm_v2(handle: *mut c_void, side: c_int, uplo: c_int, m: c_int, n: c_int, alpha: *const f32, A: *const f32, lda: c_int, B: *const f32, ldb: c_int, beta: *const f32, C: *mut f32, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(side as i32).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 31, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDsymm_v2(handle: *mut c_void, side: c_int, uplo: c_int, m: c_int, n: c_int, alpha: *const f64, A: *const f64, lda: c_int, B: *const f64, ldb: c_int, beta: *const f64, C: *mut f64, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(side as i32).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 32, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasStrsm_v2(handle: *mut c_void, side: c_int, uplo: c_int, trans: c_int, diag: c_int, m: c_int, n: c_int, alpha: *const f32, A: *const f32, lda: c_int, B: *mut f32, ldb: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(side as i32).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(diag as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 33, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDtrsm_v2(handle: *mut c_void, side: c_int, uplo: c_int, trans: c_int, diag: c_int, m: c_int, n: c_int, alpha: *const f64, A: *const f64, lda: c_int, B: *mut f64, ldb: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(side as i32).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(diag as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 34, a)) { Ok(()) => 0, Err(_) => 1 }
}
@@ -0,0 +1,617 @@
// @generated by smolvm-cuda-codegen — do not edit. lib='cublas' id=1
pub struct GenLib { _lib: Library,
f_cublasCreate_v2: unsafe extern "C" fn(*mut *mut c_void) -> c_int,
f_cublasDestroy_v2: unsafe extern "C" fn(*mut c_void) -> c_int,
f_cublasSgemm_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, *const f32, *const f32, c_int, *const f32, c_int, *const f32, *mut f32, c_int) -> c_int,
f_cublasHgemm: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, *const u16, *const u16, c_int, *const u16, c_int, *const u16, *mut u16, c_int) -> c_int,
f_cublasDgemm_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, *const f64, *const f64, c_int, *const f64, c_int, *const f64, *mut f64, c_int) -> c_int,
f_cublasSgemmStridedBatched: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, *const f32, *const f32, c_int, i64, *const f32, c_int, i64, *const f32, *mut f32, c_int, i64, c_int) -> c_int,
f_cublasDgemmStridedBatched: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, *const f64, *const f64, c_int, i64, *const f64, c_int, i64, *const f64, *mut f64, c_int, i64, c_int) -> c_int,
f_cublasSetStream_v2: unsafe extern "C" fn(*mut c_void, *mut c_void) -> c_int,
f_cublasSetWorkspace_v2: unsafe extern "C" fn(*mut c_void, *mut c_void, usize) -> c_int,
f_cublasSetMathMode: unsafe extern "C" fn(*mut c_void, c_int) -> c_int,
f_cublasGetMathMode: unsafe extern "C" fn(*mut c_void, *mut c_int) -> c_int,
f_cublasGetProperty: unsafe extern "C" fn(c_int, *mut c_int) -> c_int,
f_cublasGemmEx: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, *const f32, *const c_void, c_int, c_int, *const c_void, c_int, c_int, *const f32, *mut c_void, c_int, c_int, c_int, c_int) -> c_int,
f_cublasGemmStridedBatchedEx: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, *const f32, *const c_void, c_int, c_int, i64, *const c_void, c_int, c_int, i64, *const f32, *mut c_void, c_int, c_int, i64, c_int, c_int, c_int) -> c_int,
f_cublasGemmBatchedEx: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, *const f32, *const c_void, c_int, c_int, *const c_void, c_int, c_int, *const f32, *mut c_void, c_int, c_int, c_int, c_int, c_int) -> c_int,
f_cublasSaxpy_v2: unsafe extern "C" fn(*mut c_void, c_int, *const f32, *const f32, c_int, *mut f32, c_int) -> c_int,
f_cublasDaxpy_v2: unsafe extern "C" fn(*mut c_void, c_int, *const f64, *const f64, c_int, *mut f64, c_int) -> c_int,
f_cublasSscal_v2: unsafe extern "C" fn(*mut c_void, c_int, *const f32, *mut f32, c_int) -> c_int,
f_cublasDscal_v2: unsafe extern "C" fn(*mut c_void, c_int, *const f64, *mut f64, c_int) -> c_int,
f_cublasScopy_v2: unsafe extern "C" fn(*mut c_void, c_int, *mut f32, c_int, *mut f32, c_int) -> c_int,
f_cublasDcopy_v2: unsafe extern "C" fn(*mut c_void, c_int, *mut f64, c_int, *mut f64, c_int) -> c_int,
f_cublasSswap_v2: unsafe extern "C" fn(*mut c_void, c_int, *mut f32, c_int, *mut f32, c_int) -> c_int,
f_cublasDswap_v2: unsafe extern "C" fn(*mut c_void, c_int, *mut f64, c_int, *mut f64, c_int) -> c_int,
f_cublasSgemv_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, *const f32, *const f32, c_int, *const f32, c_int, *const f32, *mut f32, c_int) -> c_int,
f_cublasDgemv_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, *const f64, *const f64, c_int, *const f64, c_int, *const f64, *mut f64, c_int) -> c_int,
f_cublasSger_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, *const f32, *const f32, c_int, *const f32, c_int, *mut f32, c_int) -> c_int,
f_cublasDger_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, *const f64, *const f64, c_int, *const f64, c_int, *mut f64, c_int) -> c_int,
f_cublasSgeam: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, *const f32, *const f32, c_int, *const f32, *const f32, c_int, *mut f32, c_int) -> c_int,
f_cublasDgeam: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, *const f64, *const f64, c_int, *const f64, *const f64, c_int, *mut f64, c_int) -> c_int,
f_cublasSsyrk_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, *const f32, *const f32, c_int, *const f32, *mut f32, c_int) -> c_int,
f_cublasDsyrk_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, *const f64, *const f64, c_int, *const f64, *mut f64, c_int) -> c_int,
f_cublasSsymm_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, *const f32, *const f32, c_int, *const f32, c_int, *const f32, *mut f32, c_int) -> c_int,
f_cublasDsymm_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, *const f64, *const f64, c_int, *const f64, c_int, *const f64, *mut f64, c_int) -> c_int,
f_cublasStrsm_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, c_int, *const f32, *const f32, c_int, *mut f32, c_int) -> c_int,
f_cublasDtrsm_v2: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, c_int, *const f64, *const f64, c_int, *mut f64, c_int) -> c_int,
}
impl GenLib {
pub fn load() -> Result<GenLib, String> {
unsafe {
let lib = super::open_host_lib("SMOLVM_CUBLAS_LIB", &["libcublas.so", "libcublas.so.13", "libcublas.so.12"])?;
Ok(GenLib {
f_cublasCreate_v2: sym(&lib, b"cublasCreate_v2\0")?,
f_cublasDestroy_v2: sym(&lib, b"cublasDestroy_v2\0")?,
f_cublasSgemm_v2: sym(&lib, b"cublasSgemm_v2\0")?,
f_cublasHgemm: sym(&lib, b"cublasHgemm\0")?,
f_cublasDgemm_v2: sym(&lib, b"cublasDgemm_v2\0")?,
f_cublasSgemmStridedBatched: sym(&lib, b"cublasSgemmStridedBatched\0")?,
f_cublasDgemmStridedBatched: sym(&lib, b"cublasDgemmStridedBatched\0")?,
f_cublasSetStream_v2: sym(&lib, b"cublasSetStream_v2\0")?,
f_cublasSetWorkspace_v2: sym(&lib, b"cublasSetWorkspace_v2\0")?,
f_cublasSetMathMode: sym(&lib, b"cublasSetMathMode\0")?,
f_cublasGetMathMode: sym(&lib, b"cublasGetMathMode\0")?,
f_cublasGetProperty: sym(&lib, b"cublasGetProperty\0")?,
f_cublasGemmEx: sym(&lib, b"cublasGemmEx\0")?,
f_cublasGemmStridedBatchedEx: sym(&lib, b"cublasGemmStridedBatchedEx\0")?,
f_cublasGemmBatchedEx: sym(&lib, b"cublasGemmBatchedEx\0")?,
f_cublasSaxpy_v2: sym(&lib, b"cublasSaxpy_v2\0")?,
f_cublasDaxpy_v2: sym(&lib, b"cublasDaxpy_v2\0")?,
f_cublasSscal_v2: sym(&lib, b"cublasSscal_v2\0")?,
f_cublasDscal_v2: sym(&lib, b"cublasDscal_v2\0")?,
f_cublasScopy_v2: sym(&lib, b"cublasScopy_v2\0")?,
f_cublasDcopy_v2: sym(&lib, b"cublasDcopy_v2\0")?,
f_cublasSswap_v2: sym(&lib, b"cublasSswap_v2\0")?,
f_cublasDswap_v2: sym(&lib, b"cublasDswap_v2\0")?,
f_cublasSgemv_v2: sym(&lib, b"cublasSgemv_v2\0")?,
f_cublasDgemv_v2: sym(&lib, b"cublasDgemv_v2\0")?,
f_cublasSger_v2: sym(&lib, b"cublasSger_v2\0")?,
f_cublasDger_v2: sym(&lib, b"cublasDger_v2\0")?,
f_cublasSgeam: sym(&lib, b"cublasSgeam\0")?,
f_cublasDgeam: sym(&lib, b"cublasDgeam\0")?,
f_cublasSsyrk_v2: sym(&lib, b"cublasSsyrk_v2\0")?,
f_cublasDsyrk_v2: sym(&lib, b"cublasDsyrk_v2\0")?,
f_cublasSsymm_v2: sym(&lib, b"cublasSsymm_v2\0")?,
f_cublasDsymm_v2: sym(&lib, b"cublasDsymm_v2\0")?,
f_cublasStrsm_v2: sym(&lib, b"cublasStrsm_v2\0")?,
f_cublasDtrsm_v2: sym(&lib, b"cublasDtrsm_v2\0")?,
_lib: lib,
})
}
}
pub fn dispatch(&self, func: u16, args: &[u8], __vh: &mut std::collections::HashMap<u64, u64>, __streams: &std::collections::HashMap<u64, u64>) -> (i32, Vec<u8>) {
let mut __c = GenCur { b: args, p: 0 };
let _ = __streams;
match func {
0 => { let mut h: *mut c_void = std::ptr::null_mut(); let st = unsafe { (self.f_cublasCreate_v2)(&mut h) }; if st == 0 && args.len() >= 8 { let id = u64::from_le_bytes(args[..8].try_into().unwrap()); if id & super::VHANDLE_TAG != 0 { __vh.insert(id, h as u64); } } (st, (h as u64).to_le_bytes().to_vec()) }
1 => {
let __raw = __c.u64();
let handle = super::vh_resolve(__vh, __raw) as *mut c_void;
if __raw & super::VHANDLE_TAG != 0 { __vh.remove(&__raw); }
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDestroy_v2)(handle) };
(st, out)
}
2 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let transa = __c.i32();
let transb = __c.i32();
let m = __c.i32();
let n = __c.i32();
let k = __c.i32();
let alpha_v = __c.f32();
let A = __c.u64() as *const f32;
let lda = __c.i32();
let B = __c.u64() as *const f32;
let ldb = __c.i32();
let beta_v = __c.f32();
let C = __c.u64() as *mut f32;
let ldc = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSgemm_v2)(handle, transa as c_int, transb as c_int, m as c_int, n as c_int, k as c_int, &alpha_v, A, lda as c_int, B, ldb as c_int, &beta_v, C, ldc as c_int) };
(st, out)
}
3 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let transa = __c.i32();
let transb = __c.i32();
let m = __c.i32();
let n = __c.i32();
let k = __c.i32();
let alpha_v = __c.u16();
let A = __c.u64() as *const u16;
let lda = __c.i32();
let B = __c.u64() as *const u16;
let ldb = __c.i32();
let beta_v = __c.u16();
let C = __c.u64() as *mut u16;
let ldc = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasHgemm)(handle, transa as c_int, transb as c_int, m as c_int, n as c_int, k as c_int, &alpha_v, A, lda as c_int, B, ldb as c_int, &beta_v, C, ldc as c_int) };
(st, out)
}
4 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let transa = __c.i32();
let transb = __c.i32();
let m = __c.i32();
let n = __c.i32();
let k = __c.i32();
let alpha_v = __c.f64();
let A = __c.u64() as *const f64;
let lda = __c.i32();
let B = __c.u64() as *const f64;
let ldb = __c.i32();
let beta_v = __c.f64();
let C = __c.u64() as *mut f64;
let ldc = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDgemm_v2)(handle, transa as c_int, transb as c_int, m as c_int, n as c_int, k as c_int, &alpha_v, A, lda as c_int, B, ldb as c_int, &beta_v, C, ldc as c_int) };
(st, out)
}
5 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let transa = __c.i32();
let transb = __c.i32();
let m = __c.i32();
let n = __c.i32();
let k = __c.i32();
let alpha_v = __c.f32();
let A = __c.u64() as *const f32;
let lda = __c.i32();
let strideA = __c.i64();
let B = __c.u64() as *const f32;
let ldb = __c.i32();
let strideB = __c.i64();
let beta_v = __c.f32();
let C = __c.u64() as *mut f32;
let ldc = __c.i32();
let strideC = __c.i64();
let batchCount = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSgemmStridedBatched)(handle, transa as c_int, transb as c_int, m as c_int, n as c_int, k as c_int, &alpha_v, A, lda as c_int, strideA as i64, B, ldb as c_int, strideB as i64, &beta_v, C, ldc as c_int, strideC as i64, batchCount as c_int) };
(st, out)
}
6 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let transa = __c.i32();
let transb = __c.i32();
let m = __c.i32();
let n = __c.i32();
let k = __c.i32();
let alpha_v = __c.f64();
let A = __c.u64() as *const f64;
let lda = __c.i32();
let strideA = __c.i64();
let B = __c.u64() as *const f64;
let ldb = __c.i32();
let strideB = __c.i64();
let beta_v = __c.f64();
let C = __c.u64() as *mut f64;
let ldc = __c.i32();
let strideC = __c.i64();
let batchCount = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDgemmStridedBatched)(handle, transa as c_int, transb as c_int, m as c_int, n as c_int, k as c_int, &alpha_v, A, lda as c_int, strideA as i64, B, ldb as c_int, strideB as i64, &beta_v, C, ldc as c_int, strideC as i64, batchCount as c_int) };
(st, out)
}
7 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let stream = super::stream_resolve(__streams, __c.u64()) as *mut c_void;
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSetStream_v2)(handle, stream) };
(st, out)
}
8 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let workspace = __c.u64() as *mut c_void;
let size = __c.u64();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSetWorkspace_v2)(handle, workspace, size as usize) };
(st, out)
}
9 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let mode = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSetMathMode)(handle, mode as c_int) };
(st, out)
}
10 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let mut mode_v: c_int = 0 as c_int;
let mut out = Vec::new();
let st = unsafe { (self.f_cublasGetMathMode)(handle, &mut mode_v) };
out.extend_from_slice(&(mode_v as i32).to_le_bytes());
(st, out)
}
11 => {
let prop_type = __c.i32();
let mut value_v: c_int = 0 as c_int;
let mut out = Vec::new();
let st = unsafe { (self.f_cublasGetProperty)(prop_type as c_int, &mut value_v) };
out.extend_from_slice(&(value_v as i32).to_le_bytes());
(st, out)
}
12 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let transa = __c.i32();
let transb = __c.i32();
let m = __c.i32();
let n = __c.i32();
let k = __c.i32();
let alpha_v = __c.f32();
let A = __c.u64() as *const c_void;
let Atype = __c.i32();
let lda = __c.i32();
let B = __c.u64() as *const c_void;
let Btype = __c.i32();
let ldb = __c.i32();
let beta_v = __c.f32();
let C = __c.u64() as *mut c_void;
let Ctype = __c.i32();
let ldc = __c.i32();
let computeType = __c.i32();
let algo = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasGemmEx)(handle, transa as c_int, transb as c_int, m as c_int, n as c_int, k as c_int, &alpha_v, A, Atype as c_int, lda as c_int, B, Btype as c_int, ldb as c_int, &beta_v, C, Ctype as c_int, ldc as c_int, computeType as c_int, algo as c_int) };
(st, out)
}
13 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let transa = __c.i32();
let transb = __c.i32();
let m = __c.i32();
let n = __c.i32();
let k = __c.i32();
let alpha_v = __c.f32();
let A = __c.u64() as *const c_void;
let Atype = __c.i32();
let lda = __c.i32();
let strideA = __c.i64();
let B = __c.u64() as *const c_void;
let Btype = __c.i32();
let ldb = __c.i32();
let strideB = __c.i64();
let beta_v = __c.f32();
let C = __c.u64() as *mut c_void;
let Ctype = __c.i32();
let ldc = __c.i32();
let strideC = __c.i64();
let batchCount = __c.i32();
let computeType = __c.i32();
let algo = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasGemmStridedBatchedEx)(handle, transa as c_int, transb as c_int, m as c_int, n as c_int, k as c_int, &alpha_v, A, Atype as c_int, lda as c_int, strideA as i64, B, Btype as c_int, ldb as c_int, strideB as i64, &beta_v, C, Ctype as c_int, ldc as c_int, strideC as i64, batchCount as c_int, computeType as c_int, algo as c_int) };
(st, out)
}
14 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let transa = __c.i32();
let transb = __c.i32();
let m = __c.i32();
let n = __c.i32();
let k = __c.i32();
let alpha_v = __c.f32();
let Aarray = __c.u64() as *const c_void;
let Atype = __c.i32();
let lda = __c.i32();
let Barray = __c.u64() as *const c_void;
let Btype = __c.i32();
let ldb = __c.i32();
let beta_v = __c.f32();
let Carray = __c.u64() as *mut c_void;
let Ctype = __c.i32();
let ldc = __c.i32();
let batchCount = __c.i32();
let computeType = __c.i32();
let algo = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasGemmBatchedEx)(handle, transa as c_int, transb as c_int, m as c_int, n as c_int, k as c_int, &alpha_v, Aarray, Atype as c_int, lda as c_int, Barray, Btype as c_int, ldb as c_int, &beta_v, Carray, Ctype as c_int, ldc as c_int, batchCount as c_int, computeType as c_int, algo as c_int) };
(st, out)
}
15 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let n = __c.i32();
let alpha_v = __c.f32();
let x = __c.u64() as *const f32;
let incx = __c.i32();
let y = __c.u64() as *mut f32;
let incy = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSaxpy_v2)(handle, n as c_int, &alpha_v, x, incx as c_int, y, incy as c_int) };
(st, out)
}
16 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let n = __c.i32();
let alpha_v = __c.f64();
let x = __c.u64() as *const f64;
let incx = __c.i32();
let y = __c.u64() as *mut f64;
let incy = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDaxpy_v2)(handle, n as c_int, &alpha_v, x, incx as c_int, y, incy as c_int) };
(st, out)
}
17 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let n = __c.i32();
let alpha_v = __c.f32();
let x = __c.u64() as *mut f32;
let incx = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSscal_v2)(handle, n as c_int, &alpha_v, x, incx as c_int) };
(st, out)
}
18 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let n = __c.i32();
let alpha_v = __c.f64();
let x = __c.u64() as *mut f64;
let incx = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDscal_v2)(handle, n as c_int, &alpha_v, x, incx as c_int) };
(st, out)
}
19 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let n = __c.i32();
let x = __c.u64() as *mut f32;
let incx = __c.i32();
let y = __c.u64() as *mut f32;
let incy = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasScopy_v2)(handle, n as c_int, x, incx as c_int, y, incy as c_int) };
(st, out)
}
20 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let n = __c.i32();
let x = __c.u64() as *mut f64;
let incx = __c.i32();
let y = __c.u64() as *mut f64;
let incy = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDcopy_v2)(handle, n as c_int, x, incx as c_int, y, incy as c_int) };
(st, out)
}
21 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let n = __c.i32();
let x = __c.u64() as *mut f32;
let incx = __c.i32();
let y = __c.u64() as *mut f32;
let incy = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSswap_v2)(handle, n as c_int, x, incx as c_int, y, incy as c_int) };
(st, out)
}
22 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let n = __c.i32();
let x = __c.u64() as *mut f64;
let incx = __c.i32();
let y = __c.u64() as *mut f64;
let incy = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDswap_v2)(handle, n as c_int, x, incx as c_int, y, incy as c_int) };
(st, out)
}
23 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let trans = __c.i32();
let m = __c.i32();
let n = __c.i32();
let alpha_v = __c.f32();
let A = __c.u64() as *const f32;
let lda = __c.i32();
let x = __c.u64() as *const f32;
let incx = __c.i32();
let beta_v = __c.f32();
let y = __c.u64() as *mut f32;
let incy = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSgemv_v2)(handle, trans as c_int, m as c_int, n as c_int, &alpha_v, A, lda as c_int, x, incx as c_int, &beta_v, y, incy as c_int) };
(st, out)
}
24 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let trans = __c.i32();
let m = __c.i32();
let n = __c.i32();
let alpha_v = __c.f64();
let A = __c.u64() as *const f64;
let lda = __c.i32();
let x = __c.u64() as *const f64;
let incx = __c.i32();
let beta_v = __c.f64();
let y = __c.u64() as *mut f64;
let incy = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDgemv_v2)(handle, trans as c_int, m as c_int, n as c_int, &alpha_v, A, lda as c_int, x, incx as c_int, &beta_v, y, incy as c_int) };
(st, out)
}
25 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let m = __c.i32();
let n = __c.i32();
let alpha_v = __c.f32();
let x = __c.u64() as *const f32;
let incx = __c.i32();
let y = __c.u64() as *const f32;
let incy = __c.i32();
let A = __c.u64() as *mut f32;
let lda = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSger_v2)(handle, m as c_int, n as c_int, &alpha_v, x, incx as c_int, y, incy as c_int, A, lda as c_int) };
(st, out)
}
26 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let m = __c.i32();
let n = __c.i32();
let alpha_v = __c.f64();
let x = __c.u64() as *const f64;
let incx = __c.i32();
let y = __c.u64() as *const f64;
let incy = __c.i32();
let A = __c.u64() as *mut f64;
let lda = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDger_v2)(handle, m as c_int, n as c_int, &alpha_v, x, incx as c_int, y, incy as c_int, A, lda as c_int) };
(st, out)
}
27 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let transa = __c.i32();
let transb = __c.i32();
let m = __c.i32();
let n = __c.i32();
let alpha_v = __c.f32();
let A = __c.u64() as *const f32;
let lda = __c.i32();
let beta_v = __c.f32();
let B = __c.u64() as *const f32;
let ldb = __c.i32();
let C = __c.u64() as *mut f32;
let ldc = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSgeam)(handle, transa as c_int, transb as c_int, m as c_int, n as c_int, &alpha_v, A, lda as c_int, &beta_v, B, ldb as c_int, C, ldc as c_int) };
(st, out)
}
28 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let transa = __c.i32();
let transb = __c.i32();
let m = __c.i32();
let n = __c.i32();
let alpha_v = __c.f64();
let A = __c.u64() as *const f64;
let lda = __c.i32();
let beta_v = __c.f64();
let B = __c.u64() as *const f64;
let ldb = __c.i32();
let C = __c.u64() as *mut f64;
let ldc = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDgeam)(handle, transa as c_int, transb as c_int, m as c_int, n as c_int, &alpha_v, A, lda as c_int, &beta_v, B, ldb as c_int, C, ldc as c_int) };
(st, out)
}
29 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let uplo = __c.i32();
let trans = __c.i32();
let n = __c.i32();
let k = __c.i32();
let alpha_v = __c.f32();
let A = __c.u64() as *const f32;
let lda = __c.i32();
let beta_v = __c.f32();
let C = __c.u64() as *mut f32;
let ldc = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSsyrk_v2)(handle, uplo as c_int, trans as c_int, n as c_int, k as c_int, &alpha_v, A, lda as c_int, &beta_v, C, ldc as c_int) };
(st, out)
}
30 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let uplo = __c.i32();
let trans = __c.i32();
let n = __c.i32();
let k = __c.i32();
let alpha_v = __c.f64();
let A = __c.u64() as *const f64;
let lda = __c.i32();
let beta_v = __c.f64();
let C = __c.u64() as *mut f64;
let ldc = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDsyrk_v2)(handle, uplo as c_int, trans as c_int, n as c_int, k as c_int, &alpha_v, A, lda as c_int, &beta_v, C, ldc as c_int) };
(st, out)
}
31 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let side = __c.i32();
let uplo = __c.i32();
let m = __c.i32();
let n = __c.i32();
let alpha_v = __c.f32();
let A = __c.u64() as *const f32;
let lda = __c.i32();
let B = __c.u64() as *const f32;
let ldb = __c.i32();
let beta_v = __c.f32();
let C = __c.u64() as *mut f32;
let ldc = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasSsymm_v2)(handle, side as c_int, uplo as c_int, m as c_int, n as c_int, &alpha_v, A, lda as c_int, B, ldb as c_int, &beta_v, C, ldc as c_int) };
(st, out)
}
32 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let side = __c.i32();
let uplo = __c.i32();
let m = __c.i32();
let n = __c.i32();
let alpha_v = __c.f64();
let A = __c.u64() as *const f64;
let lda = __c.i32();
let B = __c.u64() as *const f64;
let ldb = __c.i32();
let beta_v = __c.f64();
let C = __c.u64() as *mut f64;
let ldc = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDsymm_v2)(handle, side as c_int, uplo as c_int, m as c_int, n as c_int, &alpha_v, A, lda as c_int, B, ldb as c_int, &beta_v, C, ldc as c_int) };
(st, out)
}
33 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let side = __c.i32();
let uplo = __c.i32();
let trans = __c.i32();
let diag = __c.i32();
let m = __c.i32();
let n = __c.i32();
let alpha_v = __c.f32();
let A = __c.u64() as *const f32;
let lda = __c.i32();
let B = __c.u64() as *mut f32;
let ldb = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasStrsm_v2)(handle, side as c_int, uplo as c_int, trans as c_int, diag as c_int, m as c_int, n as c_int, &alpha_v, A, lda as c_int, B, ldb as c_int) };
(st, out)
}
34 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let side = __c.i32();
let uplo = __c.i32();
let trans = __c.i32();
let diag = __c.i32();
let m = __c.i32();
let n = __c.i32();
let alpha_v = __c.f64();
let A = __c.u64() as *const f64;
let lda = __c.i32();
let B = __c.u64() as *mut f64;
let ldb = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cublasDtrsm_v2)(handle, side as c_int, uplo as c_int, trans as c_int, diag as c_int, m as c_int, n as c_int, &alpha_v, A, lda as c_int, B, ldb as c_int) };
(st, out)
}
_ => (super::super::CUDA_ERROR_NOT_FOUND, Vec::new()),
}
}
}
struct GenCur<'a> { b: &'a [u8], p: usize }
impl GenCur<'_> {
fn take(&mut self, n: usize) -> [u8; 8] { let mut o = [0u8; 8]; let end = (self.p + n).min(self.b.len()); o[..end - self.p].copy_from_slice(&self.b[self.p..end]); self.p = end; o }
fn u16(&mut self) -> u16 { u16::from_le_bytes(self.take(2)[..2].try_into().unwrap()) }
fn i32(&mut self) -> i32 { i32::from_le_bytes(self.take(4)[..4].try_into().unwrap()) }
fn i64(&mut self) -> i64 { i64::from_le_bytes(self.take(8)) }
fn u64(&mut self) -> u64 { u64::from_le_bytes(self.take(8)) }
fn f32(&mut self) -> f32 { f32::from_le_bytes(self.take(4)[..4].try_into().unwrap()) }
fn f64(&mut self) -> f64 { f64::from_le_bytes(self.take(8)) }
}
@@ -0,0 +1,164 @@
// @generated by smolvm-cuda-codegen — do not edit. lib='cudnn' id=2
const LIB_ID: u8 = 2;
#[no_mangle]
pub extern "C" fn cudnnCreate(handle_out: *mut *mut c_void) -> c_int {
if handle_out.is_null() { return 1; }
// Fire-and-forget create: return a guest-assigned virtual id
// immediately; the host materializes the real descriptor and
// maps the id (see vh_resolve on the host side).
let vh = super::alloc_vhandle();
match with_client(|c| c.lib_call_deferred(LIB_ID, 0, vh.to_le_bytes().to_vec())) {
Ok(()) => { unsafe { *handle_out = vh as *mut c_void }; 0 }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cudnnDestroy(handle: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 1, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnSetStream(handle: *mut c_void, stream: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(stream as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 2, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnCreateTensorDescriptor(handle_out: *mut *mut c_void) -> c_int {
if handle_out.is_null() { return 1; }
// Fire-and-forget create: return a guest-assigned virtual id
// immediately; the host materializes the real descriptor and
// maps the id (see vh_resolve on the host side).
let vh = super::alloc_vhandle();
match with_client(|c| c.lib_call_deferred(LIB_ID, 3, vh.to_le_bytes().to_vec())) {
Ok(()) => { unsafe { *handle_out = vh as *mut c_void }; 0 }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cudnnSetTensor4dDescriptor(t: *mut c_void, fmt: c_int, dtype: c_int, n: c_int, c: c_int, h: c_int, w: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(t as u64).to_le_bytes());
a.extend_from_slice(&(fmt as i32).to_le_bytes());
a.extend_from_slice(&(dtype as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(c as i32).to_le_bytes());
a.extend_from_slice(&(h as i32).to_le_bytes());
a.extend_from_slice(&(w as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 4, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnDestroyTensorDescriptor(t: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(t as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 5, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnCreateFilterDescriptor(handle_out: *mut *mut c_void) -> c_int {
if handle_out.is_null() { return 1; }
// Fire-and-forget create: return a guest-assigned virtual id
// immediately; the host materializes the real descriptor and
// maps the id (see vh_resolve on the host side).
let vh = super::alloc_vhandle();
match with_client(|c| c.lib_call_deferred(LIB_ID, 6, vh.to_le_bytes().to_vec())) {
Ok(()) => { unsafe { *handle_out = vh as *mut c_void }; 0 }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cudnnSetFilter4dDescriptor(f: *mut c_void, dtype: c_int, fmt: c_int, k: c_int, c: c_int, h: c_int, w: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(f as u64).to_le_bytes());
a.extend_from_slice(&(dtype as i32).to_le_bytes());
a.extend_from_slice(&(fmt as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
a.extend_from_slice(&(c as i32).to_le_bytes());
a.extend_from_slice(&(h as i32).to_le_bytes());
a.extend_from_slice(&(w as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 7, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnDestroyFilterDescriptor(f: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(f as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 8, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnCreateConvolutionDescriptor(handle_out: *mut *mut c_void) -> c_int {
if handle_out.is_null() { return 1; }
// Fire-and-forget create: return a guest-assigned virtual id
// immediately; the host materializes the real descriptor and
// maps the id (see vh_resolve on the host side).
let vh = super::alloc_vhandle();
match with_client(|c| c.lib_call_deferred(LIB_ID, 9, vh.to_le_bytes().to_vec())) {
Ok(()) => { unsafe { *handle_out = vh as *mut c_void }; 0 }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cudnnSetConvolution2dDescriptor(cv: *mut c_void, pad_h: c_int, pad_w: c_int, u: c_int, v: c_int, dil_h: c_int, dil_w: c_int, mode: c_int, ctype: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(cv as u64).to_le_bytes());
a.extend_from_slice(&(pad_h as i32).to_le_bytes());
a.extend_from_slice(&(pad_w as i32).to_le_bytes());
a.extend_from_slice(&(u as i32).to_le_bytes());
a.extend_from_slice(&(v as i32).to_le_bytes());
a.extend_from_slice(&(dil_h as i32).to_le_bytes());
a.extend_from_slice(&(dil_w as i32).to_le_bytes());
a.extend_from_slice(&(mode as i32).to_le_bytes());
a.extend_from_slice(&(ctype as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 10, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnDestroyConvolutionDescriptor(cv: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(cv as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 11, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnGetConvolutionForwardWorkspaceSize(handle: *mut c_void, x: *mut c_void, w: *mut c_void, cv: *mut c_void, y: *mut c_void, algo: c_int, size: *mut usize) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(w as u64).to_le_bytes());
a.extend_from_slice(&(cv as u64).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(algo as i32).to_le_bytes());
match with_client(|c| c.lib_call(LIB_ID, 12, a)) {
Ok((st, out)) => { let mut o = 0usize;
if !size.is_null() && out.len() >= o + 8 { unsafe { *size = u64::from_le_bytes(out[o..o + 8].try_into().unwrap()) as _ }; } o += 8;
let _ = o; st }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cudnnConvolutionForward(handle: *mut c_void, alpha: *const f32, xDesc: *mut c_void, x: *mut c_void, wDesc: *mut c_void, w: *mut c_void, convDesc: *mut c_void, algo: c_int, workspace: *mut c_void, wsSize: usize, beta: *const f32, yDesc: *mut c_void, y: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(xDesc as u64).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(wDesc as u64).to_le_bytes());
a.extend_from_slice(&(w as u64).to_le_bytes());
a.extend_from_slice(&(convDesc as u64).to_le_bytes());
a.extend_from_slice(&(algo as i32).to_le_bytes());
a.extend_from_slice(&(workspace as u64).to_le_bytes());
a.extend_from_slice(&(wsSize as u64).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(yDesc as u64).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 13, a)) { Ok(()) => 0, Err(_) => 1 }
}
@@ -0,0 +1,170 @@
// @generated by smolvm-cuda-codegen — do not edit. lib='cudnn' id=2
pub struct GenLib { _lib: Library,
f_cudnnCreate: unsafe extern "C" fn(*mut *mut c_void) -> c_int,
f_cudnnDestroy: unsafe extern "C" fn(*mut c_void) -> c_int,
f_cudnnSetStream: unsafe extern "C" fn(*mut c_void, *mut c_void) -> c_int,
f_cudnnCreateTensorDescriptor: unsafe extern "C" fn(*mut *mut c_void) -> c_int,
f_cudnnSetTensor4dDescriptor: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, c_int) -> c_int,
f_cudnnDestroyTensorDescriptor: unsafe extern "C" fn(*mut c_void) -> c_int,
f_cudnnCreateFilterDescriptor: unsafe extern "C" fn(*mut *mut c_void) -> c_int,
f_cudnnSetFilter4dDescriptor: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, c_int) -> c_int,
f_cudnnDestroyFilterDescriptor: unsafe extern "C" fn(*mut c_void) -> c_int,
f_cudnnCreateConvolutionDescriptor: unsafe extern "C" fn(*mut *mut c_void) -> c_int,
f_cudnnSetConvolution2dDescriptor: unsafe extern "C" fn(*mut c_void, c_int, c_int, c_int, c_int, c_int, c_int, c_int, c_int) -> c_int,
f_cudnnDestroyConvolutionDescriptor: unsafe extern "C" fn(*mut c_void) -> c_int,
f_cudnnGetConvolutionForwardWorkspaceSize: unsafe extern "C" fn(*mut c_void, *mut c_void, *mut c_void, *mut c_void, *mut c_void, c_int, *mut usize) -> c_int,
f_cudnnConvolutionForward: unsafe extern "C" fn(*mut c_void, *const f32, *mut c_void, *mut c_void, *mut c_void, *mut c_void, *mut c_void, c_int, *mut c_void, usize, *const f32, *mut c_void, *mut c_void) -> c_int,
}
impl GenLib {
pub fn load() -> Result<GenLib, String> {
unsafe {
let lib = super::open_host_lib("SMOLVM_CUDNN_LIB", &["libcudnn.so", "libcudnn.so.9", "libcudnn.so.8"])?;
Ok(GenLib {
f_cudnnCreate: sym(&lib, b"cudnnCreate\0")?,
f_cudnnDestroy: sym(&lib, b"cudnnDestroy\0")?,
f_cudnnSetStream: sym(&lib, b"cudnnSetStream\0")?,
f_cudnnCreateTensorDescriptor: sym(&lib, b"cudnnCreateTensorDescriptor\0")?,
f_cudnnSetTensor4dDescriptor: sym(&lib, b"cudnnSetTensor4dDescriptor\0")?,
f_cudnnDestroyTensorDescriptor: sym(&lib, b"cudnnDestroyTensorDescriptor\0")?,
f_cudnnCreateFilterDescriptor: sym(&lib, b"cudnnCreateFilterDescriptor\0")?,
f_cudnnSetFilter4dDescriptor: sym(&lib, b"cudnnSetFilter4dDescriptor\0")?,
f_cudnnDestroyFilterDescriptor: sym(&lib, b"cudnnDestroyFilterDescriptor\0")?,
f_cudnnCreateConvolutionDescriptor: sym(&lib, b"cudnnCreateConvolutionDescriptor\0")?,
f_cudnnSetConvolution2dDescriptor: sym(&lib, b"cudnnSetConvolution2dDescriptor\0")?,
f_cudnnDestroyConvolutionDescriptor: sym(&lib, b"cudnnDestroyConvolutionDescriptor\0")?,
f_cudnnGetConvolutionForwardWorkspaceSize: sym(&lib, b"cudnnGetConvolutionForwardWorkspaceSize\0")?,
f_cudnnConvolutionForward: sym(&lib, b"cudnnConvolutionForward\0")?,
_lib: lib,
})
}
}
pub fn dispatch(&self, func: u16, args: &[u8], __vh: &mut std::collections::HashMap<u64, u64>, __streams: &std::collections::HashMap<u64, u64>) -> (i32, Vec<u8>) {
let mut __c = GenCur { b: args, p: 0 };
let _ = __streams;
match func {
0 => { let mut h: *mut c_void = std::ptr::null_mut(); let st = unsafe { (self.f_cudnnCreate)(&mut h) }; if st == 0 && args.len() >= 8 { let id = u64::from_le_bytes(args[..8].try_into().unwrap()); if id & super::VHANDLE_TAG != 0 { __vh.insert(id, h as u64); } } (st, (h as u64).to_le_bytes().to_vec()) }
1 => {
let __raw = __c.u64();
let handle = super::vh_resolve(__vh, __raw) as *mut c_void;
if __raw & super::VHANDLE_TAG != 0 { __vh.remove(&__raw); }
let mut out = Vec::new();
let st = unsafe { (self.f_cudnnDestroy)(handle) };
(st, out)
}
2 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let stream = super::stream_resolve(__streams, __c.u64()) as *mut c_void;
let mut out = Vec::new();
let st = unsafe { (self.f_cudnnSetStream)(handle, stream) };
(st, out)
}
3 => { let mut h: *mut c_void = std::ptr::null_mut(); let st = unsafe { (self.f_cudnnCreateTensorDescriptor)(&mut h) }; if st == 0 && args.len() >= 8 { let id = u64::from_le_bytes(args[..8].try_into().unwrap()); if id & super::VHANDLE_TAG != 0 { __vh.insert(id, h as u64); } } (st, (h as u64).to_le_bytes().to_vec()) }
4 => {
let t = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let fmt = __c.i32();
let dtype = __c.i32();
let n = __c.i32();
let c = __c.i32();
let h = __c.i32();
let w = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cudnnSetTensor4dDescriptor)(t, fmt as c_int, dtype as c_int, n as c_int, c as c_int, h as c_int, w as c_int) };
(st, out)
}
5 => {
let __raw = __c.u64();
let t = super::vh_resolve(__vh, __raw) as *mut c_void;
if __raw & super::VHANDLE_TAG != 0 { __vh.remove(&__raw); }
let mut out = Vec::new();
let st = unsafe { (self.f_cudnnDestroyTensorDescriptor)(t) };
(st, out)
}
6 => { let mut h: *mut c_void = std::ptr::null_mut(); let st = unsafe { (self.f_cudnnCreateFilterDescriptor)(&mut h) }; if st == 0 && args.len() >= 8 { let id = u64::from_le_bytes(args[..8].try_into().unwrap()); if id & super::VHANDLE_TAG != 0 { __vh.insert(id, h as u64); } } (st, (h as u64).to_le_bytes().to_vec()) }
7 => {
let f = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let dtype = __c.i32();
let fmt = __c.i32();
let k = __c.i32();
let c = __c.i32();
let h = __c.i32();
let w = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cudnnSetFilter4dDescriptor)(f, dtype as c_int, fmt as c_int, k as c_int, c as c_int, h as c_int, w as c_int) };
(st, out)
}
8 => {
let __raw = __c.u64();
let f = super::vh_resolve(__vh, __raw) as *mut c_void;
if __raw & super::VHANDLE_TAG != 0 { __vh.remove(&__raw); }
let mut out = Vec::new();
let st = unsafe { (self.f_cudnnDestroyFilterDescriptor)(f) };
(st, out)
}
9 => { let mut h: *mut c_void = std::ptr::null_mut(); let st = unsafe { (self.f_cudnnCreateConvolutionDescriptor)(&mut h) }; if st == 0 && args.len() >= 8 { let id = u64::from_le_bytes(args[..8].try_into().unwrap()); if id & super::VHANDLE_TAG != 0 { __vh.insert(id, h as u64); } } (st, (h as u64).to_le_bytes().to_vec()) }
10 => {
let cv = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let pad_h = __c.i32();
let pad_w = __c.i32();
let u = __c.i32();
let v = __c.i32();
let dil_h = __c.i32();
let dil_w = __c.i32();
let mode = __c.i32();
let ctype = __c.i32();
let mut out = Vec::new();
let st = unsafe { (self.f_cudnnSetConvolution2dDescriptor)(cv, pad_h as c_int, pad_w as c_int, u as c_int, v as c_int, dil_h as c_int, dil_w as c_int, mode as c_int, ctype as c_int) };
(st, out)
}
11 => {
let __raw = __c.u64();
let cv = super::vh_resolve(__vh, __raw) as *mut c_void;
if __raw & super::VHANDLE_TAG != 0 { __vh.remove(&__raw); }
let mut out = Vec::new();
let st = unsafe { (self.f_cudnnDestroyConvolutionDescriptor)(cv) };
(st, out)
}
12 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let x = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let w = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let cv = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let y = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let algo = __c.i32();
let mut size_v: usize = 0 as usize;
let mut out = Vec::new();
let st = unsafe { (self.f_cudnnGetConvolutionForwardWorkspaceSize)(handle, x, w, cv, y, algo as c_int, &mut size_v) };
out.extend_from_slice(&(size_v as u64).to_le_bytes());
(st, out)
}
13 => {
let handle = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let alpha_v = __c.f32();
let xDesc = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let x = __c.u64() as *mut c_void;
let wDesc = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let w = __c.u64() as *mut c_void;
let convDesc = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let algo = __c.i32();
let workspace = __c.u64() as *mut c_void;
let wsSize = __c.u64();
let beta_v = __c.f32();
let yDesc = super::vh_resolve(__vh, __c.u64()) as *mut c_void;
let y = __c.u64() as *mut c_void;
let mut out = Vec::new();
let st = unsafe { (self.f_cudnnConvolutionForward)(handle, &alpha_v, xDesc, x, wDesc, w, convDesc, algo as c_int, workspace, wsSize as usize, &beta_v, yDesc, y) };
(st, out)
}
_ => (super::super::CUDA_ERROR_NOT_FOUND, Vec::new()),
}
}
}
struct GenCur<'a> { b: &'a [u8], p: usize }
impl GenCur<'_> {
fn take(&mut self, n: usize) -> [u8; 8] { let mut o = [0u8; 8]; let end = (self.p + n).min(self.b.len()); o[..end - self.p].copy_from_slice(&self.b[self.p..end]); self.p = end; o }
fn u16(&mut self) -> u16 { u16::from_le_bytes(self.take(2)[..2].try_into().unwrap()) }
fn i32(&mut self) -> i32 { i32::from_le_bytes(self.take(4)[..4].try_into().unwrap()) }
fn i64(&mut self) -> i64 { i64::from_le_bytes(self.take(8)) }
fn u64(&mut self) -> u64 { u64::from_le_bytes(self.take(8)) }
fn f32(&mut self) -> f32 { f32::from_le_bytes(self.take(4)[..4].try_into().unwrap()) }
fn f64(&mut self) -> f64 { f64::from_le_bytes(self.take(8)) }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
//! CUDA Driver-API remoting for smolvm: a guest microVM forwards `cu*` calls
//! over vsock to a host server that runs them on the host's NVIDIA GPU.
//!
//! - `proto` — the wire protocol (framing, request/response codec). No deps.
//! - `client` — guest-side marshalling over any `Read`/`Write` stream.
//! - `host` — host-side dispatch with a `Backend` trait; ships a real GPU
//! backend (`GpuBackend`, `gpu` feature) and a CPU emulation backend
//! (`CpuBackend`) for GPU-less verification.
//!
//! The guest binary depends on this crate with `default-features = false` to
//! pull only `proto` + `client` (no `libloading`), keeping the musl build lean.
pub mod client;
pub mod proto;
/// Shared-memory command/completion rings (low-latency in-VM transport).
pub mod ring;
/// Fingerprint of the wire-defining source (see `build.rs`). The client sends
/// it in the `Init` handshake; the host rejects a mismatch, turning a stale
/// shim/server pairing into a loud error instead of silent data corruption.
pub const PROTO_HASH: u64 = {
// env! gives the hex string from build.rs; parse it at compile time.
let s = env!("SMOLVM_PROTO_HASH").as_bytes();
let mut v = 0u64;
let mut i = 0;
while i < s.len() {
let d = match s[i] {
b'0'..=b'9' => s[i] - b'0',
b'a'..=b'f' => s[i] - b'a' + 10,
_ => 0,
};
v = v * 16 + d as u64;
i += 1;
}
v
};
/// Shared-memory bulk-data channel (zero-copy memcpy). Linux-only.
#[cfg(target_os = "linux")]
pub mod shm;
#[cfg(feature = "host")]
pub mod host;
File diff suppressed because it is too large Load Diff
+303
View File
@@ -0,0 +1,303 @@
//! Shared-memory command/completion rings: the low-latency transport between
//! a guest shim and the host CUDA service.
//!
//! The guest allocates two rings in its own RAM (request: guest→host,
//! completion: host→guest) and hands their guest-physical page lists to the
//! host over the bootstrap vsock connection (`Request::RingSetup`). The host
//! already maps guest RAM for zero-copy transfers, so both sides then share
//! the rings at memory speed: a sync round-trip costs ~1-2µs of polling
//! instead of a vsock wakeup (~50-70µs).
//!
//! Layout (one ring): a 64-byte header page-prefix followed by `capacity`
//! fixed-size records. Records never straddle a page boundary
//! (`RECORD_SIZE` divides the page size), because guest pages are physically
//! discontiguous — each side addresses record `i` through a per-page mapping
//! table, not one flat pointer.
//!
//! Record publish protocol: the producer writes payload then `len`, then
//! stores the slot's wrapping sequence number with release ordering; the
//! consumer reads the sequence with acquire ordering and only then the
//! payload — a torn read is impossible because a slot's sequence only
//! becomes valid after its bytes are complete.
//!
//! Doorbells: pure polling burns a core, so each side may park. A consumer
//! that decides to sleep sets `PARKED` in its ring's header *then re-checks
//! for new records* (the producer's publish-then-check mirrors it) and blocks
//! on the bootstrap vsock socket; a producer that sees `PARKED` after
//! publishing clears it and writes one byte to the socket. The socket carries
//! only doorbells after ring setup — every request and response rides the
//! rings, preserving the single program-ordered queue.
use std::sync::atomic::{AtomicU32, Ordering};
/// Bytes per record. Divides 4096 so records never straddle a guest page.
/// Sized so common frames stay inline: a Triton launch with ~30 pointer args
/// serializes to ~300 bytes, cudnn attribute blobs run to ~1 KiB.
pub const RECORD_SIZE: usize = 1024;
/// Per-record header: sequence (u32) + payload length (u32).
pub const RECORD_HDR: usize = 8;
/// Largest payload that fits inline in one record.
pub const INLINE_MAX: usize = RECORD_SIZE - RECORD_HDR;
/// Ring header size (one cache line, at the start of the first page).
pub const HEADER_SIZE: usize = 64;
/// Consumer-parked flag in the header `flags` word: the producer must clear
/// it and kick the doorbell after publishing.
pub const FLAG_PARKED: u32 = 1;
/// Payload `len` flag bit: the record body is not the payload itself but a
/// list of `(gpa: u64, len: u64)` segments the consumer must gather from
/// guest RAM (requests too large for a record — H2D byte-ship, fatbins).
pub const LEN_INDIRECT: u32 = 1 << 31;
/// Offsets within the 64-byte header.
const OFF_HEAD: usize = 0; // producer: next sequence to publish (u32)
const OFF_TAIL: usize = 8; // consumer: next sequence to consume (u32)
const OFF_FLAGS: usize = 16; // FLAG_PARKED etc.
/// One side of a ring: raw per-page pointers into the shared memory. The
/// guest builds this over its own allocation; the host over its mapping of
/// the same guest pages. `pages[0]` begins with the header; records start at
/// `HEADER_SIZE` and continue across pages (each page after the first is
/// wall-to-wall records).
pub struct Ring {
pages: Vec<*mut u8>,
page_size: usize,
capacity: u32,
}
// SAFETY: the ring is shared memory by design; all cross-thread access goes
// through atomics with acquire/release ordering per the publish protocol.
unsafe impl Send for Ring {}
unsafe impl Sync for Ring {}
impl Ring {
/// Records that fit in `pages` (first page loses `HEADER_SIZE` bytes).
pub fn capacity_for(num_pages: usize, page_size: usize) -> u32 {
let first = (page_size - HEADER_SIZE) / RECORD_SIZE;
let rest = (num_pages - 1) * (page_size / RECORD_SIZE);
(first + rest) as u32
}
/// Wrap `pages` (each `page_size` bytes, zeroed by the creator).
///
/// # Safety
/// Every pointer must be valid for `page_size` bytes for the ring's
/// lifetime, and the memory must not be moved or unmapped.
pub unsafe fn from_pages(pages: Vec<*mut u8>, page_size: usize) -> Ring {
let capacity = Self::capacity_for(pages.len(), page_size);
Ring {
pages,
page_size,
capacity,
}
}
pub fn capacity(&self) -> u32 {
self.capacity
}
fn header_atomic(&self, off: usize) -> &AtomicU32 {
// SAFETY: header lives in the first page, within HEADER_SIZE.
unsafe { AtomicU32::from_ptr(self.pages[0].add(off) as *mut u32) }
}
/// (page pointer, offset) of record `slot`.
fn record_ptr(&self, slot: u32) -> *mut u8 {
let first_cap = (self.page_size - HEADER_SIZE) / RECORD_SIZE;
let slot = slot as usize;
if slot < first_cap {
// SAFETY: slot bounds-checked against the first page's capacity.
unsafe { self.pages[0].add(HEADER_SIZE + slot * RECORD_SIZE) }
} else {
let per_page = self.page_size / RECORD_SIZE;
let rest = slot - first_cap;
let page = 1 + rest / per_page;
// SAFETY: capacity_for bounds slots to the page list.
unsafe { self.pages[page].add((rest % per_page) * RECORD_SIZE) }
}
}
/// Producer: try to publish `payload` (with `flags` OR-ed into its
/// length). Returns false when the ring is full.
pub fn try_push(&self, payload: &[u8], flags: u32) -> bool {
debug_assert!(payload.len() <= INLINE_MAX);
let head = self.header_atomic(OFF_HEAD);
let tail = self.header_atomic(OFF_TAIL);
let seq = head.load(Ordering::Relaxed);
if seq.wrapping_sub(tail.load(Ordering::Acquire)) >= self.capacity {
return false; // full
}
let slot = seq % self.capacity;
let rec = self.record_ptr(slot);
// SAFETY: rec points at a full RECORD_SIZE record inside our pages;
// the slot is ours until we bump `head` (single producer).
unsafe {
std::ptr::copy_nonoverlapping(payload.as_ptr(), rec.add(RECORD_HDR), payload.len());
(rec.add(4) as *mut u32).write_volatile(payload.len() as u32 | flags);
// Publish: sequence write is the release gate for the payload.
AtomicU32::from_ptr(rec as *mut u32).store(seq.wrapping_add(1), Ordering::Release);
}
head.store(seq.wrapping_add(1), Ordering::Release);
true
}
/// Consumer: read the next record if one is published. Returns
/// `(payload, flags)`.
pub fn try_pop(&self) -> Option<(Vec<u8>, u32)> {
let tail = self.header_atomic(OFF_TAIL);
let seq = tail.load(Ordering::Relaxed);
let slot = seq % self.capacity;
let rec = self.record_ptr(slot);
// SAFETY: reading the slot's sequence atomically; payload only after
// the acquire load observes the publish.
let published = unsafe { AtomicU32::from_ptr(rec as *mut u32).load(Ordering::Acquire) };
if published != seq.wrapping_add(1) {
return None;
}
let (len_flags, payload) = unsafe {
let lf = (rec.add(4) as *const u32).read_volatile();
let len = (lf & !LEN_INDIRECT) as usize;
let mut buf = vec![0u8; len.min(INLINE_MAX)];
std::ptr::copy_nonoverlapping(rec.add(RECORD_HDR), buf.as_mut_ptr(), buf.len());
(lf, buf)
};
tail.store(seq.wrapping_add(1), Ordering::Release);
Some((payload, len_flags & LEN_INDIRECT))
}
/// Consumer: about to block — set the parked flag. Returns true if new
/// records were published in the meantime (caller must NOT sleep).
pub fn park(&self) -> bool {
self.header_atomic(OFF_FLAGS)
.fetch_or(FLAG_PARKED, Ordering::SeqCst);
// Re-check after the flag is visible: a producer that published
// before seeing the flag would not kick.
let head = self.header_atomic(OFF_HEAD).load(Ordering::SeqCst);
let tail = self.header_atomic(OFF_TAIL).load(Ordering::Relaxed);
if head != tail {
self.unpark();
return true;
}
false
}
pub fn unpark(&self) {
self.header_atomic(OFF_FLAGS)
.fetch_and(!FLAG_PARKED, Ordering::SeqCst);
}
/// Producer: does the consumer need a doorbell kick? (Checked after
/// publishing; clears the flag when set so exactly one kick is sent.)
pub fn take_parked(&self) -> bool {
let flags = self.header_atomic(OFF_FLAGS);
if flags.load(Ordering::SeqCst) & FLAG_PARKED != 0 {
flags.fetch_and(!FLAG_PARKED, Ordering::SeqCst) & FLAG_PARKED != 0
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_ring(num_pages: usize) -> (Ring, Vec<Vec<u8>>) {
let page = 4096;
let mut backing: Vec<Vec<u8>> = (0..num_pages).map(|_| vec![0u8; page]).collect();
let pages: Vec<*mut u8> = backing.iter_mut().map(|p| p.as_mut_ptr()).collect();
// SAFETY: backing outlives the ring within each test.
(unsafe { Ring::from_pages(pages, page) }, backing)
}
#[test]
fn push_pop_roundtrip() {
let (ring, _keep) = make_ring(2);
assert!(ring.try_push(b"hello", 0));
assert!(ring.try_push(b"world", LEN_INDIRECT));
let (a, f_a) = ring.try_pop().unwrap();
let (b, f_b) = ring.try_pop().unwrap();
assert_eq!((a.as_slice(), f_a), (b"hello".as_slice(), 0));
assert_eq!((b.as_slice(), f_b), (b"world".as_slice(), LEN_INDIRECT));
assert!(ring.try_pop().is_none());
}
#[test]
fn fills_and_wraps() {
let (ring, _keep) = make_ring(1);
let cap = ring.capacity();
for i in 0..cap {
assert!(ring.try_push(&[i as u8], 0), "push {i}");
}
assert!(!ring.try_push(b"x", 0), "must report full");
for i in 0..cap {
assert_eq!(ring.try_pop().unwrap().0, vec![i as u8]);
}
// Wrapped sequences keep working past capacity.
for round in 0..3u32 {
assert!(ring.try_push(&round.to_le_bytes(), 0));
assert_eq!(ring.try_pop().unwrap().0, round.to_le_bytes());
}
}
#[test]
fn record_slots_never_straddle_pages() {
let (ring, keep) = make_ring(3);
let page = 4096;
for slot in 0..ring.capacity() {
let p = ring.record_ptr(slot) as usize;
let off_in_page = keep
.iter()
.map(|b| b.as_ptr() as usize)
.filter(|&base| p >= base && p < base + page)
.map(|base| p - base)
.next()
.expect("record outside all pages");
assert!(off_in_page + RECORD_SIZE <= page, "slot {slot} straddles");
}
}
#[test]
fn park_sees_concurrent_publish() {
let (ring, _keep) = make_ring(1);
assert!(!ring.park(), "empty ring parks");
ring.unpark();
assert!(ring.try_push(b"x", 0));
assert!(ring.park(), "publish before park must cancel the sleep");
// Producer-side doorbell handoff.
assert!(ring.try_pop().is_some());
assert!(!ring.park());
assert!(ring.try_push(b"y", 0));
assert!(ring.take_parked(), "producer collects exactly one kick");
assert!(!ring.take_parked());
}
#[test]
fn cross_thread_stream() {
use std::sync::Arc;
let (ring, keep) = make_ring(4);
let ring = Arc::new(ring);
let producer = {
let ring = Arc::clone(&ring);
std::thread::spawn(move || {
for i in 0..10_000u32 {
while !ring.try_push(&i.to_le_bytes(), 0) {
std::hint::spin_loop();
}
}
})
};
let mut expected = 0u32;
while expected < 10_000 {
if let Some((payload, _)) = ring.try_pop() {
assert_eq!(payload, expected.to_le_bytes());
expected += 1;
} else {
std::hint::spin_loop();
}
}
producer.join().unwrap();
drop(keep);
}
}
+130
View File
@@ -0,0 +1,130 @@
//! A named POSIX shared-memory region, mapped by both the guest shim and the
//! host CUDA server, used as a **zero-copy bulk-data channel**.
//!
//! Bulk `cudaMemcpy` is the bandwidth bottleneck of socket-based remoting: the
//! bytes are copied guest→socket→host→GPU. With a region both sides map, a
//! host-pinned/`cudaMallocHost` buffer lives *in* the region, and a memcpy
//! ships only an `(offset, size)` descriptor — the host reads the bytes
//! straight from the shared mapping and DMAs them to the GPU. No bulk bytes
//! cross the socket.
//!
//! Same-host today (guest shim + host server share `/dev/shm/<name>`). The
//! microVM case backs the *same protocol* with guest RAM the host already maps
//! (a libkrun API to expose the guest-memory base; see docs).
//!
//! Selected by `SMOLVM_CUDA_SHM=<name>` (+ `SMOLVM_CUDA_SHM_SIZE`, default
//! 512 MiB). Unset → the region is absent and callers fall back to byte-shipping.
use std::ffi::CString;
use std::sync::OnceLock;
/// A mapped shared region: base pointer + length. `Send`/`Sync` because it is a
/// process-lifetime mapping accessed under the server's per-connection ordering.
pub struct ShmRegion {
base: *mut u8,
len: usize,
}
// SAFETY: the mapping lives for the whole process; concurrent access is
// serialized by the CUDA server's single-threaded-per-connection model and, on
// the guest, by the client mutex.
unsafe impl Send for ShmRegion {}
unsafe impl Sync for ShmRegion {}
impl ShmRegion {
pub fn base(&self) -> *mut u8 {
self.base
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
/// A checked slice `[offset, offset+size)` into the region, or `None` if out
/// of bounds (guards a hostile/buggy offset).
pub fn checked(&self, offset: u64, size: u64) -> Option<*mut u8> {
let end = offset.checked_add(size)?;
if end > self.len as u64 {
return None;
}
Some(unsafe { self.base.add(offset as usize) })
}
}
fn shm_size() -> usize {
std::env::var("SMOLVM_CUDA_SHM_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(512 * 1024 * 1024)
}
/// Open (server: `create=true`) or attach (`create=false`) the region named by
/// `SMOLVM_CUDA_SHM`. Returns `None` if the env var is unset (no shm channel).
pub fn open(create: bool) -> Option<ShmRegion> {
let trace = std::env::var_os("SMOLVM_CUDA_SHM_TRACE").is_some();
let name = match std::env::var("SMOLVM_CUDA_SHM") {
Ok(n) => n,
Err(_) => {
if trace {
eprintln!("shm: SMOLVM_CUDA_SHM not set (create={create})");
}
return None;
}
};
if trace {
eprintln!("shm: open name={name:?} create={create}");
}
let len = shm_size();
let cname = CString::new(name).ok()?;
unsafe {
let mut flags = libc::O_RDWR;
if create {
flags |= libc::O_CREAT;
}
let fd = libc::shm_open(cname.as_ptr(), flags, 0o600);
if fd < 0 {
if trace {
eprintln!(
"shm: shm_open failed errno={}",
std::io::Error::last_os_error()
);
}
return None;
}
if create && libc::ftruncate(fd, len as libc::off_t) != 0 {
if trace {
eprintln!("shm: ftruncate failed");
}
libc::close(fd);
return None;
}
let base = libc::mmap(
std::ptr::null_mut(),
len,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
fd,
0,
);
libc::close(fd); // the mapping keeps the region alive
if base == libc::MAP_FAILED {
return None;
}
Some(ShmRegion {
base: base as *mut u8,
len,
})
}
}
/// Process-wide handle, opened once on first use (attach mode).
pub fn get() -> Option<&'static ShmRegion> {
static REGION: OnceLock<Option<ShmRegion>> = OnceLock::new();
REGION.get_or_init(|| open(false)).as_ref()
}
/// Server-side: create-or-attach the region once.
pub fn get_or_create() -> Option<&'static ShmRegion> {
static REGION: OnceLock<Option<ShmRegion>> = OnceLock::new();
REGION.get_or_init(|| open(true)).as_ref()
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "smolvm-cudart-shim"
version = "1.5.2"
edition = "2021"
description = "Guest-side libcudart.so drop-in: CUDA Runtime API lowered to smolvm's Driver-API vsock RPC"
license = "Apache-2.0"
[lib]
name = "cudart"
crate-type = ["cdylib"]
[dependencies]
smolvm-cuda = { path = "../smolvm-cuda", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
vsock = "0.5"
libc = "0.2"
+9
View File
@@ -0,0 +1,9 @@
fn main() {
// Announce the soname a CUDA 11.x program links against, so the dynamic
// linker satisfies `libcudart.so.11.0`. Build artifact is libcudart.so;
// installers stage it as libcudart.so.11.0 (or LD_PRELOAD it).
let target = std::env::var("TARGET").unwrap_or_default();
if target.contains("linux") {
println!("cargo:rustc-cdylib-link-arg=-Wl,-soname,libcudart.so.11.0");
}
}
@@ -0,0 +1,912 @@
// @generated by smolvm nm-based stub generation — do not edit.
//! Auto-generated stub exports for cuBLAS/cuBLASLt symbols PyTorch links but
//! that aren't yet forwarded. Each logs under SMOLVM_CUDA_SHIM_TRACE and
//! returns CUBLAS_STATUS_NOT_SUPPORTED so we can trace what torch actually calls.
#![allow(non_snake_case)]
use std::os::raw::c_int;
fn stub(name: &str) -> c_int {
if std::env::var_os("SMOLVM_CUDA_SHIM_TRACE").is_some() {
eprintln!("[cublas-stub] CALLED (unimplemented): {name}");
}
15
}
// cublasLt* are forwarded for real in lib.rs (LIB_CUBLASLT).
#[no_mangle]
pub extern "C" fn cublasCdotc_v2() -> c_int {
stub("cublasCdotc_v2")
}
#[no_mangle]
pub extern "C" fn cublasCdotu_v2() -> c_int {
stub("cublasCdotu_v2")
}
#[no_mangle]
pub extern "C" fn cublasCgelsBatched() -> c_int {
stub("cublasCgelsBatched")
}
#[no_mangle]
pub extern "C" fn cublasCgemmStridedBatched() -> c_int {
stub("cublasCgemmStridedBatched")
}
#[no_mangle]
pub extern "C" fn cublasCgemm_v2() -> c_int {
stub("cublasCgemm_v2")
}
#[no_mangle]
pub extern "C" fn cublasCgemv_v2() -> c_int {
stub("cublasCgemv_v2")
}
#[no_mangle]
pub extern "C" fn cublasCgeqrfBatched() -> c_int {
stub("cublasCgeqrfBatched")
}
#[no_mangle]
pub extern "C" fn cublasCgetrfBatched() -> c_int {
stub("cublasCgetrfBatched")
}
#[no_mangle]
pub extern "C" fn cublasCgetrsBatched() -> c_int {
stub("cublasCgetrsBatched")
}
#[no_mangle]
pub extern "C" fn cublasCtrsmBatched() -> c_int {
stub("cublasCtrsmBatched")
}
#[no_mangle]
pub extern "C" fn cublasCtrsm_v2() -> c_int {
stub("cublasCtrsm_v2")
}
#[no_mangle]
pub extern "C" fn cublasDdot_v2() -> c_int {
stub("cublasDdot_v2")
}
#[no_mangle]
pub extern "C" fn cublasDgelsBatched() -> c_int {
stub("cublasDgelsBatched")
}
#[no_mangle]
pub extern "C" fn cublasDgeqrfBatched() -> c_int {
stub("cublasDgeqrfBatched")
}
#[no_mangle]
pub extern "C" fn cublasDgetrfBatched() -> c_int {
stub("cublasDgetrfBatched")
}
#[no_mangle]
pub extern "C" fn cublasDgetrsBatched() -> c_int {
stub("cublasDgetrsBatched")
}
#[no_mangle]
pub extern "C" fn cublasDotEx() -> c_int {
stub("cublasDotEx")
}
#[no_mangle]
pub extern "C" fn cublasDtrsmBatched() -> c_int {
stub("cublasDtrsmBatched")
}
#[no_mangle]
pub extern "C" fn cublasGetPointerMode_v2() -> c_int {
stub("cublasGetPointerMode_v2")
}
#[no_mangle]
pub extern "C" fn cublasSdot_v2() -> c_int {
stub("cublasSdot_v2")
}
#[no_mangle]
pub extern "C" fn cublasSetPointerMode_v2() -> c_int {
stub("cublasSetPointerMode_v2")
}
#[no_mangle]
pub extern "C" fn cublasSgelsBatched() -> c_int {
stub("cublasSgelsBatched")
}
#[no_mangle]
pub extern "C" fn cublasSgemmEx() -> c_int {
stub("cublasSgemmEx")
}
#[no_mangle]
pub extern "C" fn cublasSgeqrfBatched() -> c_int {
stub("cublasSgeqrfBatched")
}
#[no_mangle]
pub extern "C" fn cublasSgetrfBatched() -> c_int {
stub("cublasSgetrfBatched")
}
#[no_mangle]
pub extern "C" fn cublasSgetrsBatched() -> c_int {
stub("cublasSgetrsBatched")
}
#[no_mangle]
pub extern "C" fn cublasStrsmBatched() -> c_int {
stub("cublasStrsmBatched")
}
#[no_mangle]
pub extern "C" fn cublasZdotc_v2() -> c_int {
stub("cublasZdotc_v2")
}
#[no_mangle]
pub extern "C" fn cublasZdotu_v2() -> c_int {
stub("cublasZdotu_v2")
}
#[no_mangle]
pub extern "C" fn cublasZgelsBatched() -> c_int {
stub("cublasZgelsBatched")
}
#[no_mangle]
pub extern "C" fn cublasZgemmStridedBatched() -> c_int {
stub("cublasZgemmStridedBatched")
}
#[no_mangle]
pub extern "C" fn cublasZgemm_v2() -> c_int {
stub("cublasZgemm_v2")
}
#[no_mangle]
pub extern "C" fn cublasZgemv_v2() -> c_int {
stub("cublasZgemv_v2")
}
#[no_mangle]
pub extern "C" fn cublasZgeqrfBatched() -> c_int {
stub("cublasZgeqrfBatched")
}
#[no_mangle]
pub extern "C" fn cublasZgetrfBatched() -> c_int {
stub("cublasZgetrfBatched")
}
#[no_mangle]
pub extern "C" fn cublasZgetrsBatched() -> c_int {
stub("cublasZgetrsBatched")
}
#[no_mangle]
pub extern "C" fn cublasZtrsmBatched() -> c_int {
stub("cublasZtrsmBatched")
}
#[no_mangle]
pub extern "C" fn cublasZtrsm_v2() -> c_int {
stub("cublasZtrsm_v2")
}
#[no_mangle]
pub extern "C" fn cudnn_batch_norm4callERKNS_6TensorES4_RKSt8optionalIS2_ES8_S8_bdd() -> c_int {
stub("cudnn_batch_norm4callERKNS_6TensorES4_RKSt8optionalIS2_ES8_S8_bdd")
}
#[no_mangle]
pub extern "C" fn cudnn_batch_norm_backward4callERKNS_6TensorES4_S4_RKSt8optionalIS2_ES8_S8_S8_dS4_(
) -> c_int {
stub("cudnn_batch_norm_backward4callERKNS_6TensorES4_S4_RKSt8optionalIS2_ES8_S8_S8_dS4_")
}
#[no_mangle]
pub extern "C" fn cudnn_batch_norm_out4callERKNS_6TensorES4_RKSt8optionalIS2_ES8_S8_bddRS2_S9_S9_S9_(
) -> c_int {
stub("cudnn_batch_norm_out4callERKNS_6TensorES4_RKSt8optionalIS2_ES8_S8_bddRS2_S9_S9_S9_")
}
#[no_mangle]
pub extern "C" fn cudnnConvolutionBackwardData() -> c_int {
stub("cudnnConvolutionBackwardData")
}
#[no_mangle]
pub extern "C" fn cudnnConvolutionBackwardFilter() -> c_int {
stub("cudnnConvolutionBackwardFilter")
}
#[no_mangle]
pub extern "C" fn cudnn_convolution_backward_stubE() -> c_int {
stub("cudnn_convolution_backward_stubE")
}
#[no_mangle]
pub extern "C" fn cudnnConvolutionBiasActivationForward() -> c_int {
stub("cudnnConvolutionBiasActivationForward")
}
#[no_mangle]
pub extern "C" fn cudnn_convolution_transpose_backward_stubE() -> c_int {
stub("cudnn_convolution_transpose_backward_stubE")
}
#[no_mangle]
pub extern "C" fn cudnnCreateActivationDescriptor() -> c_int {
stub("cudnnCreateActivationDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnCreateCTCLossDescriptor() -> c_int {
stub("cudnnCreateCTCLossDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnCreateDropoutDescriptor() -> c_int {
stub("cudnnCreateDropoutDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnCreatePoolingDescriptor() -> c_int {
stub("cudnnCreatePoolingDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnCreateRNNDataDescriptor() -> c_int {
stub("cudnnCreateRNNDataDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnCreateRNNDescriptor() -> c_int {
stub("cudnnCreateRNNDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnCreateSpatialTransformerDescriptor() -> c_int {
stub("cudnnCreateSpatialTransformerDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnCTCLoss() -> c_int {
stub("cudnnCTCLoss")
}
#[no_mangle]
pub extern "C" fn cudnnCTCLoss_v8() -> c_int {
stub("cudnnCTCLoss_v8")
}
#[no_mangle]
pub extern "C" fn cudnnDestroyActivationDescriptor() -> c_int {
stub("cudnnDestroyActivationDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnDestroyCTCLossDescriptor() -> c_int {
stub("cudnnDestroyCTCLossDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnDestroyDropoutDescriptor() -> c_int {
stub("cudnnDestroyDropoutDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnDestroyRNNDataDescriptor() -> c_int {
stub("cudnnDestroyRNNDataDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnDestroyRNNDescriptor() -> c_int {
stub("cudnnDestroyRNNDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnDestroySpatialTransformerDescriptor() -> c_int {
stub("cudnnDestroySpatialTransformerDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnDropoutGetStatesSize() -> c_int {
stub("cudnnDropoutGetStatesSize")
}
#[no_mangle]
pub extern "C" fn cudnnFindConvolutionBackwardDataAlgorithmEx() -> c_int {
stub("cudnnFindConvolutionBackwardDataAlgorithmEx")
}
#[no_mangle]
pub extern "C" fn cudnnFindConvolutionBackwardFilterAlgorithmEx() -> c_int {
stub("cudnnFindConvolutionBackwardFilterAlgorithmEx")
}
#[no_mangle]
pub extern "C" fn cudnnFindConvolutionForwardAlgorithmEx() -> c_int {
stub("cudnnFindConvolutionForwardAlgorithmEx")
}
#[no_mangle]
pub extern "C" fn cudnn_get_conv_benchmark_empty_cacheEv() -> c_int {
stub("cudnn_get_conv_benchmark_empty_cacheEv")
}
#[no_mangle]
pub extern "C" fn cudnnGetConvolutionBackwardDataAlgorithm_v7() -> c_int {
stub("cudnnGetConvolutionBackwardDataAlgorithm_v7")
}
#[no_mangle]
pub extern "C" fn cudnnGetConvolutionBackwardDataWorkspaceSize() -> c_int {
stub("cudnnGetConvolutionBackwardDataWorkspaceSize")
}
#[no_mangle]
pub extern "C" fn cudnnGetConvolutionBackwardFilterAlgorithm_v7() -> c_int {
stub("cudnnGetConvolutionBackwardFilterAlgorithm_v7")
}
#[no_mangle]
pub extern "C" fn cudnnGetConvolutionBackwardFilterWorkspaceSize() -> c_int {
stub("cudnnGetConvolutionBackwardFilterWorkspaceSize")
}
#[no_mangle]
pub extern "C" fn cudnnGetConvolutionForwardAlgorithm_v7() -> c_int {
stub("cudnnGetConvolutionForwardAlgorithm_v7")
}
#[no_mangle]
pub extern "C" fn cudnnGetCTCLossWorkspaceSize() -> c_int {
stub("cudnnGetCTCLossWorkspaceSize")
}
#[no_mangle]
pub extern "C" fn cudnnGetCTCLossWorkspaceSize_v8() -> c_int {
stub("cudnnGetCTCLossWorkspaceSize_v8")
}
#[no_mangle]
pub extern "C" fn cudnnGetCudartVersion() -> c_int {
stub("cudnnGetCudartVersion")
}
#[no_mangle]
pub extern "C" fn cudnnGetFilterNdDescriptor() -> c_int {
stub("cudnnGetFilterNdDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnGetLastErrorString() -> c_int {
stub("cudnnGetLastErrorString")
}
#[no_mangle]
pub extern "C" fn cudnnGetRNNTempSpaceSizes() -> c_int {
stub("cudnnGetRNNTempSpaceSizes")
}
#[no_mangle]
pub extern "C" fn cudnnGetRNNWeightParams() -> c_int {
stub("cudnnGetRNNWeightParams")
}
#[no_mangle]
pub extern "C" fn cudnnGetRNNWeightSpaceSize() -> c_int {
stub("cudnnGetRNNWeightSpaceSize")
}
#[no_mangle]
pub extern "C" fn cudnnGetStream() -> c_int {
stub("cudnnGetStream")
}
#[no_mangle]
pub extern "C" fn cudnnGetTensorNdDescriptor() -> c_int {
stub("cudnnGetTensorNdDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnGetVersion() -> c_int {
stub("cudnnGetVersion")
}
#[no_mangle]
pub extern "C" fn cudnn_init_dropout_state4callEdblSt8optionalIN3c1010ScalarTypeEES2_INS3_6LayoutEES2_INS3_6DeviceEES2_IbE(
) -> c_int {
stub("cudnn_init_dropout_state4callEdblSt8optionalIN3c1010ScalarTypeEES2_INS3_6LayoutEES2_INS3_6DeviceEES2_IbE")
}
#[no_mangle]
pub extern "C" fn cudnn_is_acceptableERKNS_10TensorBaseE() -> c_int {
stub("cudnn_is_acceptableERKNS_10TensorBaseE")
}
#[no_mangle]
pub extern "C" fn cudnnPoolingForward() -> c_int {
stub("cudnnPoolingForward")
}
#[no_mangle]
pub extern "C" fn cudnnRestoreDropoutDescriptor() -> c_int {
stub("cudnnRestoreDropoutDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnn_rnn4callERKNS_6TensorEN3c108ArrayRefIS2_EElRKSt8optionalIS2_ES4_SB_lNS5_6SymIntESC_lbdbbNS6_ISC_EESB_(
) -> c_int {
stub("cudnn_rnn4callERKNS_6TensorEN3c108ArrayRefIS2_EElRKSt8optionalIS2_ES4_SB_lNS5_6SymIntESC_lbdbbNS6_ISC_EESB_")
}
#[no_mangle]
pub extern "C" fn cudnnRNNBackwardData_v8() -> c_int {
stub("cudnnRNNBackwardData_v8")
}
#[no_mangle]
pub extern "C" fn cudnnRNNBackwardWeights_v8() -> c_int {
stub("cudnnRNNBackwardWeights_v8")
}
#[no_mangle]
pub extern "C" fn cudnnRNNForward() -> c_int {
stub("cudnnRNNForward")
}
#[no_mangle]
pub extern "C" fn cudnnSetActivationDescriptor() -> c_int {
stub("cudnnSetActivationDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnSetConvolutionGroupCount() -> c_int {
stub("cudnnSetConvolutionGroupCount")
}
#[no_mangle]
pub extern "C" fn cudnnSetConvolutionMathType() -> c_int {
stub("cudnnSetConvolutionMathType")
}
#[no_mangle]
pub extern "C" fn cudnnSetConvolutionNdDescriptor() -> c_int {
stub("cudnnSetConvolutionNdDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnSetCTCLossDescriptorEx() -> c_int {
stub("cudnnSetCTCLossDescriptorEx")
}
#[no_mangle]
pub extern "C" fn cudnnSetCTCLossDescriptor_v9() -> c_int {
stub("cudnnSetCTCLossDescriptor_v9")
}
#[no_mangle]
pub extern "C" fn cudnnSetDropoutDescriptor() -> c_int {
stub("cudnnSetDropoutDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnSetFilterNdDescriptor() -> c_int {
stub("cudnnSetFilterNdDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnSetPooling2dDescriptor() -> c_int {
stub("cudnnSetPooling2dDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnSetRNNDataDescriptor() -> c_int {
stub("cudnnSetRNNDataDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnSetRNNDescriptor_v8() -> c_int {
stub("cudnnSetRNNDescriptor_v8")
}
#[no_mangle]
pub extern "C" fn cudnnSetSpatialTransformerNdDescriptor() -> c_int {
stub("cudnnSetSpatialTransformerNdDescriptor")
}
#[no_mangle]
pub extern "C" fn cudnnSpatialTfGridGeneratorBackward() -> c_int {
stub("cudnnSpatialTfGridGeneratorBackward")
}
#[no_mangle]
pub extern "C" fn cudnnSpatialTfGridGeneratorForward() -> c_int {
stub("cudnnSpatialTfGridGeneratorForward")
}
#[no_mangle]
pub extern "C" fn cudnnSpatialTfSamplerBackward() -> c_int {
stub("cudnnSpatialTfSamplerBackward")
}
#[no_mangle]
pub extern "C" fn cudnnSpatialTfSamplerForward() -> c_int {
stub("cudnnSpatialTfSamplerForward")
}
#[no_mangle]
pub extern "C" fn cudnn_stubE() -> c_int {
stub("cudnn_stubE")
}
fn rt_stub(name: &str) -> c_int {
if std::env::var_os("SMOLVM_CUDA_SHIM_TRACE").is_some() {
eprintln!("[cudart-stub] CALLED: {name}");
}
801 // cudaErrorNotSupported
}
#[no_mangle]
pub extern "C" fn cudaGraphDebugDotPrint() -> c_int {
rt_stub("cudaGraphDebugDotPrint")
}
#[no_mangle]
pub extern "C" fn cudaIpcCloseMemHandle() -> c_int {
rt_stub("cudaIpcCloseMemHandle")
}
#[no_mangle]
pub extern "C" fn cudaIpcGetEventHandle() -> c_int {
rt_stub("cudaIpcGetEventHandle")
}
#[no_mangle]
pub extern "C" fn cudaIpcGetMemHandle() -> c_int {
rt_stub("cudaIpcGetMemHandle")
}
#[no_mangle]
pub extern "C" fn cudaIpcOpenEventHandle() -> c_int {
rt_stub("cudaIpcOpenEventHandle")
}
#[no_mangle]
pub extern "C" fn cudaIpcOpenMemHandle() -> c_int {
rt_stub("cudaIpcOpenMemHandle")
}
#[no_mangle]
pub extern "C" fn cudaMemcpyPeerAsync() -> c_int {
rt_stub("cudaMemcpyPeerAsync")
}
#[no_mangle]
pub extern "C" fn cudaRegisterVar() -> c_int {
rt_stub("cudaRegisterVar")
}
/// `__cudaRegisterVar` — registers a __device__/__constant__ global. No-op:
/// forwarded workloads that don't use cudaMemcpyToSymbol never need the mapping.
#[no_mangle]
pub extern "C" fn __cudaRegisterVar(
_fat: *mut *mut std::os::raw::c_void,
_host_var: *mut std::os::raw::c_char,
_dev_addr: *mut std::os::raw::c_char,
_dev_name: *const std::os::raw::c_char,
_ext: c_int,
_size: usize,
_constant: c_int,
_global: c_int,
) {
}
// ---- remaining cudart runtime functions PyTorch links -----------------------
use std::os::raw::c_void;
#[no_mangle]
pub extern "C" fn cudaProfilerStart() -> c_int {
0
}
#[no_mangle]
pub extern "C" fn cudaProfilerStop() -> c_int {
0
}
#[no_mangle]
pub extern "C" fn cudaInitModule(_x: *mut c_void) -> c_int {
0
}
#[no_mangle]
pub extern "C" fn cudaStreamGetPriority(_s: *mut c_void, priority: *mut c_int) -> c_int {
if !priority.is_null() {
unsafe { *priority = 0 }
}
0
}
#[no_mangle]
pub extern "C" fn cudaMemcpy2DAsync() -> c_int {
rt_stub("cudaMemcpy2DAsync")
}
#[no_mangle]
pub extern "C" fn cublasCaxpy_v2() -> c_int {
stub("cublasCaxpy_v2")
}
#[no_mangle]
pub extern "C" fn cublasCcopy_v2() -> c_int {
stub("cublasCcopy_v2")
}
#[no_mangle]
pub extern "C" fn cublasCgemmBatched() -> c_int {
stub("cublasCgemmBatched")
}
#[no_mangle]
pub extern "C" fn cublasCgerc_v2() -> c_int {
stub("cublasCgerc_v2")
}
#[no_mangle]
pub extern "C" fn cublasCgeru_v2() -> c_int {
stub("cublasCgeru_v2")
}
#[no_mangle]
pub extern "C" fn cublasChemm_v2() -> c_int {
stub("cublasChemm_v2")
}
#[no_mangle]
pub extern "C" fn cublasChemv_v2() -> c_int {
stub("cublasChemv_v2")
}
#[no_mangle]
pub extern "C" fn cublasCher2k_v2() -> c_int {
stub("cublasCher2k_v2")
}
#[no_mangle]
pub extern "C" fn cublasCher2_v2() -> c_int {
stub("cublasCher2_v2")
}
#[no_mangle]
pub extern "C" fn cublasCherk_v2() -> c_int {
stub("cublasCherk_v2")
}
#[no_mangle]
pub extern "C" fn cublasCher_v2() -> c_int {
stub("cublasCher_v2")
}
#[no_mangle]
pub extern "C" fn cublasCrotg_v2() -> c_int {
stub("cublasCrotg_v2")
}
#[no_mangle]
pub extern "C" fn cublasCrot_v2() -> c_int {
stub("cublasCrot_v2")
}
#[no_mangle]
pub extern "C" fn cublasCscal_v2() -> c_int {
stub("cublasCscal_v2")
}
#[no_mangle]
pub extern "C" fn cublasCsrot_v2() -> c_int {
stub("cublasCsrot_v2")
}
#[no_mangle]
pub extern "C" fn cublasCsscal_v2() -> c_int {
stub("cublasCsscal_v2")
}
#[no_mangle]
pub extern "C" fn cublasCswap_v2() -> c_int {
stub("cublasCswap_v2")
}
#[no_mangle]
pub extern "C" fn cublasCsymm_v2() -> c_int {
stub("cublasCsymm_v2")
}
#[no_mangle]
pub extern "C" fn cublasCsymv_v2() -> c_int {
stub("cublasCsymv_v2")
}
#[no_mangle]
pub extern "C" fn cublasCsyr2k_v2() -> c_int {
stub("cublasCsyr2k_v2")
}
#[no_mangle]
pub extern "C" fn cublasCsyr2_v2() -> c_int {
stub("cublasCsyr2_v2")
}
#[no_mangle]
pub extern "C" fn cublasCsyrk_v2() -> c_int {
stub("cublasCsyrk_v2")
}
#[no_mangle]
pub extern "C" fn cublasCsyr_v2() -> c_int {
stub("cublasCsyr_v2")
}
#[no_mangle]
pub extern "C" fn cublasCtrmm_v2() -> c_int {
stub("cublasCtrmm_v2")
}
#[no_mangle]
pub extern "C" fn cublasCtrmv_v2() -> c_int {
stub("cublasCtrmv_v2")
}
#[no_mangle]
pub extern "C" fn cublasCtrsv_v2() -> c_int {
stub("cublasCtrsv_v2")
}
#[no_mangle]
pub extern "C" fn cublasDasum_v2() -> c_int {
stub("cublasDasum_v2")
}
#[no_mangle]
pub extern "C" fn cublasDgemmBatched() -> c_int {
stub("cublasDgemmBatched")
}
#[no_mangle]
pub extern "C" fn cublasDnrm2_v2() -> c_int {
stub("cublasDnrm2_v2")
}
#[no_mangle]
pub extern "C" fn cublasDrotg_v2() -> c_int {
stub("cublasDrotg_v2")
}
#[no_mangle]
pub extern "C" fn cublasDrotmg_v2() -> c_int {
stub("cublasDrotmg_v2")
}
#[no_mangle]
pub extern "C" fn cublasDrotm_v2() -> c_int {
stub("cublasDrotm_v2")
}
#[no_mangle]
pub extern "C" fn cublasDrot_v2() -> c_int {
stub("cublasDrot_v2")
}
#[no_mangle]
pub extern "C" fn cublasDsymv_v2() -> c_int {
stub("cublasDsymv_v2")
}
#[no_mangle]
pub extern "C" fn cublasDsyr2k_v2() -> c_int {
stub("cublasDsyr2k_v2")
}
#[no_mangle]
pub extern "C" fn cublasDsyr2_v2() -> c_int {
stub("cublasDsyr2_v2")
}
#[no_mangle]
pub extern "C" fn cublasDsyr_v2() -> c_int {
stub("cublasDsyr_v2")
}
#[no_mangle]
pub extern "C" fn cublasDtrmm_v2() -> c_int {
stub("cublasDtrmm_v2")
}
#[no_mangle]
pub extern "C" fn cublasDtrmv_v2() -> c_int {
stub("cublasDtrmv_v2")
}
#[no_mangle]
pub extern "C" fn cublasDtrsv_v2() -> c_int {
stub("cublasDtrsv_v2")
}
#[no_mangle]
pub extern "C" fn cublasDzasum_v2() -> c_int {
stub("cublasDzasum_v2")
}
#[no_mangle]
pub extern "C" fn cublasDznrm2_v2() -> c_int {
stub("cublasDznrm2_v2")
}
#[no_mangle]
pub extern "C" fn cublasGetMatrixAsync() -> c_int {
stub("cublasGetMatrixAsync")
}
#[no_mangle]
pub extern "C" fn cublasGetVectorAsync() -> c_int {
stub("cublasGetVectorAsync")
}
#[no_mangle]
pub extern "C" fn cublasIcamax_v2() -> c_int {
stub("cublasIcamax_v2")
}
#[no_mangle]
pub extern "C" fn cublasIcamin_v2() -> c_int {
stub("cublasIcamin_v2")
}
#[no_mangle]
pub extern "C" fn cublasIdamax_v2() -> c_int {
stub("cublasIdamax_v2")
}
#[no_mangle]
pub extern "C" fn cublasIdamin_v2() -> c_int {
stub("cublasIdamin_v2")
}
#[no_mangle]
pub extern "C" fn cublasIsamax_v2() -> c_int {
stub("cublasIsamax_v2")
}
#[no_mangle]
pub extern "C" fn cublasIsamin_v2() -> c_int {
stub("cublasIsamin_v2")
}
#[no_mangle]
pub extern "C" fn cublasIzamax_v2() -> c_int {
stub("cublasIzamax_v2")
}
#[no_mangle]
pub extern "C" fn cublasIzamin_v2() -> c_int {
stub("cublasIzamin_v2")
}
#[no_mangle]
pub extern "C" fn cublasSasum_v2() -> c_int {
stub("cublasSasum_v2")
}
#[no_mangle]
pub extern "C" fn cublasScasum_v2() -> c_int {
stub("cublasScasum_v2")
}
#[no_mangle]
pub extern "C" fn cublasScnrm2_v2() -> c_int {
stub("cublasScnrm2_v2")
}
#[no_mangle]
pub extern "C" fn cublasSetMatrixAsync() -> c_int {
stub("cublasSetMatrixAsync")
}
#[no_mangle]
pub extern "C" fn cublasSetVectorAsync() -> c_int {
stub("cublasSetVectorAsync")
}
#[no_mangle]
pub extern "C" fn cublasSgemmBatched() -> c_int {
stub("cublasSgemmBatched")
}
#[no_mangle]
pub extern "C" fn cublasSnrm2_v2() -> c_int {
stub("cublasSnrm2_v2")
}
#[no_mangle]
pub extern "C" fn cublasSrotg_v2() -> c_int {
stub("cublasSrotg_v2")
}
#[no_mangle]
pub extern "C" fn cublasSrotmg_v2() -> c_int {
stub("cublasSrotmg_v2")
}
#[no_mangle]
pub extern "C" fn cublasSrotm_v2() -> c_int {
stub("cublasSrotm_v2")
}
#[no_mangle]
pub extern "C" fn cublasSrot_v2() -> c_int {
stub("cublasSrot_v2")
}
#[no_mangle]
pub extern "C" fn cublasSsymv_v2() -> c_int {
stub("cublasSsymv_v2")
}
#[no_mangle]
pub extern "C" fn cublasSsyr2k_v2() -> c_int {
stub("cublasSsyr2k_v2")
}
#[no_mangle]
pub extern "C" fn cublasSsyr2_v2() -> c_int {
stub("cublasSsyr2_v2")
}
#[no_mangle]
pub extern "C" fn cublasSsyr_v2() -> c_int {
stub("cublasSsyr_v2")
}
#[no_mangle]
pub extern "C" fn cublasStrmm_v2() -> c_int {
stub("cublasStrmm_v2")
}
#[no_mangle]
pub extern "C" fn cublasStrmv_v2() -> c_int {
stub("cublasStrmv_v2")
}
#[no_mangle]
pub extern "C" fn cublasStrsv_v2() -> c_int {
stub("cublasStrsv_v2")
}
#[no_mangle]
pub extern "C" fn cublasZaxpy_v2() -> c_int {
stub("cublasZaxpy_v2")
}
#[no_mangle]
pub extern "C" fn cublasZcopy_v2() -> c_int {
stub("cublasZcopy_v2")
}
#[no_mangle]
pub extern "C" fn cublasZdrot_v2() -> c_int {
stub("cublasZdrot_v2")
}
#[no_mangle]
pub extern "C" fn cublasZdscal_v2() -> c_int {
stub("cublasZdscal_v2")
}
#[no_mangle]
pub extern "C" fn cublasZgemmBatched() -> c_int {
stub("cublasZgemmBatched")
}
#[no_mangle]
pub extern "C" fn cublasZgerc_v2() -> c_int {
stub("cublasZgerc_v2")
}
#[no_mangle]
pub extern "C" fn cublasZgeru_v2() -> c_int {
stub("cublasZgeru_v2")
}
#[no_mangle]
pub extern "C" fn cublasZhemm_v2() -> c_int {
stub("cublasZhemm_v2")
}
#[no_mangle]
pub extern "C" fn cublasZhemv_v2() -> c_int {
stub("cublasZhemv_v2")
}
#[no_mangle]
pub extern "C" fn cublasZher2k_v2() -> c_int {
stub("cublasZher2k_v2")
}
#[no_mangle]
pub extern "C" fn cublasZher2_v2() -> c_int {
stub("cublasZher2_v2")
}
#[no_mangle]
pub extern "C" fn cublasZherk_v2() -> c_int {
stub("cublasZherk_v2")
}
#[no_mangle]
pub extern "C" fn cublasZher_v2() -> c_int {
stub("cublasZher_v2")
}
#[no_mangle]
pub extern "C" fn cublasZrotg_v2() -> c_int {
stub("cublasZrotg_v2")
}
#[no_mangle]
pub extern "C" fn cublasZrot_v2() -> c_int {
stub("cublasZrot_v2")
}
#[no_mangle]
pub extern "C" fn cublasZscal_v2() -> c_int {
stub("cublasZscal_v2")
}
#[no_mangle]
pub extern "C" fn cublasZswap_v2() -> c_int {
stub("cublasZswap_v2")
}
#[no_mangle]
pub extern "C" fn cublasZsymm_v2() -> c_int {
stub("cublasZsymm_v2")
}
#[no_mangle]
pub extern "C" fn cublasZsymv_v2() -> c_int {
stub("cublasZsymv_v2")
}
#[no_mangle]
pub extern "C" fn cublasZsyr2k_v2() -> c_int {
stub("cublasZsyr2k_v2")
}
#[no_mangle]
pub extern "C" fn cublasZsyr2_v2() -> c_int {
stub("cublasZsyr2_v2")
}
#[no_mangle]
pub extern "C" fn cublasZsyrk_v2() -> c_int {
stub("cublasZsyrk_v2")
}
#[no_mangle]
pub extern "C" fn cublasZsyr_v2() -> c_int {
stub("cublasZsyr_v2")
}
#[no_mangle]
pub extern "C" fn cublasZtrmm_v2() -> c_int {
stub("cublasZtrmm_v2")
}
#[no_mangle]
pub extern "C" fn cublasZtrmv_v2() -> c_int {
stub("cublasZtrmv_v2")
}
#[no_mangle]
pub extern "C" fn cublasZtrsv_v2() -> c_int {
stub("cublasZtrsv_v2")
}
#[no_mangle]
pub extern "C" fn cudnnGetProperty() -> c_int {
stub("cudnnGetProperty")
}
@@ -0,0 +1,606 @@
// @generated by smolvm-cuda-codegen — do not edit. lib='cublas' id=1
const LIB_ID: u8 = 1;
#[no_mangle]
pub extern "C" fn cublasCreate_v2(handle_out: *mut *mut c_void) -> c_int {
if handle_out.is_null() { return 1; }
// Fire-and-forget create: return a guest-assigned virtual id
// immediately; the host materializes the real descriptor and
// maps the id (see vh_resolve on the host side).
let vh = super::alloc_vhandle();
match with_client(|c| c.lib_call_deferred(LIB_ID, 0, vh.to_le_bytes().to_vec())) {
Ok(()) => { unsafe { *handle_out = vh as *mut c_void }; 0 }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cublasDestroy_v2(handle: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 1, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSgemm_v2(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f32, A: *const f32, lda: c_int, B: *const f32, ldb: c_int, beta: *const f32, C: *mut f32, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 2, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasHgemm(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const u16, A: *const u16, lda: c_int, B: *const u16, ldb: c_int, beta: *const u16, C: *mut u16, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as u16).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as u16).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 3, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDgemm_v2(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f64, A: *const f64, lda: c_int, B: *const f64, ldb: c_int, beta: *const f64, C: *mut f64, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 4, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSgemmStridedBatched(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f32, A: *const f32, lda: c_int, strideA: i64, B: *const f32, ldb: c_int, strideB: i64, beta: *const f32, C: *mut f32, ldc: c_int, strideC: i64, batchCount: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(strideA as i64).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
a.extend_from_slice(&(strideB as i64).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
a.extend_from_slice(&(strideC as i64).to_le_bytes());
a.extend_from_slice(&(batchCount as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 5, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDgemmStridedBatched(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f64, A: *const f64, lda: c_int, strideA: i64, B: *const f64, ldb: c_int, strideB: i64, beta: *const f64, C: *mut f64, ldc: c_int, strideC: i64, batchCount: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(strideA as i64).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
a.extend_from_slice(&(strideB as i64).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
a.extend_from_slice(&(strideC as i64).to_le_bytes());
a.extend_from_slice(&(batchCount as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 6, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSetStream_v2(handle: *mut c_void, stream: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(stream as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 7, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSetWorkspace_v2(handle: *mut c_void, workspace: *mut c_void, size: usize) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(workspace as u64).to_le_bytes());
a.extend_from_slice(&(size as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 8, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSetMathMode(handle: *mut c_void, mode: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(mode as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 9, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasGetMathMode(handle: *mut c_void, mode: *mut c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
match with_client(|c| c.lib_call(LIB_ID, 10, a)) {
Ok((st, out)) => { let mut o = 0usize;
if !mode.is_null() && out.len() >= o + 4 { unsafe { *mode = i32::from_le_bytes(out[o..o + 4].try_into().unwrap()) as _ }; } o += 4;
let _ = o; st }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cublasGetProperty(prop_type: c_int, value: *mut c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(prop_type as i32).to_le_bytes());
match with_client(|c| c.lib_call(LIB_ID, 11, a)) {
Ok((st, out)) => { let mut o = 0usize;
if !value.is_null() && out.len() >= o + 4 { unsafe { *value = i32::from_le_bytes(out[o..o + 4].try_into().unwrap()) as _ }; } o += 4;
let _ = o; st }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cublasGemmEx(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f32, A: *const c_void, Atype: c_int, lda: c_int, B: *const c_void, Btype: c_int, ldb: c_int, beta: *const f32, C: *mut c_void, Ctype: c_int, ldc: c_int, computeType: c_int, algo: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(Atype as i32).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(Btype as i32).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(Ctype as i32).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
a.extend_from_slice(&(computeType as i32).to_le_bytes());
a.extend_from_slice(&(algo as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 12, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasGemmStridedBatchedEx(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f32, A: *const c_void, Atype: c_int, lda: c_int, strideA: i64, B: *const c_void, Btype: c_int, ldb: c_int, strideB: i64, beta: *const f32, C: *mut c_void, Ctype: c_int, ldc: c_int, strideC: i64, batchCount: c_int, computeType: c_int, algo: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(Atype as i32).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(strideA as i64).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(Btype as i32).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
a.extend_from_slice(&(strideB as i64).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(Ctype as i32).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
a.extend_from_slice(&(strideC as i64).to_le_bytes());
a.extend_from_slice(&(batchCount as i32).to_le_bytes());
a.extend_from_slice(&(computeType as i32).to_le_bytes());
a.extend_from_slice(&(algo as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 13, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasGemmBatchedEx(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, k: c_int, alpha: *const f32, Aarray: *const c_void, Atype: c_int, lda: c_int, Barray: *const c_void, Btype: c_int, ldb: c_int, beta: *const f32, Carray: *mut c_void, Ctype: c_int, ldc: c_int, batchCount: c_int, computeType: c_int, algo: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(Aarray as u64).to_le_bytes());
a.extend_from_slice(&(Atype as i32).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(Barray as u64).to_le_bytes());
a.extend_from_slice(&(Btype as i32).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(Carray as u64).to_le_bytes());
a.extend_from_slice(&(Ctype as i32).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
a.extend_from_slice(&(batchCount as i32).to_le_bytes());
a.extend_from_slice(&(computeType as i32).to_le_bytes());
a.extend_from_slice(&(algo as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 14, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSaxpy_v2(handle: *mut c_void, n: c_int, alpha: *const f32, x: *const f32, incx: c_int, y: *mut f32, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 15, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDaxpy_v2(handle: *mut c_void, n: c_int, alpha: *const f64, x: *const f64, incx: c_int, y: *mut f64, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 16, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSscal_v2(handle: *mut c_void, n: c_int, alpha: *const f32, x: *mut f32, incx: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 17, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDscal_v2(handle: *mut c_void, n: c_int, alpha: *const f64, x: *mut f64, incx: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 18, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasScopy_v2(handle: *mut c_void, n: c_int, x: *mut f32, incx: c_int, y: *mut f32, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 19, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDcopy_v2(handle: *mut c_void, n: c_int, x: *mut f64, incx: c_int, y: *mut f64, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 20, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSswap_v2(handle: *mut c_void, n: c_int, x: *mut f32, incx: c_int, y: *mut f32, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 21, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDswap_v2(handle: *mut c_void, n: c_int, x: *mut f64, incx: c_int, y: *mut f64, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 22, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSgemv_v2(handle: *mut c_void, trans: c_int, m: c_int, n: c_int, alpha: *const f32, A: *const f32, lda: c_int, x: *const f32, incx: c_int, beta: *const f32, y: *mut f32, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 23, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDgemv_v2(handle: *mut c_void, trans: c_int, m: c_int, n: c_int, alpha: *const f64, A: *const f64, lda: c_int, x: *const f64, incx: c_int, beta: *const f64, y: *mut f64, incy: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 24, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSger_v2(handle: *mut c_void, m: c_int, n: c_int, alpha: *const f32, x: *const f32, incx: c_int, y: *const f32, incy: c_int, A: *mut f32, lda: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 25, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDger_v2(handle: *mut c_void, m: c_int, n: c_int, alpha: *const f64, x: *const f64, incx: c_int, y: *const f64, incy: c_int, A: *mut f64, lda: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(incx as i32).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(incy as i32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 26, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSgeam(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, alpha: *const f32, A: *const f32, lda: c_int, beta: *const f32, B: *const f32, ldb: c_int, C: *mut f32, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 27, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDgeam(handle: *mut c_void, transa: c_int, transb: c_int, m: c_int, n: c_int, alpha: *const f64, A: *const f64, lda: c_int, beta: *const f64, B: *const f64, ldb: c_int, C: *mut f64, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(transa as i32).to_le_bytes());
a.extend_from_slice(&(transb as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 28, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSsyrk_v2(handle: *mut c_void, uplo: c_int, trans: c_int, n: c_int, k: c_int, alpha: *const f32, A: *const f32, lda: c_int, beta: *const f32, C: *mut f32, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 29, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDsyrk_v2(handle: *mut c_void, uplo: c_int, trans: c_int, n: c_int, k: c_int, alpha: *const f64, A: *const f64, lda: c_int, beta: *const f64, C: *mut f64, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 30, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasSsymm_v2(handle: *mut c_void, side: c_int, uplo: c_int, m: c_int, n: c_int, alpha: *const f32, A: *const f32, lda: c_int, B: *const f32, ldb: c_int, beta: *const f32, C: *mut f32, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(side as i32).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 31, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDsymm_v2(handle: *mut c_void, side: c_int, uplo: c_int, m: c_int, n: c_int, alpha: *const f64, A: *const f64, lda: c_int, B: *const f64, ldb: c_int, beta: *const f64, C: *mut f64, ldc: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(side as i32).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f64).to_le_bytes());
a.extend_from_slice(&(C as u64).to_le_bytes());
a.extend_from_slice(&(ldc as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 32, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasStrsm_v2(handle: *mut c_void, side: c_int, uplo: c_int, trans: c_int, diag: c_int, m: c_int, n: c_int, alpha: *const f32, A: *const f32, lda: c_int, B: *mut f32, ldb: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(side as i32).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(diag as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 33, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cublasDtrsm_v2(handle: *mut c_void, side: c_int, uplo: c_int, trans: c_int, diag: c_int, m: c_int, n: c_int, alpha: *const f64, A: *const f64, lda: c_int, B: *mut f64, ldb: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(side as i32).to_le_bytes());
a.extend_from_slice(&(uplo as i32).to_le_bytes());
a.extend_from_slice(&(trans as i32).to_le_bytes());
a.extend_from_slice(&(diag as i32).to_le_bytes());
a.extend_from_slice(&(m as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f64).to_le_bytes());
a.extend_from_slice(&(A as u64).to_le_bytes());
a.extend_from_slice(&(lda as i32).to_le_bytes());
a.extend_from_slice(&(B as u64).to_le_bytes());
a.extend_from_slice(&(ldb as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 34, a)) { Ok(()) => 0, Err(_) => 1 }
}
@@ -0,0 +1,164 @@
// @generated by smolvm-cuda-codegen — do not edit. lib='cudnn' id=2
const LIB_ID: u8 = 2;
#[no_mangle]
pub extern "C" fn cudnnCreate(handle_out: *mut *mut c_void) -> c_int {
if handle_out.is_null() { return 1; }
// Fire-and-forget create: return a guest-assigned virtual id
// immediately; the host materializes the real descriptor and
// maps the id (see vh_resolve on the host side).
let vh = super::alloc_vhandle();
match with_client(|c| c.lib_call_deferred(LIB_ID, 0, vh.to_le_bytes().to_vec())) {
Ok(()) => { unsafe { *handle_out = vh as *mut c_void }; 0 }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cudnnDestroy(handle: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 1, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnSetStream(handle: *mut c_void, stream: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(stream as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 2, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnCreateTensorDescriptor(handle_out: *mut *mut c_void) -> c_int {
if handle_out.is_null() { return 1; }
// Fire-and-forget create: return a guest-assigned virtual id
// immediately; the host materializes the real descriptor and
// maps the id (see vh_resolve on the host side).
let vh = super::alloc_vhandle();
match with_client(|c| c.lib_call_deferred(LIB_ID, 3, vh.to_le_bytes().to_vec())) {
Ok(()) => { unsafe { *handle_out = vh as *mut c_void }; 0 }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cudnnSetTensor4dDescriptor(t: *mut c_void, fmt: c_int, dtype: c_int, n: c_int, c: c_int, h: c_int, w: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(t as u64).to_le_bytes());
a.extend_from_slice(&(fmt as i32).to_le_bytes());
a.extend_from_slice(&(dtype as i32).to_le_bytes());
a.extend_from_slice(&(n as i32).to_le_bytes());
a.extend_from_slice(&(c as i32).to_le_bytes());
a.extend_from_slice(&(h as i32).to_le_bytes());
a.extend_from_slice(&(w as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 4, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnDestroyTensorDescriptor(t: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(t as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 5, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnCreateFilterDescriptor(handle_out: *mut *mut c_void) -> c_int {
if handle_out.is_null() { return 1; }
// Fire-and-forget create: return a guest-assigned virtual id
// immediately; the host materializes the real descriptor and
// maps the id (see vh_resolve on the host side).
let vh = super::alloc_vhandle();
match with_client(|c| c.lib_call_deferred(LIB_ID, 6, vh.to_le_bytes().to_vec())) {
Ok(()) => { unsafe { *handle_out = vh as *mut c_void }; 0 }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cudnnSetFilter4dDescriptor(f: *mut c_void, dtype: c_int, fmt: c_int, k: c_int, c: c_int, h: c_int, w: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(f as u64).to_le_bytes());
a.extend_from_slice(&(dtype as i32).to_le_bytes());
a.extend_from_slice(&(fmt as i32).to_le_bytes());
a.extend_from_slice(&(k as i32).to_le_bytes());
a.extend_from_slice(&(c as i32).to_le_bytes());
a.extend_from_slice(&(h as i32).to_le_bytes());
a.extend_from_slice(&(w as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 7, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnDestroyFilterDescriptor(f: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(f as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 8, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnCreateConvolutionDescriptor(handle_out: *mut *mut c_void) -> c_int {
if handle_out.is_null() { return 1; }
// Fire-and-forget create: return a guest-assigned virtual id
// immediately; the host materializes the real descriptor and
// maps the id (see vh_resolve on the host side).
let vh = super::alloc_vhandle();
match with_client(|c| c.lib_call_deferred(LIB_ID, 9, vh.to_le_bytes().to_vec())) {
Ok(()) => { unsafe { *handle_out = vh as *mut c_void }; 0 }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cudnnSetConvolution2dDescriptor(cv: *mut c_void, pad_h: c_int, pad_w: c_int, u: c_int, v: c_int, dil_h: c_int, dil_w: c_int, mode: c_int, ctype: c_int) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(cv as u64).to_le_bytes());
a.extend_from_slice(&(pad_h as i32).to_le_bytes());
a.extend_from_slice(&(pad_w as i32).to_le_bytes());
a.extend_from_slice(&(u as i32).to_le_bytes());
a.extend_from_slice(&(v as i32).to_le_bytes());
a.extend_from_slice(&(dil_h as i32).to_le_bytes());
a.extend_from_slice(&(dil_w as i32).to_le_bytes());
a.extend_from_slice(&(mode as i32).to_le_bytes());
a.extend_from_slice(&(ctype as i32).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 10, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnDestroyConvolutionDescriptor(cv: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(cv as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 11, a)) { Ok(()) => 0, Err(_) => 1 }
}
#[no_mangle]
pub extern "C" fn cudnnGetConvolutionForwardWorkspaceSize(handle: *mut c_void, x: *mut c_void, w: *mut c_void, cv: *mut c_void, y: *mut c_void, algo: c_int, size: *mut usize) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(w as u64).to_le_bytes());
a.extend_from_slice(&(cv as u64).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
a.extend_from_slice(&(algo as i32).to_le_bytes());
match with_client(|c| c.lib_call(LIB_ID, 12, a)) {
Ok((st, out)) => { let mut o = 0usize;
if !size.is_null() && out.len() >= o + 8 { unsafe { *size = u64::from_le_bytes(out[o..o + 8].try_into().unwrap()) as _ }; } o += 8;
let _ = o; st }
Err(_) => 1,
}
}
#[no_mangle]
pub extern "C" fn cudnnConvolutionForward(handle: *mut c_void, alpha: *const f32, xDesc: *mut c_void, x: *mut c_void, wDesc: *mut c_void, w: *mut c_void, convDesc: *mut c_void, algo: c_int, workspace: *mut c_void, wsSize: usize, beta: *const f32, yDesc: *mut c_void, y: *mut c_void) -> c_int {
let mut a: Vec<u8> = Vec::new();
a.extend_from_slice(&(handle as u64).to_le_bytes());
if alpha.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *alpha } as f32).to_le_bytes());
a.extend_from_slice(&(xDesc as u64).to_le_bytes());
a.extend_from_slice(&(x as u64).to_le_bytes());
a.extend_from_slice(&(wDesc as u64).to_le_bytes());
a.extend_from_slice(&(w as u64).to_le_bytes());
a.extend_from_slice(&(convDesc as u64).to_le_bytes());
a.extend_from_slice(&(algo as i32).to_le_bytes());
a.extend_from_slice(&(workspace as u64).to_le_bytes());
a.extend_from_slice(&(wsSize as u64).to_le_bytes());
if beta.is_null() { return 1; }
a.extend_from_slice(&(unsafe { *beta } as f32).to_le_bytes());
a.extend_from_slice(&(yDesc as u64).to_le_bytes());
a.extend_from_slice(&(y as u64).to_le_bytes());
// No output params: fire-and-forget (failures surface as sticky async errors).
match with_client(|c| c.lib_call_deferred(LIB_ID, 13, a)) { Ok(()) => 0, Err(_) => 1 }
}
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "smolvm-network"
version = "1.5.2"
edition = "2021"
description = "Host-side virtio-net runtime for smolvm"
license = "Apache-2.0"
repository = "https://github.com/smol-machines/smolvm"
[dependencies]
crossbeam-queue = "0.3"
humantime = "2"
polling = "3"
smoltcp = { version = "0.13", default-features = false, features = [
"std",
"log",
"medium-ethernet",
"proto-ipv4",
"proto-ipv6",
"socket-tcp",
"socket-udp",
# Raw IP sockets capture the guest's ICMP echo traffic so the gateway can
# relay ping to real host ICMP sockets (see `icmp_relay`).
"socket-raw",
# The gateway interface owns three addresses: IPv4, IPv6 ULA, and the
# IPv6 link-local (default cap is 2).
"iface-max-addr-count-3",
] }
socket2 = "0.6"
tracing = { workspace = true }
[features]
# Exposes fuzz-only entrypoints (e.g. fuzz_classify_guest_frame). Never enabled in normal builds.
fuzzing = []
+4
View File
@@ -0,0 +1,4 @@
target
corpus
artifacts
coverage
+363
View File
@@ -0,0 +1,363 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.2.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "defmt"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
dependencies = [
"defmt 1.1.0",
]
[[package]]
name = "defmt"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f"
dependencies = [
"bitflags",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b"
dependencies = [
"defmt-parser",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]]
name = "heapless"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom",
"libc",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libfuzzer-sys"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
"arbitrary",
"cc",
]
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "managed"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "smoltcp"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e"
dependencies = [
"bitflags",
"byteorder",
"cfg-if",
"defmt 0.3.100",
"heapless",
"log",
"managed",
]
[[package]]
name = "smolvm-network"
version = "1.0.3"
dependencies = [
"crossbeam-queue",
"humantime",
"libc",
"smoltcp",
"tracing",
]
[[package]]
name = "smolvm-network-fuzz"
version = "0.0.0"
dependencies = [
"libfuzzer-sys",
"smolvm-network",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "smolvm-network-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
[dependencies.smolvm-network]
path = ".."
features = ["fuzzing"]
[[bin]]
name = "classify_guest_frame"
path = "fuzz_targets/classify_guest_frame.rs"
test = false
doc = false
bench = false
[workspace]
@@ -0,0 +1,10 @@
#![no_main]
//! Fuzz the host-side parser of guest-driven ethernet frames.
//!
//! A malicious guest sends arbitrary bytes over virtio-net; the host runs each
//! through `classify_guest_frame`. It must never panic / abort on any input.
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
smolvm_network::stack::fuzz_classify_guest_frame(data);
});
+243
View File
@@ -0,0 +1,243 @@
//! smoltcp `phy::Device` adapter for the virtio-net backend.
//!
//! Context
//! =======
//!
//! smoltcp talks to an implementation of its `phy::Device` trait:
//! - `receive()` yields one incoming frame and a transmit token
//! - `transmit()` yields a transmit token when space exists for an outgoing
//! frame
//! - `capabilities()` describes medium and MTU
//!
//! This module is the narrow adapter layer between:
//! - smoltcp's abstract device API
//! - the queue-based frame transport used by the rest of the virtio runtime
//!
//! Data flow:
//!
//! ```text
//! guest_to_host queue --stage_next_frame--> smoltcp receive()
//! smoltcp transmit() --host_to_guest queue--> frame_stream writer
//! ```
//!
//! More concretely:
//!
//! ```text
//! guest frame arrives in guest_to_host
//! -> poll loop calls stage_next_frame()
//! -> poll loop may inspect/classify frame first
//! -> smoltcp calls receive()
//! -> DeviceRxToken hands bytes to smoltcp
//!
//! smoltcp wants to emit a frame
//! -> calls transmit()
//! -> gets DeviceTxToken
//! -> fills provided buffer
//! -> token pushes frame into host_to_guest
//! -> poll loop later wakes frame writer
//! ```
use crate::queues::NetworkFrameQueues;
use smoltcp::phy::{self, DeviceCapabilities, Medium};
use smoltcp::time::Instant;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
/// smoltcp `Device` backed by shared frame queues.
///
/// `staged_guest_frame` exists because the poll loop sometimes needs to inspect
/// a frame before handing it to smoltcp. In particular, the stack wants to
/// classify guest TCP SYN and DNS packets before consumption so it can prepare
/// relay/socket state.
///
/// The staging pattern looks like:
///
/// ```text
/// queue -> staged_guest_frame -> RxToken -> smoltcp
/// ```
pub struct VirtioNetworkDevice {
queues: Arc<NetworkFrameQueues>,
mtu: usize,
staged_guest_frame: Option<Vec<u8>>,
/// Set when smoltcp emitted at least one frame for the guest.
pub(crate) frames_emitted: AtomicBool,
}
/// RX token representing one guest ethernet frame.
///
/// smoltcp consumes RX tokens immediately; the token just owns the frame bytes
/// until the stack asks to inspect them.
pub struct DeviceRxToken {
frame: Vec<u8>,
}
/// TX token representing one outgoing frame from smoltcp.
///
/// The token borrows the device so it can enqueue the produced frame when
/// smoltcp finishes writing into the provided buffer.
pub struct DeviceTxToken<'a> {
device: &'a mut VirtioNetworkDevice,
}
impl VirtioNetworkDevice {
/// Create a new device for the given queues and MTU.
///
/// `mtu` here is the guest IP MTU, not the full Ethernet frame size.
/// `capabilities()` translates it to the Ethernet-frame convention expected
/// by smoltcp.
pub fn new(queues: Arc<NetworkFrameQueues>, mtu: usize) -> Self {
Self {
queues,
mtu,
staged_guest_frame: None,
frames_emitted: AtomicBool::new(false),
}
}
/// Stage one guest frame so the poll loop can inspect it before smoltcp consumes it.
///
/// Why staging exists:
/// - the frame arrives first in `guest_to_host`
/// - the poll loop may need to classify it before calling
/// `Interface::poll_ingress_single`
/// - once smoltcp calls `receive()`, ownership moves into an RX token
///
/// So staging gives the poll loop a temporary peek at the next frame
/// without losing the normal smoltcp `Device` flow.
///
/// This is the key reason the adapter is not just a direct `queue.pop()`
/// inside `receive()`.
pub fn stage_next_frame(&mut self) -> Option<&[u8]> {
if self.staged_guest_frame.is_none() {
self.staged_guest_frame = self.queues.guest_to_host.pop();
}
self.staged_guest_frame.as_deref()
}
/// Drop the currently staged guest frame.
///
/// This is used when the poll loop decides not to pass a frame into
/// smoltcp, for example when the MVP intentionally drops unsupported UDP.
pub fn drop_staged_frame(&mut self) {
self.staged_guest_frame = None;
}
}
impl phy::Device for VirtioNetworkDevice {
type RxToken<'a> = DeviceRxToken;
type TxToken<'a> = DeviceTxToken<'a>;
fn receive(&mut self, _timestamp: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
// smoltcp asks for the next ingress frame only after the poll loop has
// already staged it. If nothing is staged, there is nothing to receive.
let frame = self.staged_guest_frame.take()?;
// This is the single point where a guest-outbound frame is accepted into
// the stack (frames the poll loop dropped never reach here), so it's the
// natural place to meter egress. Count the full ethernet frame.
self.queues.add_egress_bytes(frame.len() as u64);
Some((DeviceRxToken { frame }, DeviceTxToken { device: self }))
}
fn transmit(&mut self, _timestamp: Instant) -> Option<Self::TxToken<'_>> {
// smoltcp may ask for a transmit token even when the downstream writer
// is temporarily behind. We only hand out a token if the host->guest
// queue still has room.
if self.queues.host_to_guest.len() < self.queues.host_to_guest.capacity() {
Some(DeviceTxToken { device: self })
} else {
None
}
}
fn capabilities(&self) -> DeviceCapabilities {
let mut capabilities = DeviceCapabilities::default();
capabilities.medium = Medium::Ethernet;
// smoltcp wants the maximum Ethernet frame size here, not the Linux IP
// MTU. For Ethernet devices that means "IP MTU + 14-byte Ethernet
// header"; see smoltcp's `DeviceCapabilities::max_transmission_unit`
// documentation.
capabilities.max_transmission_unit = self.mtu + 14;
capabilities
}
}
impl phy::RxToken for DeviceRxToken {
/// Hand the queued guest frame bytes to smoltcp.
fn consume<R, F>(self, f: F) -> R
where
F: FnOnce(&[u8]) -> R,
{
f(&self.frame)
}
}
impl<'a> phy::TxToken for DeviceTxToken<'a> {
/// Let smoltcp build one Ethernet frame and enqueue it for libkrun.
///
/// Flow:
///
/// ```text
/// smoltcp fills provided buffer
/// -> adapter enqueues frame into host_to_guest
/// -> sets frames_emitted
/// -> poll loop later wakes the Unix-stream writer
/// ```
///
/// The queue push is the handoff point. After that, this adapter no longer
/// owns the frame bytes; the frame writer thread eventually serializes them
/// onto the libkrun Unix stream.
fn consume<R, F>(self, len: usize, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R,
{
let mut frame = vec![0u8; len];
let result = f(&mut frame);
if self.device.queues.host_to_guest.push(frame).is_ok() {
self.device.frames_emitted.store(true, Ordering::Relaxed);
} else {
tracing::debug!("dropping outbound ethernet frame because the guest queue is full");
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::queues::DEFAULT_FRAME_QUEUE_CAPACITY;
use smoltcp::phy::{Device, RxToken};
#[test]
fn receive_counts_guest_outbound_bytes_as_egress() {
let queues = NetworkFrameQueues::shared(DEFAULT_FRAME_QUEUE_CAPACITY);
let mut dev = VirtioNetworkDevice::new(queues.clone(), 1500);
assert_eq!(queues.egress_bytes(), 0, "starts at zero");
// A guest frame must be staged before smoltcp's receive() can take it.
queues.guest_to_host.push(vec![0u8; 100]).unwrap();
assert!(dev.stage_next_frame().is_some());
let (rx, _tx) = dev.receive(Instant::ZERO).expect("frame available");
rx.consume(|f| assert_eq!(f.len(), 100));
assert_eq!(queues.egress_bytes(), 100, "one frame metered");
// A second frame accumulates.
queues.guest_to_host.push(vec![0u8; 40]).unwrap();
assert!(dev.stage_next_frame().is_some());
let (rx, _tx) = dev.receive(Instant::ZERO).expect("frame available");
rx.consume(|_| {});
assert_eq!(queues.egress_bytes(), 140, "cumulative");
}
#[test]
fn dropped_staged_frame_is_not_metered() {
let queues = NetworkFrameQueues::shared(DEFAULT_FRAME_QUEUE_CAPACITY);
let mut dev = VirtioNetworkDevice::new(queues.clone(), 1500);
queues.guest_to_host.push(vec![0u8; 100]).unwrap();
assert!(dev.stage_next_frame().is_some());
// The poll loop drops frames it won't forward (e.g. unsupported UDP);
// those never reach the stack, so they must not bill egress.
dev.drop_staged_frame();
assert_eq!(queues.egress_bytes(), 0, "dropped frame not metered");
}
}
+380
View File
@@ -0,0 +1,380 @@
//! Minimal DNS wire parsing + allow-host filtering for the virtio-net gateway.
//!
//! This mirrors libkrun's TSI DNS filter (`vsock/dns_filter.rs`) so `--allow-host`
//! behaves identically on both backends: a query is forwarded upstream only when
//! its name matches the allow-host list (exact match or a subdomain), the answer's
//! A and AAAA records are learned as temporarily-allowed egress IPs, and a
//! disallowed name gets an NXDOMAIN. Hostname matching and TTL clamping match the
//! libkrun rules exactly.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
const DNS_HEADER_LEN: usize = 12;
const DNS_ID_LEN: usize = 2;
const DNS_U16_LEN: usize = 2;
const DNS_U32_LEN: usize = 4;
const DNS_FLAGS_OFFSET: usize = 2;
const DNS_QDCOUNT_OFFSET: usize = 4;
const DNS_ANCOUNT_OFFSET: usize = 6;
const DNS_QUESTION_FIXED_LEN: usize = 4;
const DNS_RR_FIXED_LEN: usize = 10;
const DNS_RR_TYPE_OFFSET: usize = 0;
const DNS_RR_CLASS_OFFSET: usize = 2;
const DNS_RR_TTL_OFFSET: usize = 4;
const DNS_RR_RDLEN_OFFSET: usize = 8;
const DNS_CLASS_IN: u16 = 1;
const DNS_TYPE_A: u16 = 1;
const DNS_TYPE_AAAA: u16 = 28;
const DNS_A_RDATA_LEN: usize = 4;
const DNS_AAAA_RDATA_LEN: usize = 16;
pub const DNS_RCODE_SERVFAIL: u16 = 2;
pub const DNS_RCODE_NXDOMAIN: u16 = 3;
const DNS_RCODE_MASK: u16 = 0x000f;
const DNS_ONE_QUESTION: u16 = 1;
const DNS_FLAG_RESPONSE: u16 = 0x8000;
const DNS_FLAG_RECURSION_DESIRED: u16 = 0x0100;
const DNS_FLAG_RECURSION_AVAILABLE: u16 = 0x0080;
const DNS_POINTER_TAG: u8 = 0xc0;
const DNS_POINTER_MASK: u8 = 0xc0;
const DNS_POINTER_OFFSET_MASK: u8 = 0x3f;
const DNS_MAX_COMPRESSION_JUMPS: usize = 16;
const DNS_MAX_LABEL_LEN: usize = 63;
/// Lowercase + strip a trailing dot. `None` for an empty name.
pub fn normalize_hostname(hostname: &str) -> Option<String> {
let hostname = hostname.trim_end_matches('.').to_ascii_lowercase();
if hostname.is_empty() {
None
} else {
Some(hostname)
}
}
/// Whether `hostname` matches the allow-list: exact match, or a subdomain of an
/// allowed entry (`foo.example.com` matches `example.com`, `notexample.com` does
/// not). Mirrors libkrun's `is_hostname_allowed`.
pub fn hostname_allowed(hostname: &str, allowed_hosts: &[String]) -> bool {
let Some(hostname) = normalize_hostname(hostname) else {
return false;
};
allowed_hosts.iter().any(|allowed| {
hostname == *allowed
|| hostname
.strip_suffix(allowed)
.is_some_and(|prefix| prefix.ends_with('.'))
})
}
/// The question name in a DNS query (first question only). `None` on malformed input.
pub fn question_name(packet: &[u8]) -> Option<String> {
if packet.len() < DNS_HEADER_LEN || read_u16(packet, DNS_QDCOUNT_OFFSET)? != DNS_ONE_QUESTION {
return None;
}
let (name, _) = read_name(packet, DNS_HEADER_LEN)?;
Some(name)
}
/// Extract `(IpAddr, ttl)` pairs from the A and AAAA records in a DNS response.
pub fn answer_ip_records(packet: &[u8]) -> Vec<(IpAddr, u32)> {
parse_answer_ip_records(packet).unwrap_or_default()
}
fn parse_answer_ip_records(packet: &[u8]) -> Option<Vec<(IpAddr, u32)>> {
if packet.len() < DNS_HEADER_LEN {
return None;
}
let qdcount = read_u16(packet, DNS_QDCOUNT_OFFSET)? as usize;
let ancount = read_u16(packet, DNS_ANCOUNT_OFFSET)? as usize;
let mut offset = DNS_HEADER_LEN;
for _ in 0..qdcount {
let (_, after_name) = read_name(packet, offset)?;
if after_name + DNS_QUESTION_FIXED_LEN > packet.len() {
return None;
}
offset = after_name + DNS_QUESTION_FIXED_LEN;
}
let mut ips = Vec::new();
for _ in 0..ancount {
let (_, after_name) = read_name(packet, offset)?;
if after_name + DNS_RR_FIXED_LEN > packet.len() {
return None;
}
offset = after_name;
let rr_type = read_u16(packet, offset + DNS_RR_TYPE_OFFSET)?;
let class = read_u16(packet, offset + DNS_RR_CLASS_OFFSET)?;
let ttl = read_u32(packet, offset + DNS_RR_TTL_OFFSET)?;
let rdlen = read_u16(packet, offset + DNS_RR_RDLEN_OFFSET)? as usize;
offset += DNS_RR_FIXED_LEN;
if offset + rdlen > packet.len() {
return None;
}
if class == DNS_CLASS_IN && rr_type == DNS_TYPE_A && rdlen == DNS_A_RDATA_LEN {
ips.push((
IpAddr::V4(Ipv4Addr::new(
packet[offset],
packet[offset + 1],
packet[offset + 2],
packet[offset + 3],
)),
ttl,
));
} else if class == DNS_CLASS_IN && rr_type == DNS_TYPE_AAAA && rdlen == DNS_AAAA_RDATA_LEN {
let octets: [u8; DNS_AAAA_RDATA_LEN] = packet[offset..offset + DNS_AAAA_RDATA_LEN]
.try_into()
.ok()?;
ips.push((IpAddr::V6(Ipv6Addr::from(octets)), ttl));
}
offset += rdlen;
}
Some(ips)
}
/// Build a DNS response carrying just an error `rcode` (e.g. NXDOMAIN), echoing
/// the query id and question. Mirrors libkrun's `build_error_response`.
pub fn error_response(query: &[u8], rcode: u16) -> Vec<u8> {
let id: &[u8] = if query.len() >= DNS_ID_LEN {
&query[..DNS_ID_LEN]
} else {
&[0, 0]
};
let req_flags = if query.len() >= DNS_FLAGS_OFFSET + DNS_U16_LEN {
read_u16(query, DNS_FLAGS_OFFSET).unwrap_or(0)
} else {
0
};
let flags = DNS_FLAG_RESPONSE
| (req_flags & DNS_FLAG_RECURSION_DESIRED)
| DNS_FLAG_RECURSION_AVAILABLE
| (rcode & DNS_RCODE_MASK);
// Echo the question section (qdcount stays as in the query) when parseable.
let question_end = question_section_end(query);
let qdcount = if question_end.is_some() { 1u16 } else { 0 };
let mut response = Vec::with_capacity(question_end.unwrap_or(DNS_HEADER_LEN));
response.extend_from_slice(id);
response.extend_from_slice(&flags.to_be_bytes());
response.extend_from_slice(&qdcount.to_be_bytes());
response.extend_from_slice(&0u16.to_be_bytes()); // ancount
response.extend_from_slice(&0u16.to_be_bytes()); // nscount
response.extend_from_slice(&0u16.to_be_bytes()); // arcount
if let Some(end) = question_end {
response.extend_from_slice(&query[DNS_HEADER_LEN..end]);
}
response
}
fn question_section_end(packet: &[u8]) -> Option<usize> {
if packet.len() < DNS_HEADER_LEN || read_u16(packet, DNS_QDCOUNT_OFFSET)? != DNS_ONE_QUESTION {
return None;
}
let (_, after_name) = read_name(packet, DNS_HEADER_LEN)?;
let end = after_name + DNS_QUESTION_FIXED_LEN;
if end > packet.len() {
return None;
}
Some(end)
}
/// Parse a DNS name (with compression pointers), returning `(name, next_offset)`.
/// `next_offset` is the byte after the name in the *original* (non-jumped) stream.
fn read_name(packet: &[u8], offset: usize) -> Option<(String, usize)> {
let mut labels = Vec::new();
let mut pos = offset;
let mut next_offset = offset;
let mut jumped = false;
let mut jumps = 0;
loop {
let len = *packet.get(pos)?;
if len & DNS_POINTER_MASK == DNS_POINTER_TAG {
let lo = *packet.get(pos + 1)?;
let pointer = (((len & DNS_POINTER_OFFSET_MASK) as usize) << 8) | lo as usize;
if pointer >= packet.len() {
return None;
}
if !jumped {
next_offset = pos + DNS_U16_LEN;
}
pos = pointer;
jumped = true;
jumps += 1;
if jumps > DNS_MAX_COMPRESSION_JUMPS {
return None;
}
continue;
}
if len & DNS_POINTER_MASK != 0 {
return None;
}
pos += 1;
if len == 0 {
if !jumped {
next_offset = pos;
}
break;
}
let len = len as usize;
if len > DNS_MAX_LABEL_LEN || pos + len > packet.len() {
return None;
}
let label = std::str::from_utf8(&packet[pos..pos + len]).ok()?;
labels.push(label.to_ascii_lowercase());
pos += len;
if !jumped {
next_offset = pos;
}
}
Some((labels.join("."), next_offset))
}
fn read_u16(buf: &[u8], offset: usize) -> Option<u16> {
let bytes = buf.get(offset..offset + DNS_U16_LEN)?;
Some(u16::from_be_bytes([bytes[0], bytes[1]]))
}
fn read_u32(buf: &[u8], offset: usize) -> Option<u32> {
let bytes = buf.get(offset..offset + DNS_U32_LEN)?;
Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
#[cfg(test)]
mod tests {
use super::*;
/// A query for `name` with one question (A/IN).
fn query_for(name: &str) -> Vec<u8> {
let mut q = vec![
0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
for label in name.split('.') {
q.push(label.len() as u8);
q.extend_from_slice(label.as_bytes());
}
q.push(0);
q.extend_from_slice(&DNS_TYPE_A.to_be_bytes());
q.extend_from_slice(&DNS_CLASS_IN.to_be_bytes());
q
}
#[test]
fn parses_question_name() {
assert_eq!(
question_name(&query_for("www.example.com")).as_deref(),
Some("www.example.com")
);
}
#[test]
fn hostname_matching_allows_exact_and_subdomain_only() {
let allow = vec!["example.com".to_string()];
assert!(hostname_allowed("example.com", &allow));
assert!(hostname_allowed("www.example.com", &allow));
assert!(hostname_allowed("a.b.example.com", &allow));
assert!(!hostname_allowed("notexample.com", &allow));
assert!(!hostname_allowed("example.com.evil.com", &allow));
}
#[test]
fn empty_allow_list_blocks_all() {
assert!(!hostname_allowed("example.com", &[]));
}
#[test]
fn extracts_a_records_with_ttl() {
// Response: echo the question, then one A record 93.184.216.34 ttl 300.
let mut r = query_for("example.com");
r[2] = 0x81; // QR=1, RD=1
r[3] = 0x80; // RA=1
r[6] = 0x00;
r[7] = 0x01; // ancount = 1
// Answer: name pointer to question (0xc00c), type A, class IN, ttl, rdlen, rdata
r.extend_from_slice(&[0xc0, 0x0c]);
r.extend_from_slice(&DNS_TYPE_A.to_be_bytes());
r.extend_from_slice(&DNS_CLASS_IN.to_be_bytes());
r.extend_from_slice(&300u32.to_be_bytes());
r.extend_from_slice(&(DNS_A_RDATA_LEN as u16).to_be_bytes());
r.extend_from_slice(&[93, 184, 216, 34]);
let records = answer_ip_records(&r);
assert_eq!(
records,
vec![(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 300)]
);
}
#[test]
fn extracts_aaaa_records_with_ttl() {
// Response with one AAAA record 2606:2800:21f:cb07:6820:80da:af6b:8b2c ttl 600.
let v6: Ipv6Addr = "2606:2800:21f:cb07:6820:80da:af6b:8b2c".parse().unwrap();
let mut r = query_for("example.com");
r[2] = 0x81;
r[3] = 0x80;
r[6] = 0x00;
r[7] = 0x01; // ancount = 1
r.extend_from_slice(&[0xc0, 0x0c]);
r.extend_from_slice(&DNS_TYPE_AAAA.to_be_bytes());
r.extend_from_slice(&DNS_CLASS_IN.to_be_bytes());
r.extend_from_slice(&600u32.to_be_bytes());
r.extend_from_slice(&(DNS_AAAA_RDATA_LEN as u16).to_be_bytes());
r.extend_from_slice(&v6.octets());
let records = answer_ip_records(&r);
assert_eq!(records, vec![(IpAddr::V6(v6), 600)]);
}
#[test]
fn extracts_mixed_a_and_aaaa_records() {
let v6: Ipv6Addr = "2606:4700::6810:84e5".parse().unwrap();
let mut r = query_for("example.com");
r[2] = 0x81;
r[3] = 0x80;
r[6] = 0x00;
r[7] = 0x02; // ancount = 2
r.extend_from_slice(&[0xc0, 0x0c]);
r.extend_from_slice(&DNS_TYPE_A.to_be_bytes());
r.extend_from_slice(&DNS_CLASS_IN.to_be_bytes());
r.extend_from_slice(&300u32.to_be_bytes());
r.extend_from_slice(&(DNS_A_RDATA_LEN as u16).to_be_bytes());
r.extend_from_slice(&[104, 16, 132, 229]);
r.extend_from_slice(&[0xc0, 0x0c]);
r.extend_from_slice(&DNS_TYPE_AAAA.to_be_bytes());
r.extend_from_slice(&DNS_CLASS_IN.to_be_bytes());
r.extend_from_slice(&600u32.to_be_bytes());
r.extend_from_slice(&(DNS_AAAA_RDATA_LEN as u16).to_be_bytes());
r.extend_from_slice(&v6.octets());
let records = answer_ip_records(&r);
assert_eq!(
records,
vec![
(IpAddr::V4(Ipv4Addr::new(104, 16, 132, 229)), 300),
(IpAddr::V6(v6), 600),
]
);
}
#[test]
fn error_response_is_well_formed() {
let q = query_for("blocked.test");
let resp = error_response(&q, DNS_RCODE_NXDOMAIN);
// QR bit set + rcode NXDOMAIN in low nibble of flags.
let flags = read_u16(&resp, DNS_FLAGS_OFFSET).unwrap();
assert_eq!(flags & DNS_FLAG_RESPONSE, DNS_FLAG_RESPONSE);
assert_eq!(flags & DNS_RCODE_MASK, DNS_RCODE_NXDOMAIN);
assert_eq!(&resp[..2], &q[..2]); // echoed id
}
#[test]
fn malformed_packets_dont_panic() {
assert_eq!(question_name(&[0, 1, 2]), None);
assert!(answer_ip_records(&[0xff; 4]).is_empty());
}
}
+497
View File
@@ -0,0 +1,497 @@
//! Off-poll-thread DNS resolver for the virtio-net backend.
//!
//! Context
//! =======
//!
//! Guest DNS (UDP/TCP :53) is intercepted by the gateway and forwarded to an
//! upstream resolver (`stack.rs::process_dns_queries` / `process_dns_tcp`). The
//! upstream exchange is a *blocking* host socket round trip with a 2-second
//! timeout. It used to run inline on the single virtio-net poll thread — the
//! thread that owns the smoltcp interface and every one of that VM's TCP/UDP/
//! ICMP sockets. A slow or dead resolver therefore stalled *all* of the guest's
//! traffic for up to 2 seconds (head-of-line block).
//!
//! This module moves that blocking resolution off the poll thread, mirroring
//! the UDP/ICMP relay offload ([`crate::udp_relay`]). The poll loop only:
//! 1. reads a guest query out of its smoltcp DNS socket,
//! 2. applies the egress allow-host policy itself (cheap, no host I/O),
//! 3. hands allowed queries to this relay over a channel, and
//! 4. later picks the answer back up via the shared relay wake + channel and
//! writes it into the guest socket — never blocking on the resolver.
//!
//! Egress policy (allow/deny, `learn_ip_records`) stays on the poll thread so
//! [`EgressPolicy`](crate::egress::EgressPolicy) is never shared across threads;
//! only the raw upstream forward is offloaded.
//!
//! ```text
//! guest :53 query -> smoltcp gateway socket
//! -> poll loop: classify (allow-host) -> allowed?
//! no -> answer NXDOMAIN/SERVFAIL immediately (no relay)
//! yes -> assign id, remember reply context, channel (id, query) to relay
//! -> relay thread: UDP -> non-blocking connected host socket + poller
//! TCP -> bounded detached worker (rare path)
//! -> answer bytes -> channel back -> reply_wake
//! -> poll loop: learn A/AAAA records, write answer into the guest socket
//! ```
//!
//! DNS is low-volume and its answers are quick, so the tables here are small and
//! loss under saturation just makes a guest see a normal DNS timeout.
use crate::queues::WakePipe;
use crate::virtio_net_log;
use polling::{Event, Events};
use std::io::{Read, Write};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream, UdpSocket as HostUdpSocket};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
/// DNS service port.
const DNS_PORT: u16 = 53;
/// Upstream exchange timeout, matching the previous inline behaviour.
const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(2);
/// Largest DNS message we accept back (EDNS0 / DNS-over-TCP bound).
const DNS_MAX_MSG: usize = 4096;
/// Max in-flight queries buffered in each channel direction.
const CHANNEL_CAPACITY: usize = 256;
/// Max concurrent in-flight UDP queries with a live host socket.
const MAX_INFLIGHT_UDP: usize = 256;
/// Max concurrent in-flight DNS-over-TCP worker threads.
const MAX_INFLIGHT_TCP: usize = 64;
/// Relay thread poll ceiling so shutdown and deadlines are noticed promptly.
const RELAY_POLL_MAX_MS: u64 = 250;
/// Which upstream transport a query must use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DnsTransport {
Udp,
Tcp,
}
/// A query handed from the poll loop to the relay thread.
pub struct DnsQuery {
/// Correlation id chosen by the poll loop; echoed back in the response.
pub id: u64,
/// Transport to reach the upstream resolver on.
pub transport: DnsTransport,
/// Upstream resolver address.
pub upstream: Ipv4Addr,
/// Raw DNS query message (no TCP length prefix).
pub query: Vec<u8>,
}
/// A resolved answer handed back to the poll loop.
pub struct DnsResponse {
/// The id of the originating [`DnsQuery`].
pub id: u64,
/// Raw DNS answer message, or `None` on error/timeout (guest sees a normal
/// DNS timeout — identical to the old inline `forward` error path).
pub answer: Option<Vec<u8>>,
}
/// Channel pair connecting the poll loop and the DNS relay thread.
pub struct DnsRelayChannels {
/// Poll loop -> relay thread.
pub to_relay: SyncSender<DnsQuery>,
/// Relay thread -> poll loop.
pub from_relay: Receiver<DnsResponse>,
/// Wakes the relay thread after `to_relay` sends.
pub relay_thread_wake: WakePipe,
}
/// Start the DNS relay thread. Returns the poll-loop-side channel endpoints.
///
/// `reply_wake` is the smoltcp poll loop's existing relay wake pipe — pulsed
/// whenever an answer is queued so the loop wakes to deliver it. The thread
/// exits when `shutdown` reports true (checked at least once per
/// [`RELAY_POLL_MAX_MS`]).
pub fn start_dns_relay(
reply_wake: Arc<WakePipe>,
shutdown: Arc<dyn Fn() -> bool + Send + Sync>,
) -> DnsRelayChannels {
let (to_relay_tx, to_relay_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
let (from_relay_tx, from_relay_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
let relay_thread_wake = WakePipe::new();
let thread_wake = relay_thread_wake.clone();
let _ = thread::Builder::new()
.name("smolvm-dns-relay".into())
.spawn(move || {
run_dns_relay(
to_relay_rx,
from_relay_tx,
thread_wake,
reply_wake,
shutdown,
);
});
DnsRelayChannels {
to_relay: to_relay_tx,
from_relay: from_relay_rx,
relay_thread_wake,
}
}
/// One in-flight UDP query: a non-blocking connected host socket awaiting its
/// single response, and the deadline after which we give up.
struct InflightUdp {
socket: HostUdpSocket,
deadline: Instant,
}
/// Send one answer back to the poll loop. Returns `true` if the caller should
/// wake the poll loop, `false` if the channel is closed (relay should exit).
fn deliver_answer(inbound: &SyncSender<DnsResponse>, id: u64, answer: Option<Vec<u8>>) -> bool {
match inbound.try_send(DnsResponse { id, answer }) {
Ok(()) => true,
Err(TrySendError::Full(_)) => {
virtio_net_log!(
"virtio-net: dropping DNS answer id={} (inbound queue full)",
id
);
true
}
Err(TrySendError::Disconnected(_)) => false,
}
}
fn run_dns_relay(
outbound: Receiver<DnsQuery>,
inbound: SyncSender<DnsResponse>,
wake: WakePipe,
reply_wake: Arc<WakePipe>,
shutdown: Arc<dyn Fn() -> bool + Send + Sync>,
) {
let mut inflight: std::collections::HashMap<u64, InflightUdp> =
std::collections::HashMap::new();
let mut recv_buf = vec![0u8; DNS_MAX_MSG];
let tcp_inflight = Arc::new(AtomicUsize::new(0));
loop {
if shutdown() {
return;
}
let mut woke_reply = false;
// Outbound: queries handed over by the poll loop.
loop {
match outbound.try_recv() {
Ok(query) => match query.transport {
DnsTransport::Udp => {
if inflight.len() >= MAX_INFLIGHT_UDP {
virtio_net_log!(
"virtio-net: dropping DNS query id={} (in-flight UDP table full)",
query.id
);
woke_reply |= deliver_answer(&inbound, query.id, None);
continue;
}
let upstream = SocketAddr::new(IpAddr::V4(query.upstream), DNS_PORT);
match start_udp_query(upstream, &query.query) {
Ok(socket) => {
inflight.insert(
query.id,
InflightUdp {
socket,
deadline: Instant::now() + UPSTREAM_TIMEOUT,
},
);
}
Err(err) => {
virtio_net_log!(
"virtio-net: DNS/UDP upstream send failed id={} error={}",
query.id,
err
);
woke_reply |= deliver_answer(&inbound, query.id, None);
}
}
}
DnsTransport::Tcp => {
spawn_tcp_query(&inbound, &reply_wake, &tcp_inflight, query);
}
},
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => return,
}
}
// Inbound: block on "wake OR any in-flight UDP socket readable", exactly
// like the UDP relay. Each socket is registered at key `slot + 1`.
let poller = wake.poller();
let ids: Vec<u64> = inflight.keys().copied().collect();
for (slot, id) in ids.iter().enumerate() {
// SAFETY: the socket is owned by `inflight` and is deleted from the
// poller below before it can be dropped.
let _ = unsafe { poller.add(&inflight[id].socket, Event::readable(slot + 1)) };
}
// Wake no later than the nearest deadline so timeouts fire on time.
let now = Instant::now();
let mut wait = Duration::from_millis(RELAY_POLL_MAX_MS);
for f in inflight.values() {
let remaining = f.deadline.saturating_duration_since(now);
if remaining < wait {
wait = remaining;
}
}
let mut events = Events::new();
let _ = poller.wait(&mut events, Some(wait));
let mut ready: Vec<bool> = vec![false; ids.len()];
for event in events.iter() {
if event.key >= 1 && event.key - 1 < ready.len() {
ready[event.key - 1] = true;
}
}
// Deregister every socket before mutating `inflight`.
for id in &ids {
let _ = poller.delete(&inflight[id].socket);
}
// Deliver ready answers; expire the rest past their deadline.
let now = Instant::now();
for (slot, id) in ids.iter().enumerate() {
if ready[slot] {
let answer = inflight
.get(id)
.and_then(|f| match f.socket.recv(&mut recv_buf) {
Ok(len) => Some(recv_buf[..len].to_vec()),
Err(_) => None,
});
match answer {
Some(bytes) => {
inflight.remove(id);
woke_reply |= deliver_answer(&inbound, *id, Some(bytes));
}
// Spurious readiness with nothing to read: leave it in flight
// to be retried or expired.
None if inflight.get(id).is_some_and(|f| f.deadline <= now) => {
inflight.remove(id);
woke_reply |= deliver_answer(&inbound, *id, None);
}
None => {}
}
} else if inflight.get(id).is_some_and(|f| f.deadline <= now) {
inflight.remove(id);
woke_reply |= deliver_answer(&inbound, *id, None);
}
}
if woke_reply {
reply_wake.wake();
}
}
}
/// Open a non-blocking host UDP socket connected to the upstream resolver and
/// send the query. The single response is collected later by the poll section.
fn start_udp_query(upstream: SocketAddr, query: &[u8]) -> std::io::Result<HostUdpSocket> {
let bind: SocketAddr = if upstream.is_ipv4() {
(Ipv4Addr::UNSPECIFIED, 0).into()
} else {
(std::net::Ipv6Addr::UNSPECIFIED, 0).into()
};
let socket = HostUdpSocket::bind(bind)?;
socket.connect(upstream)?;
socket.set_nonblocking(true)?;
socket.send(query)?;
Ok(socket)
}
/// Resolve a DNS-over-TCP query on a bounded, detached worker thread.
///
/// DNS/TCP is the rare fallback path (truncated/large answers). Rather than
/// build a non-blocking length-prefixed TCP state machine, each query gets its
/// own short-lived worker so a slow TCP resolver never blocks the UDP fast path
/// or the poll loop. The worker count is capped; over the cap the query is
/// answered as a timeout.
fn spawn_tcp_query(
inbound: &SyncSender<DnsResponse>,
reply_wake: &Arc<WakePipe>,
tcp_inflight: &Arc<AtomicUsize>,
query: DnsQuery,
) {
if tcp_inflight.load(Ordering::Relaxed) >= MAX_INFLIGHT_TCP {
virtio_net_log!(
"virtio-net: dropping DNS/TCP query id={} (worker cap reached)",
query.id
);
if deliver_answer(inbound, query.id, None) {
reply_wake.wake();
}
return;
}
tcp_inflight.fetch_add(1, Ordering::Relaxed);
let worker_inbound = inbound.clone();
let worker_wake = reply_wake.clone();
let worker_inflight = tcp_inflight.clone();
let id = query.id;
let spawned = thread::Builder::new()
.name("smolvm-dns-tcp".into())
.spawn(move || {
let upstream = SocketAddr::new(IpAddr::V4(query.upstream), DNS_PORT);
let answer = forward_dns_query_tcp(upstream, &query.query).ok();
if deliver_answer(&worker_inbound, id, answer) {
worker_wake.wake();
}
worker_inflight.fetch_sub(1, Ordering::Relaxed);
});
if spawned.is_err() {
// Could not spawn: undo the reservation and answer as a timeout.
tcp_inflight.fetch_sub(1, Ordering::Relaxed);
if deliver_answer(inbound, id, None) {
reply_wake.wake();
}
}
}
/// Forward one DNS query to the upstream resolver over TCP (length-prefixed, per
/// RFC 1035 §4.2.2) and return the raw response message. Blocking host TCP
/// exchange with a short timeout — runs only on a detached worker, never the
/// poll thread.
fn forward_dns_query_tcp(upstream: SocketAddr, query: &[u8]) -> std::io::Result<Vec<u8>> {
use std::io::{Error, ErrorKind};
let len = u16::try_from(query.len())
.map_err(|_| Error::new(ErrorKind::InvalidInput, "DNS query too large for TCP"))?;
let mut stream = TcpStream::connect_timeout(&upstream, UPSTREAM_TIMEOUT)?;
stream.set_read_timeout(Some(UPSTREAM_TIMEOUT))?;
stream.set_write_timeout(Some(UPSTREAM_TIMEOUT))?;
stream.write_all(&len.to_be_bytes())?;
stream.write_all(query)?;
stream.flush()?;
let mut len_buf = [0u8; 2];
stream.read_exact(&mut len_buf)?;
let resp_len = u16::from_be_bytes(len_buf) as usize;
if resp_len == 0 || resp_len > DNS_MAX_MSG {
return Err(Error::new(
ErrorKind::InvalidData,
"upstream DNS/TCP response length out of range",
));
}
let mut response = vec![0u8; resp_len];
stream.read_exact(&mut response)?;
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicBool;
/// `start_udp_query` sends off a non-blocking connected socket; its single
/// response is collected later without blocking. Exercises the real fn
/// against a loopback echo "resolver" (arbitrary port, so no port-53 dep).
#[test]
fn start_udp_query_sends_and_receives_nonblocking() {
let upstream = HostUdpSocket::bind("127.0.0.1:0").unwrap();
let upstream_addr = upstream.local_addr().unwrap();
let resolver = thread::spawn(move || {
let mut buf = [0u8; 512];
upstream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let (n, peer) = upstream.recv_from(&mut buf).unwrap();
upstream.send_to(&buf[..n], peer).unwrap();
});
let sock = start_udp_query(upstream_addr, b"\x12\x34hello-dns").unwrap();
let deadline = Instant::now() + Duration::from_secs(2);
let mut buf = [0u8; 512];
loop {
match sock.recv(&mut buf) {
Ok(n) => {
assert_eq!(&buf[..n], b"\x12\x34hello-dns");
break;
}
Err(_) if Instant::now() < deadline => {
thread::sleep(Duration::from_millis(5));
}
Err(e) => panic!("no DNS answer: {e}"),
}
}
resolver.join().unwrap();
}
/// End-to-end through the relay thread: a live upstream on port 53 needs
/// privileges, so this proves the *offload contract* — the poll thread only
/// sends/receives channel messages and never blocks — using an unreachable
/// upstream that must resolve to a timeout answer, promptly and off-thread.
#[test]
fn poll_thread_never_blocks_on_dead_resolver() {
let reply_wake = Arc::new(WakePipe::new());
let stop = Arc::new(AtomicBool::new(false));
let stop_flag = stop.clone();
let channels = start_dns_relay(
reply_wake.clone(),
Arc::new(move || stop_flag.load(Ordering::Relaxed)),
);
// 192.0.2.1 is TEST-NET-1 (RFC 5737): guaranteed unroutable, so the
// upstream never answers and the relay must time it out for us.
let started = Instant::now();
channels
.to_relay
.send(DnsQuery {
id: 7,
transport: DnsTransport::Udp,
upstream: Ipv4Addr::new(192, 0, 2, 1),
query: b"\x00\x00query".to_vec(),
})
.unwrap();
channels.relay_thread_wake.wake();
// The send returned immediately (offloaded); this thread is free.
assert!(started.elapsed() < Duration::from_millis(50));
// The answer (a timeout -> None) arrives via the channel, driven by the
// relay thread, within a bit over the 2s upstream timeout.
let resp = channels
.from_relay
.recv_timeout(Duration::from_secs(4))
.expect("relay must always answer, even on timeout");
assert_eq!(resp.id, 7);
assert!(resp.answer.is_none());
stop.store(true, Ordering::Relaxed);
channels.relay_thread_wake.wake();
}
/// The real `forward_dns_query_tcp` resolves against a length-prefixed host
/// TCP resolver (arbitrary loopback port) and returns the raw, unprefixed
/// answer message. This is the code the detached DNS/TCP worker runs.
#[test]
fn forward_dns_query_tcp_round_trips() {
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
// Minimal DNS-over-TCP resolver: read the length-prefixed query, reply
// with a length-prefixed canned answer.
let server = thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut len_buf = [0u8; 2];
sock.read_exact(&mut len_buf).unwrap();
let qlen = u16::from_be_bytes(len_buf) as usize;
let mut q = vec![0u8; qlen];
sock.read_exact(&mut q).unwrap();
assert_eq!(&q, b"tcp-query");
let answer = b"answer-bytes";
sock.write_all(&(answer.len() as u16).to_be_bytes())
.unwrap();
sock.write_all(answer).unwrap();
});
let resp = forward_dns_query_tcp(addr, b"tcp-query").unwrap();
assert_eq!(resp, b"answer-bytes");
server.join().unwrap();
}
}
+566
View File
@@ -0,0 +1,566 @@
//! Outbound egress policy for the virtio-net gateway.
//!
//! TSI enforces `allowed_cidrs` + `--allow-host` inside libkrun's socket-intercept
//! layer; the virtio-net gateway terminates every guest flow itself, so it applies
//! the same allow-list at the point it opens a host connection
//! (`TcpRelayTable::create_tcp_socket`). This mirrors libkrun's `vsock/dns_filter.rs`
//! `EgressPolicy` so both backends behave identically:
//!
//! - static `allowed_cidrs` (IPv4 or IPv6) are always permitted;
//! - `--allow-host` names are matched by the gateway's DNS interception, and the
//! A/AAAA records of allowed answers are *learned* as temporarily-allowed IPs
//! (TTL clamped to [60s, 3600s]) so the follow-up connection passes;
//! - with hosts set but no CIDRs, egress is gated entirely by learned IPs.
//!
//! Disallowed destinations are dropped before any host socket is created. DNS
//! forwarding (gateway-internal) is never gated by this filter.
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::dns;
/// Learned-IP TTL clamp, matching libkrun's DNS filter.
const MIN_LEARNED_TTL: u64 = 60;
const MAX_LEARNED_TTL: u64 = 3600;
/// A parsed CIDR (IPv4 or IPv6) with cheap containment testing.
#[derive(Clone, Copy, Debug)]
enum Cidr {
V4 { network: u32, mask: u32 },
V6 { network: u128, mask: u128 },
}
impl Cidr {
/// Parse `"a.b.c.d"` / `"a.b.c.d/n"` / `"x::y"` / `"x::y/n"`. A bare address
/// gets a full-length prefix. Returns `None` for malformed input or a prefix
/// length beyond the address width.
fn parse(spec: &str) -> Option<Self> {
let (addr, prefix) = match spec.trim().split_once('/') {
Some((addr, prefix)) => (addr, Some(prefix.parse::<u8>().ok()?)),
None => (spec.trim(), None),
};
match addr.parse::<IpAddr>().ok()? {
IpAddr::V4(ip) => {
let prefix = prefix.unwrap_or(32);
if prefix > 32 {
return None;
}
let mask = if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix)
};
Some(Self::V4 {
network: u32::from(ip) & mask,
mask,
})
}
IpAddr::V6(ip) => {
let prefix = prefix.unwrap_or(128);
if prefix > 128 {
return None;
}
let mask = if prefix == 0 {
0
} else {
u128::MAX << (128 - prefix)
};
Some(Self::V6 {
network: u128::from(ip) & mask,
mask,
})
}
}
}
fn contains(&self, ip: IpAddr) -> bool {
match (self, ip) {
(Self::V4 { network, mask }, IpAddr::V4(ip)) => (u32::from(ip) & mask) == *network,
(Self::V6 { network, mask }, IpAddr::V6(ip)) => (u128::from(ip) & mask) == *network,
_ => false,
}
}
}
struct AllowList {
cidrs: Vec<Cidr>,
/// Normalized allow-host names. `None` = no DNS hostname filtering.
allowed_hosts: Option<Vec<String>>,
/// IPs learned from allowed DNS answers → expiry instant.
learned: Mutex<HashMap<IpAddr, Instant>>,
}
/// How much of the platform hard-floor applies, chosen once per policy from the
/// deployment context — NOT a default-on blanket deny. Reaching the host's own
/// LAN from a local VM is legitimate and expected, so the broad internal-subnet
/// floor is reserved for the multi-tenant context where it's actually needed.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum FloorMode {
/// Trusted single-tenant/local override (`SMOLVM_EGRESS_ALLOW_PRIVATE=1`):
/// floor nothing — the guest reaches exactly what the host can.
Off,
/// Local default: deny ONLY the cloud-metadata link-local range
/// (`169.254.0.0/16`, incl. `169.254.169.254`) — never a legitimate
/// destination and the canonical SSRF/credential-theft target, but still
/// protects users running on their own cloud VM. The guest keeps reaching the
/// host's LAN, loopback, etc., so local behavior is reasonable and clear.
MetadataOnly,
/// Multi-tenant/fleet (`SMOLVM_PUBLISH_ADDR` set): the full floor — metadata,
/// host/control internal subnets, loopback, link/unique-local, and the
/// gateway CGNAT range — so a guest can't steal host credentials, pivot to
/// the control plane / worker API, or reach co-resident tenants.
Strict,
}
/// Parse an explicit `SMOLVM_EGRESS_FLOOR` value into a mode. Returns `None`
/// for an absent/unrecognized value so the caller falls back to the inferred
/// default. Pure (no env) so it is unit-testable.
fn parse_floor_override(v: &str) -> Option<FloorMode> {
match v.trim().to_ascii_lowercase().as_str() {
"strict" => Some(FloorMode::Strict),
"metadata" | "metadata-only" | "metadataonly" => Some(FloorMode::MetadataOnly),
"off" | "none" => Some(FloorMode::Off),
_ => None,
}
}
/// Resolve the floor from the deployment context. Read once at policy creation
/// (never per-packet): explicit `SMOLVM_EGRESS_FLOOR` override wins, else the
/// `ALLOW_PRIVATE` opt-out, else fleet ⇒ strict, else the metadata-only default.
fn floor_mode() -> FloorMode {
// Explicit override wins (highest precedence). A multi-tenant node sets
// `SMOLVM_EGRESS_FLOOR=strict` so the floor is fail-closed and never
// silently degrades to metadata-only if `SMOLVM_PUBLISH_ADDR` is missing
// from the environment (a dropped unit override, a new provisioner, or a
// manual launch must NOT quietly expose the host LAN / control plane /
// co-tenants to a guest). `metadata`/`off` allow a deliberate downgrade.
if let Ok(v) = std::env::var("SMOLVM_EGRESS_FLOOR") {
if let Some(mode) = parse_floor_override(&v) {
return mode;
}
}
let allow_private = std::env::var("SMOLVM_EGRESS_ALLOW_PRIVATE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if allow_private {
FloorMode::Off
} else if std::env::var_os("SMOLVM_PUBLISH_ADDR").is_some() {
FloorMode::Strict
} else {
FloorMode::MetadataOnly
}
}
/// The cloud-metadata link-local range (`169.254.0.0/16` / `fe80::/10`) — the
/// one destination floored in every mode except `Off`, including via an
/// IPv4-mapped IPv6 address so it can't be smuggled past.
fn is_link_local(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.is_link_local(),
IpAddr::V6(v6) => {
(v6.segments()[0] & 0xffc0) == 0xfe80
|| v6.to_ipv4_mapped().is_some_and(|v4| v4.is_link_local())
}
}
}
/// The full multi-tenant IPv4 floor (metadata + internal + loopback + CGNAT).
fn is_reserved_v4(v4: Ipv4Addr) -> bool {
v4.is_loopback() // 127.0.0.0/8
|| v4.is_link_local() // 169.254.0.0/16 — incl. 169.254.169.254 (cloud metadata)
|| v4.is_private() // 10/8, 172.16/12, 192.168/16 — host/control internal subnet
|| v4.is_unspecified()
|| v4.is_broadcast()
// 100.64.0.0/10 (CGNAT) — the gateway's own guest/gateway addresses live here.
|| matches!(v4.octets(), [100, b, ..] if (64..=127).contains(&b))
}
/// Whether `ip` is floored under `mode` — the single hard-floor predicate. Also
/// defeats DNS-rebinding (a learned IP in a floored range is still denied).
fn is_floored(ip: IpAddr, mode: FloorMode) -> bool {
match mode {
FloorMode::Off => false,
FloorMode::MetadataOnly => is_link_local(ip),
FloorMode::Strict => match ip {
IpAddr::V4(v4) => is_reserved_v4(v4),
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 unique-local
|| v6.to_ipv4_mapped().is_some_and(is_reserved_v4)
}
},
}
}
/// Outbound egress policy enforced by the gateway before opening a host
/// connection. `unrestricted` allows everything EXCEPT the platform hard-floor
/// (`is_floored`), whose scope is set once by `FloorMode`.
#[derive(Clone)]
pub struct EgressPolicy {
inner: Option<Arc<AllowList>>,
/// Hard-floor scope, resolved once from the deployment context at creation.
floor: FloorMode,
}
impl EgressPolicy {
/// No allow-list — every destination is allowed EXCEPT the platform hard-floor.
pub fn unrestricted() -> Self {
Self {
inner: None,
floor: floor_mode(),
}
}
/// Build from `VmResources::allowed_cidrs` and the `--allow-host` list.
/// Both `None` → unrestricted. Otherwise a policy is in force: only the
/// listed CIDRs and IPs learned from allowed DNS answers may be reached
/// (an empty CIDR list with no hosts denies everything).
pub fn new(allowed_cidrs: Option<&[String]>, allowed_hosts: Option<&[String]>) -> Self {
if allowed_cidrs.is_none() && allowed_hosts.is_none() {
return Self::unrestricted();
}
let cidrs = allowed_cidrs
.unwrap_or(&[])
.iter()
.filter_map(|spec| {
let parsed = Cidr::parse(spec);
if parsed.is_none() {
tracing::warn!(cidr = %spec, "ignoring unparseable egress CIDR");
}
parsed
})
.collect();
let allowed_hosts = allowed_hosts.map(|hosts| {
hosts
.iter()
.filter_map(|h| dns::normalize_hostname(h))
.collect()
});
Self {
inner: Some(Arc::new(AllowList {
cidrs,
allowed_hosts,
learned: Mutex::new(HashMap::new()),
})),
floor: floor_mode(),
}
}
/// Convenience for the CIDR-only case.
pub fn from_allowed_cidrs(allowed: Option<&[String]>) -> Self {
Self::new(allowed, None)
}
/// Whether any policy is in force (false = allow-all).
pub fn is_restricted(&self) -> bool {
self.inner.is_some()
}
/// Whether the gateway should DNS-filter queries (an allow-host list is set).
pub fn dns_filter_active(&self) -> bool {
self.inner
.as_ref()
.is_some_and(|list| list.allowed_hosts.is_some())
}
/// Whether a DNS query for `hostname` should be forwarded upstream. With no
/// allow-host list, all queries pass (exact + subdomain match otherwise).
pub fn hostname_allowed(&self, hostname: &str) -> bool {
match &self.inner {
None => true,
Some(list) => match &list.allowed_hosts {
None => true,
Some(hosts) => dns::hostname_allowed(hostname, hosts),
},
}
}
/// Whether an outbound connection to `ip` (v4 or v6) is permitted.
pub fn allows(&self, ip: IpAddr) -> bool {
// Platform hard-floor: deny per the resolved FloorMode (metadata-only
// locally, full floor under fleet mode) regardless of the allow-list.
if is_floored(ip, self.floor) {
return false;
}
match &self.inner {
None => true,
Some(list) => {
if list.cidrs.iter().any(|cidr| cidr.contains(ip)) {
return true;
}
list.learned
.lock()
.map(|learned| {
learned
.get(&ip)
.is_some_and(|expires_at| *expires_at > Instant::now())
})
.unwrap_or(false)
}
}
}
/// Convenience for IPv4 call sites.
pub fn allows_v4(&self, ip: Ipv4Addr) -> bool {
self.allows(IpAddr::V4(ip))
}
/// Convenience for IPv6 call sites.
pub fn allows_v6(&self, ip: Ipv6Addr) -> bool {
self.allows(IpAddr::V6(ip))
}
/// Learn the A/AAAA records of an allowed DNS answer as temporarily-allowed
/// IPs. TTLs are clamped to [60s, 3600s]; expired entries are pruned. No-op
/// when unrestricted.
pub fn learn_ip_records(&self, records: &[(IpAddr, u32)]) {
let Some(list) = &self.inner else {
return;
};
let Ok(mut learned) = list.learned.lock() else {
return;
};
let now = Instant::now();
learned.retain(|_, expires_at| *expires_at > now);
for (ip, ttl) in records {
let ttl = u64::from(*ttl).clamp(MIN_LEARNED_TTL, MAX_LEARNED_TTL);
let expires_at = now + Duration::from_secs(ttl);
learned
.entry(*ip)
.and_modify(|existing| *existing = (*existing).max(expires_at))
.or_insert(expires_at);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn floor_override_parsing() {
assert_eq!(parse_floor_override("strict"), Some(FloorMode::Strict));
assert_eq!(parse_floor_override(" STRICT "), Some(FloorMode::Strict));
assert_eq!(
parse_floor_override("metadata"),
Some(FloorMode::MetadataOnly)
);
assert_eq!(
parse_floor_override("metadata-only"),
Some(FloorMode::MetadataOnly)
);
assert_eq!(parse_floor_override("off"), Some(FloorMode::Off));
assert_eq!(parse_floor_override("none"), Some(FloorMode::Off));
// Unrecognized falls through to the inferred default (None).
assert_eq!(parse_floor_override(""), None);
assert_eq!(parse_floor_override("yes"), None);
}
#[test]
fn unrestricted_allows_everything() {
let policy = EgressPolicy::unrestricted();
assert!(!policy.is_restricted());
assert!(policy.allows_v4(Ipv4Addr::new(8, 8, 8, 8)));
assert!(policy.allows_v6("2001:4860:4860::8888".parse().unwrap()));
assert!(policy.hostname_allowed("anything.test"));
assert!(!policy.dns_filter_active());
}
#[test]
fn empty_allowlist_denies_all() {
let policy = EgressPolicy::from_allowed_cidrs(Some(&[]));
assert!(policy.is_restricted());
assert!(!policy.allows_v4(Ipv4Addr::new(1, 1, 1, 1)));
assert!(!policy.allows_v6("2606:4700::1111".parse().unwrap()));
}
#[test]
fn cidr_membership_v4() {
// Public CIDRs only — private ranges are denied by the hard-floor below.
let policy = EgressPolicy::new(Some(&["8.8.8.0/24".into(), "1.1.1.1".into()]), None);
assert!(policy.allows_v4(Ipv4Addr::new(8, 8, 8, 7)));
assert!(policy.allows_v4(Ipv4Addr::new(1, 1, 1, 1)));
assert!(!policy.allows_v4(Ipv4Addr::new(1, 1, 1, 2)));
assert!(!policy.allows_v4(Ipv4Addr::new(9, 0, 0, 1)));
}
#[test]
fn unrestricted_local_floors_only_metadata() {
// Local default (no fleet mode): only the cloud-metadata link-local range
// is denied; the host's LAN, loopback, and CGNAT stay reachable — so a
// local VM behaves predictably.
let p = EgressPolicy::unrestricted();
assert!(!p.allows_v4(Ipv4Addr::new(169, 254, 169, 254))); // metadata: denied
assert!(p.allows_v4(Ipv4Addr::new(10, 0, 0, 4))); // LAN: reachable
assert!(p.allows_v4(Ipv4Addr::new(127, 0, 0, 1))); // loopback: reachable
assert!(p.allows_v4(Ipv4Addr::new(192, 168, 1, 1)));
assert!(p.allows_v4(Ipv4Addr::new(172, 16, 0, 1)));
assert!(p.allows_v4(Ipv4Addr::new(100, 96, 0, 1))); // CGNAT: reachable
assert!(p.allows_v4(Ipv4Addr::new(1, 1, 1, 1))); // public: reachable
}
#[test]
fn metadata_floor_overrides_allowlist_and_learned_ips() {
// The metadata range can't be re-opened by allow-listing it...
let p = EgressPolicy::new(Some(&["169.254.0.0/16".into()]), None);
assert!(!p.allows_v4(Ipv4Addr::new(169, 254, 169, 254)));
// ...nor via DNS-rebinding: a learned metadata IP stays denied.
let p2 = EgressPolicy::new(None, Some(&["evil.test".into()]));
let meta = IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254));
p2.learn_ip_records(&[(meta, 300)]);
assert!(!p2.allows(meta));
// But a LAN IP in the allow-list IS reachable locally.
let p3 = EgressPolicy::new(Some(&["10.0.0.0/8".into()]), None);
assert!(p3.allows_v4(Ipv4Addr::new(10, 0, 0, 4)));
}
#[test]
fn metadata_floor_blocks_mapped_and_v6_link_local() {
let p = EgressPolicy::unrestricted(); // MetadataOnly default
// mapped metadata + v6 link-local are denied...
assert!(!p.allows_v6("::ffff:169.254.169.254".parse().unwrap()));
assert!(!p.allows_v6("fe80::1".parse().unwrap()));
// ...but v6 ULA (the LAN equivalent) and global unicast are reachable.
assert!(p.allows_v6("fc00::1".parse().unwrap()));
assert!(p.allows_v6("2606:4700::1111".parse().unwrap()));
}
#[test]
fn cidr_membership_v6() {
let policy =
EgressPolicy::new(Some(&["2606:4700::/32".into(), "2001:db8::1".into()]), None);
assert!(policy.allows_v6("2606:4700::1111".parse().unwrap()));
assert!(policy.allows_v6("2606:4700:ffff::1".parse().unwrap()));
assert!(policy.allows_v6("2001:db8::1".parse().unwrap()));
assert!(!policy.allows_v6("2001:db8::2".parse().unwrap()));
assert!(!policy.allows_v6("2607::1".parse().unwrap()));
// A v6 CIDR never matches a v4 address and vice versa.
assert!(!policy.allows_v4(Ipv4Addr::new(1, 1, 1, 1)));
}
#[test]
fn allow_host_gates_dns_and_learns_ips() {
let policy = EgressPolicy::new(None, Some(&["example.com".into()]));
assert!(policy.dns_filter_active());
assert!(policy.hostname_allowed("example.com"));
assert!(policy.hostname_allowed("www.example.com"));
assert!(!policy.hostname_allowed("evil.test"));
let v4 = IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34));
let v6: IpAddr = "2606:2800:21f:cb07:6820:80da:af6b:8b2c"
.parse::<Ipv6Addr>()
.unwrap()
.into();
assert!(!policy.allows(v4));
assert!(!policy.allows(v6));
policy.learn_ip_records(&[(v4, 300), (v6, 600)]);
assert!(policy.allows(v4));
assert!(policy.allows(v6));
}
#[test]
fn learned_ip_respects_min_ttl() {
// A tiny TTL is clamped up to MIN_LEARNED_TTL, so the entry is live now.
let policy = EgressPolicy::new(None, Some(&["example.com".into()]));
let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
policy.learn_ip_records(&[(ip, 1)]);
assert!(policy.allows(ip));
}
#[test]
fn unparseable_cidr_is_skipped_not_panicked() {
let policy = EgressPolicy::new(Some(&["nonsense".into(), "1.1.1.1".into()]), None);
assert!(policy.allows_v4(Ipv4Addr::new(1, 1, 1, 1)));
assert!(!policy.allows_v4(Ipv4Addr::new(2, 2, 2, 2)));
}
#[test]
fn v6_prefix_bounds_checked() {
assert!(Cidr::parse("2001:db8::/129").is_none());
assert!(Cidr::parse("1.2.3.4/33").is_none());
assert!(Cidr::parse("::/0").is_some());
assert!(Cidr::parse("0.0.0.0/0").is_some());
}
fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(a, b, c, d))
}
#[test]
fn floor_off_blocks_nothing() {
// Trusted override: even metadata/loopback/private pass.
for ip in [
v4(8, 8, 8, 8),
v4(169, 254, 169, 254),
v4(192, 168, 1, 5),
v4(127, 0, 0, 1),
] {
assert!(
!is_floored(ip, FloorMode::Off),
"{ip} should not be floored when Off"
);
}
}
#[test]
fn floor_metadata_only_blocks_just_link_local() {
// Local default: only the cloud-metadata link-local range is denied; the
// host's LAN, loopback and the public internet stay reachable.
assert!(is_floored(v4(169, 254, 169, 254), FloorMode::MetadataOnly));
assert!(is_floored(v4(169, 254, 0, 1), FloorMode::MetadataOnly));
for ip in [
v4(8, 8, 8, 8),
v4(192, 168, 1, 5),
v4(10, 0, 0, 7),
v4(127, 0, 0, 1),
v4(172, 16, 5, 5),
] {
assert!(
!is_floored(ip, FloorMode::MetadataOnly),
"{ip} should be reachable locally"
);
}
// IPv4-mapped metadata must not slip past.
assert!(is_floored(
"::ffff:169.254.169.254".parse().unwrap(),
FloorMode::MetadataOnly
));
}
#[test]
fn floor_strict_blocks_internal_and_metadata() {
// Fleet/multi-tenant: the full floor.
for ip in [
v4(169, 254, 169, 254), // metadata
v4(192, 168, 1, 5), // RFC1918
v4(10, 0, 0, 7),
v4(172, 16, 5, 5),
v4(127, 0, 0, 1), // loopback
v4(100, 64, 0, 1), // CGNAT gateway range
] {
assert!(
is_floored(ip, FloorMode::Strict),
"{ip} should be floored under Strict"
);
}
// Public + just-outside-CGNAT stay reachable.
assert!(!is_floored(v4(8, 8, 8, 8), FloorMode::Strict));
assert!(!is_floored(v4(100, 128, 0, 1), FloorMode::Strict));
// IPv6 internal ranges + mapped private.
assert!(is_floored("fe80::1".parse().unwrap(), FloorMode::Strict));
assert!(is_floored("fc00::1".parse().unwrap(), FloorMode::Strict));
assert!(is_floored(
"::ffff:10.0.0.1".parse().unwrap(),
FloorMode::Strict
));
assert!(!is_floored(
"2606:4700::1111".parse().unwrap(),
FloorMode::Strict
));
}
}
+321
View File
@@ -0,0 +1,321 @@
//! libkrun unix-stream framing for the virtio-net backend.
//!
//! Context
//! =======
//!
//! libkrun's `krun_add_net_unixstream()` interface does not hand us raw virtio
//! rings or a tap device. Instead, it exposes a Unix stream file descriptor
//! carrying Ethernet frames in a tiny framing protocol:
//!
//! ```text
//! [4-byte big-endian frame length][raw ethernet frame bytes]
//! ```
//!
//! Important details:
//! - the payload is a raw Ethernet frame
//! - there is no virtio-net header on this stream
//! - libkrun adds/removes its internal virtio-net header itself
//! - partial reads and partial writes are normal stream-socket behavior and
//! must be handled explicitly
//!
//! So this module is not the TCP/IP stack. It is just the bridge between:
//! - libkrun's Unix stream transport
//! - the in-process frame queues consumed by the host smoltcp runtime
//!
//! Data flow:
//!
//! ```text
//! guest -> libkrun -> UnixStream -> run_reader() -> guest_to_host queue
//! host <- libkrun <- UnixStream <- run_writer() <- host_to_guest queue
//! ```
//!
//! In the broader runtime, this module sits here:
//!
//! ```text
//! libkrun unixstream
//! <-> FrameStreamBridge
//! <-> NetworkFrameQueues
//! <-> VirtioNetworkDevice / smoltcp poll loop
//! ```
use crate::queues::NetworkFrameQueues;
use socket2::Socket;
use std::io::{self, Read, Write};
use std::net::Shutdown;
use std::sync::Arc;
use std::thread::{self, JoinHandle};
const FRAME_HEADER_LEN: usize = 4;
const SOCKET_SENDBUF_BYTES: usize = 16 * 1024 * 1024;
const MAX_FRAME_LEN: usize = 64 * 1024;
/// Running libkrun unix-stream bridge for one virtio NIC.
///
/// The bridge owns:
/// - the shared AF_UNIX stream socket (used to trigger shutdown via `shutdown`)
/// - a reader thread for guest->host frames
/// - a writer thread for host->guest frames
///
/// The transport is an AF_UNIX stream socket, wrapped in [`socket2::Socket`] so
/// the same code serves Unix hosts and Windows (10 1809+ has native AF_UNIX).
/// On Unix the launcher hands us one end of a `socketpair`; on Windows it hands
/// us the socket it `accept`ed from libkrun on a per-VM path.
///
/// The socket is shared via `Arc` and the reader/writer threads use it through
/// `&Socket` (socket2 implements `Read`/`Write` for `&Socket`), rather than
/// `try_clone`-ing it three ways: duplicating an AF_UNIX socket handle is
/// unreliable on Windows (a clone may not observe the peer's writes), which
/// silently stalled all guest traffic. Concurrent read + write on one socket is
/// safe — the directions are independent.
pub struct FrameStreamBridge {
socket: Arc<Socket>,
queues: Arc<NetworkFrameQueues>,
reader_handle: Option<JoinHandle<()>>,
writer_handle: Option<JoinHandle<()>>,
}
/// Start the libkrun stream reader and writer threads for one virtio NIC.
///
/// Takes ownership of an already-connected AF_UNIX stream socket to libkrun.
pub fn start_frame_stream_bridge(
stream: Socket,
queues: Arc<NetworkFrameQueues>,
) -> io::Result<FrameStreamBridge> {
set_socket_send_buffer(&stream);
let socket = Arc::new(stream);
let reader_socket = socket.clone();
let reader_handle = thread::Builder::new()
.name("smolvm-net-reader".into())
.spawn({
let queues = queues.clone();
move || run_reader(reader_socket, queues)
})?;
let writer_socket = socket.clone();
let writer_queues = queues.clone();
let writer_handle = thread::Builder::new()
.name("smolvm-net-writer".into())
.spawn(move || run_writer(writer_socket, writer_queues))?;
Ok(FrameStreamBridge {
socket,
queues,
reader_handle: Some(reader_handle),
writer_handle: Some(writer_handle),
})
}
impl Drop for FrameStreamBridge {
/// Request shutdown and join the reader/writer workers.
///
/// `shutdown(Shutdown::Both)` is the important part here: it forces any
/// blocking read/write on the shared socket to wake up and fail, which lets
/// the threads notice shutdown and return.
fn drop(&mut self) {
self.queues.begin_shutdown();
let _ = self.socket.shutdown(Shutdown::Both);
if let Some(handle) = self.reader_handle.take() {
let _ = handle.join();
}
if let Some(handle) = self.writer_handle.take() {
let _ = handle.join();
}
}
}
fn run_reader(reader: Arc<Socket>, queues: Arc<NetworkFrameQueues>) {
// Reader thread:
// libkrun -> Unix stream -> guest_to_host queue -> smoltcp poll loop
let mut sock: &Socket = &reader;
loop {
match read_frame(&mut sock) {
Ok(frame) => {
if queues.guest_to_host.push(frame).is_ok() {
queues.guest_wake.wake();
} else {
tracing::warn!("dropping guest ethernet frame because the host queue is full");
}
}
Err(err) => {
queues.begin_shutdown();
tracing::debug!(error = %err, "virtio-net reader thread stopped");
return;
}
}
}
}
fn run_writer(writer: Arc<Socket>, queues: Arc<NetworkFrameQueues>) {
// Writer thread:
// smoltcp / host runtime -> host_to_guest queue -> Unix stream -> libkrun
let mut sock: &Socket = &writer;
loop {
if queues.is_shutting_down() && queues.host_to_guest.is_empty() {
return;
}
match queues.host_wake.wait(None) {
Ok(true) => queues.host_wake.drain(),
Ok(false) => continue,
Err(err) => {
queues.begin_shutdown();
tracing::debug!(error = %err, "virtio-net writer wake pipe failed");
return;
}
}
while let Some(frame) = queues.host_to_guest.pop() {
if let Err(err) = write_frame(&mut sock, &frame) {
queues.begin_shutdown();
tracing::debug!(error = %err, "virtio-net writer thread stopped");
return;
}
}
}
}
/// Read one raw Ethernet frame using libkrun's 4-byte big-endian length prefix.
///
/// Wire format:
///
/// ```text
/// 0 3 4 ...
/// +----------------+----------------------+
/// | frame_len (BE) | ethernet frame bytes |
/// +----------------+----------------------+
/// ```
///
/// `read_exact` is intentional:
/// - Unix stream sockets are byte streams, not message sockets
/// - one `read` may return only part of the header or part of the frame
/// - the bridge must keep reading until the whole logical frame arrives
///
/// Outcome:
/// - returns the next complete raw Ethernet frame
/// - rejects zero-length or implausibly large frames as protocol errors
pub(crate) fn read_frame<R: Read>(reader: &mut R) -> io::Result<Vec<u8>> {
let mut header = [0u8; FRAME_HEADER_LEN];
reader.read_exact(&mut header)?;
let frame_len = u32::from_be_bytes(header) as usize;
if frame_len == 0 || frame_len > MAX_FRAME_LEN {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid ethernet frame length: {frame_len}"),
));
}
let mut frame = vec![0u8; frame_len];
reader.read_exact(&mut frame)?;
Ok(frame)
}
/// Write one raw Ethernet frame using libkrun's 4-byte big-endian length prefix.
///
/// This is the inverse of [`read_frame`]:
///
/// ```text
/// write 4-byte BE length
/// write raw frame bytes
/// flush stream
/// ```
///
/// `write_all` is used instead of a single `write` because stream sockets may
/// accept only part of the buffer. The caller should not need to reason about
/// partial-write state; this helper completes the logical frame write or fails.
pub(crate) fn write_frame<W: Write>(writer: &mut W, frame: &[u8]) -> io::Result<()> {
if frame.is_empty() || frame.len() > MAX_FRAME_LEN {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid ethernet frame length: {}", frame.len()),
));
}
let header = (frame.len() as u32).to_be_bytes();
write_all(writer, &header)?;
write_all(writer, frame)?;
writer.flush()
}
fn write_all<W: Write>(writer: &mut W, mut buf: &[u8]) -> io::Result<()> {
// This is the stream-socket equivalent of "keep sending until the whole
// logical message is written". `Write::write` may legally write fewer bytes
// than requested even on success.
while !buf.is_empty() {
let written = writer.write(buf)?;
if written == 0 {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"short write while sending ethernet frame",
));
}
buf = &buf[written..];
}
Ok(())
}
/// Increase the socket's send-buffer size (SO_SNDBUF). This stream carries
/// Ethernet frames toward libkrun; a large send buffer absorbs bursts so we can
/// queue several frames before libkrun catches up. The OS may clamp the request,
/// and the option is non-critical, so a failure is logged and ignored.
/// `socket2` issues the platform `setsockopt` so this works on Unix and Windows.
fn set_socket_send_buffer(stream: &Socket) {
if let Err(err) = stream.set_send_buffer_size(SOCKET_SENDBUF_BYTES) {
tracing::warn!(
error = %err,
"failed to increase SO_SNDBUF for virtio-net unixstream"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
struct PartialWriter {
written: Vec<u8>,
chunk_size: usize,
}
impl Write for PartialWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let take = buf.len().min(self.chunk_size);
self.written.extend_from_slice(&buf[..take]);
Ok(take)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn write_frame_handles_partial_writes() {
let mut writer = PartialWriter {
written: Vec::new(),
chunk_size: 3,
};
write_frame(&mut writer, &[1, 2, 3, 4, 5, 6]).unwrap();
assert_eq!(writer.written[..4], [0, 0, 0, 6]);
assert_eq!(writer.written[4..], [1, 2, 3, 4, 5, 6]);
}
#[test]
fn read_frame_decodes_length_prefix() {
let mut input = std::io::Cursor::new(vec![0, 0, 0, 3, 7, 8, 9]);
assert_eq!(read_frame(&mut input).unwrap(), vec![7, 8, 9]);
}
#[cfg(unix)]
#[test]
fn unix_stream_round_trip_multiple_frames() {
use std::os::unix::net::UnixStream;
let (mut left, mut right) = UnixStream::pair().unwrap();
write_frame(&mut left, &[1, 2, 3]).unwrap();
write_frame(&mut left, &[4, 5]).unwrap();
assert_eq!(read_frame(&mut right).unwrap(), vec![1, 2, 3]);
assert_eq!(read_frame(&mut right).unwrap(), vec![4, 5]);
}
}
+595
View File
@@ -0,0 +1,595 @@
//! ICMP echo (ping) relay for the virtio-net backend.
//!
//! Context
//! =======
//!
//! TCP, UDP, and DNS all reach the outside world through the gateway, but a
//! guest `ping` had nowhere to go: nothing on the host answered ICMP, so echo
//! requests were silently dropped (the same gap TSI has — its syscall hijack
//! only covers stream/datagram sockets, never ICMP). This module closes it by
//! relaying guest echo requests to real host ICMP datagram sockets and feeding
//! the real replies back, so `ping` measures actual reachability.
//!
//! Model — a userspace NAT for echo, built entirely on smoltcp sockets so no
//! Ethernet/IP frames are assembled by hand:
//!
//! ```text
//! guest echo request to D
//! -> normal smoltcp ingress; a raw::Socket(ICMP) captures the full IP packet
//! -> poll loop parses (guest, D, ident, seq, data); egress check
//! -> channels it to the relay thread
//! -> relay thread: one connected host ICMP socket per (guest, D, ident); send
//! -> reply: host socket readable -> channel back -> relay_wake
//! -> poll loop builds an echo *reply* IP packet sourced from D and sends it
//! out the raw::Socket; smoltcp frames it and delivers it to the guest
//! ```
//!
//! Sourcing the reply from `D` (not the gateway) is the whole point: `ping`
//! rejects replies whose source isn't the address it targeted. smoltcp's
//! `icmp::Socket` can't do that (its TX path picks the interface's own source
//! address), so the relay sends a fully-addressed packet out a `raw::Socket`
//! instead — smoltcp still owns the Ethernet header and neighbor resolution.
//!
//! Echo is connectionless, so lifetime is NAT-style idle expiry: the relay
//! drops host sockets idle for [`FLOW_IDLE_TIMEOUT`]. Loss under pressure (full
//! channels / tables) is acceptable ICMP semantics — logged, never blocking.
use crate::egress::EgressPolicy;
use crate::queues::WakePipe;
use crate::virtio_net_log;
use polling::{Event, Events};
use smoltcp::phy::ChecksumCapabilities;
use smoltcp::wire::{
Icmpv4Packet, Icmpv4Repr, Icmpv6Packet, Icmpv6Repr, IpProtocol, Ipv4Packet, Ipv4Repr,
Ipv6Packet, Ipv6Repr,
};
use socket2::{Domain, Protocol, SockAddr, Socket as HostSocket, Type};
use std::collections::HashMap;
use std::mem::MaybeUninit;
use std::net::{IpAddr, SocketAddr};
use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
/// Max in-flight echo messages per direction before drops (ICMP may drop).
const CHANNEL_CAPACITY: usize = 256;
/// Max concurrent (guest, destination, ident) flows with live host sockets.
const MAX_FLOWS: usize = 256;
/// Largest ICMP message (type byte through payload) we relay; the guest's MTU
/// bounds requests anyway and replies mirror them.
const MAX_ICMP_BYTES: usize = 1500;
/// Default IP hop limit for relayed echo replies.
const REPLY_HOP_LIMIT: u8 = 64;
/// ICMPv4 echo-request / echo-reply type bytes.
const ICMPV4_ECHO_REQUEST: u8 = 8;
const ICMPV4_ECHO_REPLY: u8 = 0;
/// ICMPv6 echo-request / echo-reply type bytes.
const ICMPV6_ECHO_REQUEST: u8 = 128;
const ICMPV6_ECHO_REPLY: u8 = 129;
/// Drop a flow's host socket after this much inactivity.
const FLOW_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
/// Relay thread poll ceiling, so shutdown and expiry are noticed promptly.
const RELAY_POLL_MAX_MS: i32 = 1000;
/// One relayed echo message, in either direction.
///
/// On egress it is the guest's decoded echo request; on ingress it is the host
/// reply with the guest's original `ident` restored (the host kernel rewrites
/// the on-wire identifier to its own port, so the relay stamps it back).
pub struct IcmpEcho {
/// Guest address (echo source on egress, reply target on ingress).
pub guest: IpAddr,
/// External destination the guest pinged (and replies come from).
pub destination: IpAddr,
/// ICMP echo identifier the guest chose.
pub ident: u16,
/// ICMP echo sequence number.
pub seq: u16,
/// Echo payload, mirrored unchanged between request and reply.
pub data: Vec<u8>,
}
/// Channel pair connecting the poll loop and the relay thread.
pub struct IcmpRelayChannels {
/// Poll loop -> relay thread.
pub to_relay: SyncSender<IcmpEcho>,
/// Relay thread -> poll loop.
pub from_relay: Receiver<IcmpEcho>,
/// Wakes the relay thread after `to_relay` sends.
pub relay_thread_wake: WakePipe,
}
/// Start the ICMP relay thread. Returns the poll-loop-side channel endpoints.
///
/// `reply_wake` is the smoltcp poll loop's existing relay wake pipe — pulsed
/// whenever a reply is queued so the loop wakes to deliver it to the guest.
/// The thread exits when `shutdown` reports true (checked at least once per
/// [`RELAY_POLL_MAX_MS`]).
pub fn start_icmp_relay(
reply_wake: Arc<WakePipe>,
shutdown: Arc<dyn Fn() -> bool + Send + Sync>,
) -> IcmpRelayChannels {
let (to_relay_tx, to_relay_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
let (from_relay_tx, from_relay_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
let relay_thread_wake = WakePipe::new();
let thread_wake = relay_thread_wake.clone();
let _ = thread::Builder::new()
.name("smolvm-icmp-relay".into())
.spawn(move || {
run_icmp_relay(
to_relay_rx,
from_relay_tx,
thread_wake,
reply_wake,
shutdown,
);
});
IcmpRelayChannels {
to_relay: to_relay_tx,
from_relay: from_relay_rx,
relay_thread_wake,
}
}
/// Relay-thread state for one (guest, destination, ident) echo flow.
struct IcmpFlow {
socket: HostSocket,
guest: IpAddr,
destination: IpAddr,
ident: u16,
last_active: Instant,
}
fn run_icmp_relay(
outbound: Receiver<IcmpEcho>,
inbound: SyncSender<IcmpEcho>,
wake: WakePipe,
reply_wake: Arc<WakePipe>,
shutdown: Arc<dyn Fn() -> bool + Send + Sync>,
) {
let mut flows: HashMap<(IpAddr, IpAddr, u16), IcmpFlow> = HashMap::new();
let mut recv_buf = [MaybeUninit::<u8>::uninit(); MAX_ICMP_BYTES];
// The host ICMP socket may be denied (Linux `ping_group_range`, no
// privileges); log that once rather than on every dropped ping.
let mut warned_socket_error = false;
loop {
if shutdown() {
return;
}
// Outbound: echo requests handed over by the poll loop.
loop {
match outbound.try_recv() {
Ok(echo) => {
let key = (echo.guest, echo.destination, echo.ident);
if !flows.contains_key(&key) {
if flows.len() >= MAX_FLOWS {
virtio_net_log!(
"virtio-net: dropping ICMP echo {} -> {} (flow table full)",
echo.guest,
echo.destination
);
continue;
}
match create_flow_socket(echo.destination) {
Ok(socket) => {
flows.insert(
key,
IcmpFlow {
socket,
guest: echo.guest,
destination: echo.destination,
ident: echo.ident,
last_active: Instant::now(),
},
);
}
Err(err) => {
if !warned_socket_error {
virtio_net_log!(
"virtio-net: cannot open host ICMP socket for {} (ping disabled): {}",
echo.destination,
err
);
warned_socket_error = true;
}
continue;
}
}
}
let flow = flows.get_mut(&key).expect("flow inserted above");
flow.last_active = Instant::now();
// Best-effort send; ICMP loss is allowed.
let request = echo_request_bytes(echo.destination, echo.seq, &echo.data);
let _ = flow.socket.send(&request);
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => return,
}
}
// Inbound: poll all flow sockets for replies. The wake's poller blocks
// on "wake OR any flow socket readable" in a single call; the wake is a
// notify (no key), and each flow socket is registered at key `slot + 1`.
let poller = wake.poller();
let keys: Vec<(IpAddr, IpAddr, u16)> = flows.keys().copied().collect();
for (slot, key) in keys.iter().enumerate() {
// SAFETY: the socket is owned by `flows` and is deleted from the
// poller below before the next iteration may drop it.
let _ = unsafe { poller.add(&flows[key].socket, Event::readable(slot + 1)) };
}
let mut events = Events::new();
let _ = poller.wait(
&mut events,
Some(Duration::from_millis(RELAY_POLL_MAX_MS as u64)),
);
// A pending notify is consumed by `wait`; nothing else to drain.
let mut ready: Vec<bool> = vec![false; keys.len()];
for event in events.iter() {
if event.key >= 1 && event.key - 1 < ready.len() {
ready[event.key - 1] = true;
}
}
// Deregister sockets before the next rebuild so a closed flow's socket
// is never left registered on the poller.
for key in &keys {
let _ = poller.delete(&flows[key].socket);
}
let mut woke_reply = false;
for (slot, key) in keys.iter().enumerate() {
if !ready[slot] {
continue;
}
let Some(flow) = flows.get_mut(key) else {
continue;
};
// Drain everything ready on this socket. WouldBlock ends the drain;
// errors (an ICMP error surfaced on the connected socket) just mean
// this flow is quiet — idle expiry reaps it.
while let Ok(len) = flow.socket.recv(&mut recv_buf) {
// SAFETY: `recv` reports `len` initialized leading bytes.
let bytes =
unsafe { &*(&recv_buf[..len] as *const [MaybeUninit<u8>] as *const [u8]) };
let Some((seq, data)) = parse_echo_reply(flow.destination, bytes) else {
continue;
};
flow.last_active = Instant::now();
let reply = IcmpEcho {
guest: flow.guest,
destination: flow.destination,
ident: flow.ident,
seq,
data,
};
match inbound.try_send(reply) {
Ok(()) => woke_reply = true,
Err(TrySendError::Full(_)) => {
virtio_net_log!(
"virtio-net: dropping ICMP reply for {} (inbound queue full)",
flow.guest
);
}
Err(TrySendError::Disconnected(_)) => return,
}
}
}
if woke_reply {
reply_wake.wake();
}
// NAT-style idle expiry.
let now = Instant::now();
flows.retain(|_, flow| now.duration_since(flow.last_active) < FLOW_IDLE_TIMEOUT);
}
}
/// Open a non-blocking host ICMP datagram socket connected to `destination`.
///
/// Uses unprivileged ICMP datagram sockets (`SOCK_DGRAM`/`IPPROTO_ICMP[V6]`),
/// the same mechanism `ping` uses on Linux (gated by `net.ipv4.ping_group_range`)
/// and macOS. `connect` pins the peer so the kernel filters stray ICMP from
/// other hosts — each flow is its own NAT pinhole.
fn create_flow_socket(destination: IpAddr) -> std::io::Result<HostSocket> {
let (domain, protocol) = match destination {
IpAddr::V4(_) => (Domain::IPV4, Protocol::ICMPV4),
IpAddr::V6(_) => (Domain::IPV6, Protocol::ICMPV6),
};
let socket = HostSocket::new(domain, Type::DGRAM, Some(protocol))?;
// ICMP has no ports; the kernel ignores the port on connect.
socket.connect(&SockAddr::from(SocketAddr::new(destination, 0)))?;
socket.set_nonblocking(true)?;
Ok(socket)
}
/// Build the raw ICMP echo-request bytes (type byte through payload) sent to a
/// host ping socket. The kernel overwrites the identifier with the socket's
/// port and fills the checksum, so both are left zero here.
fn echo_request_bytes(destination: IpAddr, seq: u16, data: &[u8]) -> Vec<u8> {
let type_byte = if destination.is_ipv6() {
ICMPV6_ECHO_REQUEST
} else {
ICMPV4_ECHO_REQUEST
};
let mut buf = Vec::with_capacity(8 + data.len());
buf.push(type_byte);
buf.push(0); // code
buf.extend_from_slice(&[0, 0]); // checksum (kernel fills)
buf.extend_from_slice(&[0, 0]); // identifier (kernel overwrites)
buf.extend_from_slice(&seq.to_be_bytes());
buf.extend_from_slice(data);
buf
}
/// Decode a host ping socket's reply (an ICMP message, no IP header) into the
/// echo `(seq, data)`. The identifier is dropped — the relay restores the
/// guest's own. Returns `None` for anything that isn't an echo reply (e.g. an
/// ICMP error the kernel surfaced on the socket).
fn parse_echo_reply(destination: IpAddr, bytes: &[u8]) -> Option<(u16, Vec<u8>)> {
if bytes.len() < 8 {
return None;
}
let expected = if destination.is_ipv6() {
ICMPV6_ECHO_REPLY
} else {
ICMPV4_ECHO_REPLY
};
if bytes[0] != expected {
return None;
}
let seq = u16::from_be_bytes([bytes[6], bytes[7]]);
Some((seq, bytes[8..].to_vec()))
}
/// Whether the gateway should relay a guest echo to this destination. Echo
/// obeys the same egress policy as TCP/UDP (static CIDRs + DNS-learned IPs).
pub fn should_relay_icmp(destination: IpAddr, egress: &EgressPolicy) -> bool {
egress.allows(destination)
}
/// Decode a guest IPv4 ICMP echo *request* captured off the raw socket (a full
/// IP packet) into a relayable [`IcmpEcho`]. Non-echo ICMP returns `None`.
pub fn parse_guest_echo_v4(ip_packet: &[u8]) -> Option<IcmpEcho> {
let ipv4 = Ipv4Packet::new_checked(ip_packet).ok()?;
let icmp = Icmpv4Packet::new_checked(ipv4.payload()).ok()?;
match Icmpv4Repr::parse(&icmp, &ChecksumCapabilities::ignored()).ok()? {
Icmpv4Repr::EchoRequest {
ident,
seq_no,
data,
} => Some(IcmpEcho {
guest: IpAddr::V4(ipv4.src_addr()),
destination: IpAddr::V4(ipv4.dst_addr()),
ident,
seq: seq_no,
data: data.to_vec(),
}),
_ => None,
}
}
/// IPv6 counterpart of [`parse_guest_echo_v4`].
pub fn parse_guest_echo_v6(ip_packet: &[u8]) -> Option<IcmpEcho> {
let ipv6 = Ipv6Packet::new_checked(ip_packet).ok()?;
let src = ipv6.src_addr();
let dst = ipv6.dst_addr();
let icmp = Icmpv6Packet::new_checked(ipv6.payload()).ok()?;
match Icmpv6Repr::parse(&src, &dst, &icmp, &ChecksumCapabilities::ignored()).ok()? {
Icmpv6Repr::EchoRequest {
ident,
seq_no,
data,
} => Some(IcmpEcho {
guest: IpAddr::V6(src),
destination: IpAddr::V6(dst),
ident,
seq: seq_no,
data: data.to_vec(),
}),
_ => None,
}
}
/// Build the IPv4 echo-*reply* packet (IP header + ICMP) to hand to the raw
/// socket. Sourced from `reply.destination` so the guest's `ping` accepts it.
pub fn build_echo_reply_v4(reply: &IcmpEcho) -> Option<Vec<u8>> {
let (IpAddr::V4(src), IpAddr::V4(dst)) = (reply.destination, reply.guest) else {
return None;
};
let icmp = Icmpv4Repr::EchoReply {
ident: reply.ident,
seq_no: reply.seq,
data: &reply.data,
};
let ip = Ipv4Repr {
src_addr: src,
dst_addr: dst,
next_header: IpProtocol::Icmp,
payload_len: icmp.buffer_len(),
hop_limit: REPLY_HOP_LIMIT,
};
let mut buf = vec![0u8; ip.buffer_len() + icmp.buffer_len()];
let checksum = ChecksumCapabilities::default();
ip.emit(&mut Ipv4Packet::new_unchecked(&mut buf[..]), &checksum);
icmp.emit(
&mut Icmpv4Packet::new_unchecked(&mut buf[ip.buffer_len()..]),
&checksum,
);
Some(buf)
}
/// IPv6 counterpart of [`build_echo_reply_v4`].
pub fn build_echo_reply_v6(reply: &IcmpEcho) -> Option<Vec<u8>> {
let (IpAddr::V6(src), IpAddr::V6(dst)) = (reply.destination, reply.guest) else {
return None;
};
let icmp = Icmpv6Repr::EchoReply {
ident: reply.ident,
seq_no: reply.seq,
data: &reply.data,
};
let ip = Ipv6Repr {
src_addr: src,
dst_addr: dst,
next_header: IpProtocol::Icmpv6,
payload_len: icmp.buffer_len(),
hop_limit: REPLY_HOP_LIMIT,
};
let mut buf = vec![0u8; ip.buffer_len() + icmp.buffer_len()];
ip.emit(&mut Ipv6Packet::new_unchecked(&mut buf[..]));
icmp.emit(
&src,
&dst,
&mut Icmpv6Packet::new_unchecked(&mut buf[ip.buffer_len()..]),
&ChecksumCapabilities::default(),
);
Some(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::sync::atomic::{AtomicBool, Ordering};
#[test]
fn relay_thread_round_trips_an_echo_via_loopback() {
// Unprivileged ICMP sockets are gated by `net.ipv4.ping_group_range`;
// skip rather than fail where they aren't permitted (e.g. locked-down CI).
if create_flow_socket(IpAddr::V4(Ipv4Addr::LOCALHOST)).is_err() {
eprintln!("skipping: unprivileged ICMP datagram sockets not permitted here");
return;
}
let reply_wake = Arc::new(WakePipe::new());
let stop = Arc::new(AtomicBool::new(false));
let stop_flag = stop.clone();
let channels = start_icmp_relay(
reply_wake.clone(),
Arc::new(move || stop_flag.load(Ordering::Relaxed)),
);
let guest = IpAddr::V4(Ipv4Addr::new(100, 96, 0, 2));
let destination = IpAddr::V4(Ipv4Addr::LOCALHOST);
channels
.to_relay
.send(IcmpEcho {
guest,
destination,
ident: 0x4321,
seq: 9,
data: b"relaytest".to_vec(),
})
.unwrap();
channels.relay_thread_wake.wake();
// The loopback kernel answers the echo; the relay restores our ident.
let reply = channels
.from_relay
.recv_timeout(Duration::from_secs(3))
.expect("expected an echo reply from loopback");
assert_eq!(reply.guest, guest);
assert_eq!(reply.destination, destination);
assert_eq!(reply.ident, 0x4321);
assert_eq!(reply.seq, 9);
assert_eq!(reply.data, b"relaytest");
stop.store(true, Ordering::Relaxed);
channels.relay_thread_wake.wake();
}
#[test]
fn echo_request_carries_seq_and_payload() {
let bytes = echo_request_bytes(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 0x0042, b"ping");
assert_eq!(bytes[0], ICMPV4_ECHO_REQUEST);
assert_eq!(&bytes[6..8], &[0x00, 0x42]); // sequence
assert_eq!(&bytes[8..], b"ping");
}
#[test]
fn echo_request_uses_v6_type_for_v6_destination() {
let bytes = echo_request_bytes(IpAddr::V6(Ipv6Addr::LOCALHOST), 1, b"");
assert_eq!(bytes[0], ICMPV6_ECHO_REQUEST);
}
#[test]
fn parse_reply_extracts_seq_and_data_ignoring_ident() {
// type=0, code=0, cksum, ident=0xdead (host port), seq=0x0042, data.
let reply = [0u8, 0, 0, 0, 0xde, 0xad, 0x00, 0x42, b'h', b'i'];
let (seq, data) = parse_echo_reply(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), &reply).unwrap();
assert_eq!(seq, 0x0042);
assert_eq!(data, b"hi");
}
#[test]
fn parse_reply_rejects_non_echo_reply() {
// type=3 (destination unreachable) is not an echo reply.
let err = [3u8, 0, 0, 0, 0, 0, 0, 0];
assert!(parse_echo_reply(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), &err).is_none());
}
#[test]
fn round_trips_guest_echo_through_reply_v4() {
// Build a guest echo request IP packet, parse it, build the reply, and
// confirm the reply mirrors ident/seq/data and swaps src/dst.
let request = IcmpEcho {
guest: IpAddr::V4(Ipv4Addr::new(100, 96, 0, 2)),
destination: IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
ident: 0x1234,
seq: 7,
data: b"abcdefgh".to_vec(),
};
// Emit a request packet the way a guest would, then parse it back.
let icmp = Icmpv4Repr::EchoRequest {
ident: request.ident,
seq_no: request.seq,
data: &request.data,
};
let ip = Ipv4Repr {
src_addr: Ipv4Addr::new(100, 96, 0, 2),
dst_addr: Ipv4Addr::new(1, 1, 1, 1),
next_header: IpProtocol::Icmp,
payload_len: icmp.buffer_len(),
hop_limit: 64,
};
let mut pkt = vec![0u8; ip.buffer_len() + icmp.buffer_len()];
let cksum = ChecksumCapabilities::default();
ip.emit(&mut Ipv4Packet::new_unchecked(&mut pkt[..]), &cksum);
icmp.emit(
&mut Icmpv4Packet::new_unchecked(&mut pkt[ip.buffer_len()..]),
&cksum,
);
let parsed = parse_guest_echo_v4(&pkt).unwrap();
assert_eq!(parsed.destination, request.destination);
assert_eq!(parsed.ident, request.ident);
assert_eq!(parsed.seq, request.seq);
assert_eq!(parsed.data, request.data);
let reply = build_echo_reply_v4(&parsed).unwrap();
let reply_ip = Ipv4Packet::new_checked(&reply).unwrap();
// Reply is sourced from the pinged destination, addressed to the guest.
assert_eq!(reply_ip.src_addr(), Ipv4Addr::new(1, 1, 1, 1));
assert_eq!(reply_ip.dst_addr(), Ipv4Addr::new(100, 96, 0, 2));
let reply_icmp = Icmpv4Packet::new_checked(reply_ip.payload()).unwrap();
match Icmpv4Repr::parse(&reply_icmp, &cksum).unwrap() {
Icmpv4Repr::EchoReply {
ident,
seq_no,
data,
} => {
assert_eq!(ident, request.ident);
assert_eq!(seq_no, request.seq);
assert_eq!(data, &request.data[..]);
}
other => panic!("expected echo reply, got {other:?}"),
}
}
}
+369
View File
@@ -0,0 +1,369 @@
//! Host-side virtio-net runtime.
//!
//! Context
//! =======
//!
//! This module is the host-side half of the new networking path:
//!
//! ```text
//! guest app
//! -> guest kernel TCP/IP stack
//! -> virtio-net device
//! -> libkrun unix-stream bridge
//! -> smolvm FrameStreamBridge
//! -> shared frame queues
//! -> smoltcp gateway/runtime
//! -> host sockets / DNS forwarding / TCP relay
//! -> external network
//! ```
//!
//! Main runtime components:
//!
//! ```text
//! VirtioNetworkRuntime
//! ├─ FrameStreamBridge
//! │ ├─ reader thread
//! │ └─ writer thread
//! ├─ TcpPortListeners
//! │ └─ one non-blocking accept loop per `-p HOST:GUEST`
//! ├─ Arc<NetworkFrameQueues>
//! │ ├─ guest_to_host
//! │ ├─ host_to_guest
//! │ ├─ guest_wake
//! │ ├─ host_wake
//! │ └─ relay_wake
//! └─ smolvm-net-poll thread
//! ├─ VirtioNetworkDevice
//! ├─ smoltcp Interface
//! ├─ SocketSet
//! └─ TcpRelayTable
//! ```
//!
//! Component roles:
//! - `FrameStreamBridge`: translates libkrun's Unix-stream frame protocol into
//! queue operations
//! - `TcpPortListeners`: accepts host TCP connections for published ports
//! and hands them to the poll loop
//! - `NetworkFrameQueues`: handoff boundary between threads
//! - `VirtioNetworkDevice`: adapts those queues to smoltcp's `phy::Device`
//! - poll thread: acts as the guest-visible gateway and protocol dispatcher
//! - `TcpRelayTable`: maps guest TCP flows onto host-side relay threads
//!
//! This runtime is responsible for:
//! - exchanging raw Ethernet frames with libkrun
//! - presenting a gateway endpoint to the guest
//! - handling DNS through a gateway UDP socket and host UDP forwarding
//! - relaying guest TCP connections to host `TcpStream`s
//! - accepting published host TCP ports and forwarding them into guest TCP
//! connections
pub mod device;
pub mod dns;
pub mod dns_relay;
pub mod egress;
// The libkrun frame bridge speaks over an AF_UNIX stream socket, available on
// both Unix and Windows (10 1809+), so the whole stack is cross-platform.
pub mod frame_stream;
pub mod icmp_relay;
pub mod queues;
pub mod stack;
pub mod tcp_listeners;
pub mod tcp_relay;
pub mod udp_relay;
pub use egress::EgressPolicy;
use socket2::Socket;
use std::fmt;
use std::io;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::thread::JoinHandle;
use std::time::SystemTime;
use frame_stream::{start_frame_stream_bridge, FrameStreamBridge};
use queues::NetworkFrameQueues;
use queues::DEFAULT_FRAME_QUEUE_CAPACITY;
use stack::{start_network_stack, VirtioPollConfig};
use tcp_listeners::create_tcp_channel;
use tcp_listeners::TcpPortListeners;
/// Default upstream DNS resolver used by the gateway runtime.
pub const DEFAULT_DNS_ADDR: IpAddr = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
/// Host->guest published TCP port mapping serviced by the virtio gateway.
///
/// This stays crate-local so the launchers can translate CLI/data-layer port
/// mappings into the gateway runtime without pulling the gateway logic back
/// into the main `smolvm` crate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PortMapping {
/// Port bound on the host loopback interface.
pub host: u16,
/// Port exposed inside the guest.
pub guest: u16,
}
impl PortMapping {
/// Create a new published port mapping.
pub const fn new(host: u16, guest: u16) -> Self {
Self { host, guest }
}
}
/// Static guest network configuration for the virtio-net MVP.
///
/// This struct describes the two endpoints of the single virtual Ethernet link:
/// - the guest NIC (`guest_*`)
/// - the host-side gateway implemented by smolvm (`gateway_*`)
///
/// The link is dual-stack: a /30 IPv4 point-to-point pair and a /64 ULA IPv6
/// pair (`fd53:4d00::/64` — `53:4d` = "SM", matching the MAC OUI scheme).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GuestNetworkConfig {
/// Guest IPv4 address.
pub guest_ip: Ipv4Addr,
/// Gateway IPv4 address.
pub gateway_ip: Ipv4Addr,
/// Prefix length.
pub prefix_len: u8,
/// Guest IPv6 (ULA) address.
pub guest_ip6: Ipv6Addr,
/// Gateway IPv6 (ULA) address.
pub gateway_ip6: Ipv6Addr,
/// IPv6 prefix length.
pub prefix_len6: u8,
/// Guest MAC address.
pub guest_mac: [u8; 6],
/// Gateway MAC address.
pub gateway_mac: [u8; 6],
/// DNS server address presented to the guest.
///
/// For virtio-net this is the gateway's own address (`gateway_ip`): the
/// guest sends DNS to the gateway, which forwards upstream to
/// [`Self::upstream_dns`].
pub dns_server: Ipv4Addr,
/// Upstream resolver the gateway forwards guest DNS queries to.
///
/// Defaults to [`DEFAULT_DNS_ADDR`]; the launcher overrides it when the
/// caller passes `--dns <ip>` so a VM on a network that blocks the default
/// resolver can still resolve names.
pub upstream_dns: Ipv4Addr,
}
impl GuestNetworkConfig {
/// Default Phase 1 guest network configuration.
pub const fn default() -> Self {
Self {
guest_ip: Ipv4Addr::new(100, 96, 0, 2),
gateway_ip: Ipv4Addr::new(100, 96, 0, 1),
prefix_len: 30,
guest_ip6: Ipv6Addr::new(0xfd53, 0x4d00, 0, 0, 0, 0, 0, 2),
gateway_ip6: Ipv6Addr::new(0xfd53, 0x4d00, 0, 0, 0, 0, 0, 1),
prefix_len6: 64,
guest_mac: [0x02, 0x53, 0x4d, 0x00, 0x00, 0x02],
gateway_mac: [0x02, 0x53, 0x4d, 0x00, 0x00, 0x01],
dns_server: Ipv4Addr::new(100, 96, 0, 1),
upstream_dns: match DEFAULT_DNS_ADDR {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => Ipv4Addr::new(1, 1, 1, 1),
},
}
}
}
fn format_network_log_line(timestamp: SystemTime, message: &str) -> String {
format!(
"[{}]: {}",
humantime::format_rfc3339_seconds(timestamp),
message
)
}
pub(crate) fn emit_network_log_line(message: fmt::Arguments<'_>) {
eprintln!(
"{}",
format_network_log_line(SystemTime::now(), &message.to_string())
);
}
macro_rules! virtio_net_log {
($($arg:tt)*) => {
$crate::emit_network_log_line(format_args!($($arg)*))
};
}
pub(crate) use virtio_net_log;
/// Running host-side virtio-net runtime for one guest NIC.
///
/// Ownership model:
/// - one runtime instance corresponds to one guest virtio NIC
/// - it owns the queue set shared by the worker threads
/// - it owns the libkrun Unix-stream bridge threads
/// - it owns the published-port listener threads
/// - it owns the smoltcp poll thread
///
/// Dropping the runtime is the shutdown signal. `Drop` marks the shared queues
/// as shutting down, wakes blocked workers, and joins the poll thread.
pub struct VirtioNetworkRuntime {
queues: std::sync::Arc<NetworkFrameQueues>,
_frame_bridge: FrameStreamBridge,
published_ports: Option<TcpPortListeners>,
poll_handle: Option<JoinHandle<()>>,
}
/// Start the host-side virtio-net runtime for one guest NIC.
///
/// Inputs:
/// - `host_fd`: the host-side Unix stream fd that libkrun will use for this
/// guest NIC. The launcher eventually gets this from the libkrun
/// `krun_add_net_unixstream()` setup path.
/// - `guest_network`: the static guest/gateway addressing and MAC plan for this
/// NIC.
/// - `published_ports`: host->guest TCP port mappings that should be serviced
/// directly by the virtio runtime instead of TSI.
///
/// High-level flow:
///
/// ```text
/// start_virtio_network()
/// -> create shared frame queues + wake pipes
/// -> start frame reader/writer threads on the Unix stream
/// -> start host TcpListeners for published ports
/// -> start the smoltcp poll thread
/// -> return a handle that owns the whole runtime
/// ```
///
/// Expanded startup picture:
///
/// ```text
/// host_fd from libkrun
/// -> FrameStreamBridge(host_fd)
/// -> reader thread
/// -> writer thread
/// -> TcpPortListeners
/// -> accept host TcpStreams
/// -> send them to the poll loop over a bounded channel
/// -> NetworkFrameQueues
/// -> start_network_stack(...)
/// -> poll thread owns smoltcp Interface + sockets
/// -> VirtioNetworkRuntime returned to launcher
/// ```
///
/// Outcome:
/// - guest->host Ethernet frames start flowing into the queues
/// - host->guest Ethernet frames emitted by smoltcp are written back to libkrun
/// - published host TCP connections can be forwarded toward guest listeners
/// - the poll loop starts acting as the guest-visible gateway
///
/// Cross-platform: the libkrun frame bridge speaks over an AF_UNIX stream
/// socket, available on Unix and Windows (10 1809+). The caller supplies the
/// already-connected host-side socket (Unix: one end of a `socketpair`;
/// Windows: the socket `accept`ed from libkrun on the per-VM path).
pub fn start_virtio_network(
host_stream: Socket,
guest_network: GuestNetworkConfig,
published_ports: &[PortMapping],
egress: EgressPolicy,
) -> io::Result<VirtioNetworkRuntime> {
virtio_net_log!(
"virtio-net: starting runtime guest_ip={} gateway_ip={} dns_server={}",
guest_network.guest_ip,
guest_network.gateway_ip,
guest_network.dns_server
);
let queues = NetworkFrameQueues::shared(DEFAULT_FRAME_QUEUE_CAPACITY);
let frame_bridge = start_frame_stream_bridge(host_stream, queues.clone())?;
// tcp_sender sends the accepted TCP connections to the channel
// tcp_receiver receives the accepted TCP connections via the channel, and let it be consumed in poll thread.
let (tcp_sender, tcp_receiver) = create_tcp_channel();
let tcp_listeners = if published_ports.is_empty() {
None
} else {
Some(TcpPortListeners::start(
published_ports,
tcp_sender,
queues.relay_wake.clone(),
)?)
};
let poll_handle = start_network_stack(
queues.clone(),
VirtioPollConfig {
gateway_mac: guest_network.gateway_mac,
guest_mac: guest_network.guest_mac,
gateway_ipv4: guest_network.gateway_ip,
guest_ipv4: guest_network.guest_ip,
gateway_ipv6: guest_network.gateway_ip6,
guest_ipv6: guest_network.guest_ip6,
prefix_len6: guest_network.prefix_len6,
upstream_dns: guest_network.upstream_dns,
mtu: 1500,
},
tcp_listeners.as_ref().map(|_| tcp_receiver),
egress,
)?;
Ok(VirtioNetworkRuntime {
queues,
_frame_bridge: frame_bridge,
published_ports: tcp_listeners,
poll_handle: Some(poll_handle),
})
}
impl VirtioNetworkRuntime {
/// Cumulative guest-outbound (egress) bytes on this NIC since boot, at the
/// ethernet-frame level. The launcher polls this to surface per-machine
/// egress to the node API for billing. TSI VMs have no virtio runtime, so
/// egress is unavailable for them (a documented metering gap).
pub fn egress_bytes(&self) -> u64 {
self.queues.egress_bytes()
}
/// A cheap, cloneable read handle to this NIC's egress counter, so the
/// launcher can spawn a thread that periodically flushes the value to the
/// VM's runtime dir for the node API to read (the runtime is not `Clone`).
pub fn egress_counter(&self) -> std::sync::Arc<std::sync::atomic::AtomicU64> {
self.queues.egress_counter()
}
/// Take ownership of the runtime and block until the libkrun stream closes
/// (the frame reader marks the queues shutting down on EOF), then drop it for
/// a graceful shutdown. Used on Windows, where the runtime is built on the
/// `accept` thread: that thread parks here so the runtime — and its worker
/// threads — live for the whole VM lifetime instead of being dropped at the
/// end of the `accept` callback.
pub fn block_until_shutdown(self) {
while !self.queues.is_shutting_down() {
std::thread::sleep(std::time::Duration::from_millis(200));
}
// `self` drops here, joining the worker threads.
}
}
impl Drop for VirtioNetworkRuntime {
/// Shut down the worker threads in a bounded, cooperative way.
///
/// The queue shutdown flag wakes the frame bridge and smoltcp poll loop so
/// they can exit on their own. We only explicitly join the poll thread
/// here because the frame bridge joins its own threads in its own `Drop`.
fn drop(&mut self) {
self.queues.begin_shutdown();
self.published_ports = None;
if let Some(handle) = self.poll_handle.take() {
let _ = handle.join();
}
}
}
#[cfg(test)]
mod tests {
use super::format_network_log_line;
use std::time::UNIX_EPOCH;
#[test]
fn formats_timestamped_network_log_prefix() {
let line = format_network_log_line(UNIX_EPOCH, "virtio-net: smoke test");
assert_eq!(line, "[1970-01-01T00:00:00Z]: virtio-net: smoke test");
}
}
+280
View File
@@ -0,0 +1,280 @@
//! Shared queues and wake notifications for the virtio-net backend.
//!
//! Context
//! =======
//!
//! The host-side virtio runtime has several independently blocked workers:
//! - the Unix-stream reader thread
//! - the Unix-stream writer thread
//! - the smoltcp poll loop
//! - TCP relay threads
//!
//! They need two kinds of coordination:
//! 1. lock-free frame handoff between threads
//! 2. a way to wake a thread that is blocked waiting on socket readiness
//!
//! This module provides both:
//! - `ArrayQueue<Vec<u8>>` for frame ownership transfer
//! - `WakePipe` as a tiny readiness primitive built on a cross-platform poller
//! (`polling`, which maps to epoll/kqueue/IOCP). Its `notify()` is the
//! cross-thread wakeup, replacing the old self-pipe.
//!
//! Data flow:
//!
//! ```text
//! guest_to_host queue : reader thread -> smoltcp poll loop
//! host_to_guest queue : smoltcp runtime -> writer thread
//!
//! guest_wake: reader thread / shutdown -> smoltcp poll loop
//! host_wake : smoltcp runtime / shutdown -> Unix-stream writer
//! relay_wake: TCP relay threads / shutdown -> smoltcp poll loop
//! ```
//!
//! Thread interaction view:
//!
//! ```text
//! FrameStream reader thread
//! -> guest_to_host.push(frame)
//! -> guest_wake.wake()
//!
//! smolvm-net-poll thread
//! -> guest_to_host.pop()
//! -> host_to_guest.push(frame)
//! -> host_wake.wake()
//! -> relay_wake.wait()/drain()
//!
//! FrameStream writer thread
//! -> host_wake.wait()
//! -> host_to_guest.pop()
//!
//! TCP relay thread
//! -> to_smoltcp.send(payload)
//! -> relay_wake.wake()
//! ```
use crossbeam_queue::ArrayQueue;
use polling::{Events, Poller};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
/// Default queue capacity for guest/host ethernet frames.
pub const DEFAULT_FRAME_QUEUE_CAPACITY: usize = 1024;
/// Shared queues and wake handles for the host-side virtio-net runtime.
///
/// One `NetworkFrameQueues` is shared across all helper threads for a single
/// guest NIC.
///
/// A useful mental model is:
///
/// ```text
/// queues = ownership transfer for frame bytes
/// wakes = "go look at the queue now"
/// shutdown= sticky flag + wake all blocked waiters
/// ```
pub struct NetworkFrameQueues {
/// Raw ethernet frames emitted by the guest and waiting for smoltcp.
pub guest_to_host: ArrayQueue<Vec<u8>>,
/// Raw ethernet frames emitted by smoltcp and waiting for libkrun.
pub host_to_guest: ArrayQueue<Vec<u8>>,
/// Wake the smoltcp poll loop when a guest frame arrives.
///
/// `guest_wake` and `relay_wake` deliberately share one underlying poller:
/// the smoltcp loop blocks on that single poller and either wake unblocks
/// it. The loop re-runs its whole pipeline on every wakeup, so it does not
/// need to know which side fired.
pub guest_wake: WakePipe,
/// Wake the libkrun writer thread when a host frame is ready.
pub host_wake: WakePipe,
/// Wake the smoltcp poll loop when a TCP relay thread has new data.
pub relay_wake: WakePipe,
/// Signals that the helper process should shut down.
shutting_down: AtomicBool,
/// Cumulative guest-outbound (egress) bytes for this NIC since boot, at the
/// ethernet-frame level — every guest frame accepted into the stack is
/// counted. Used for per-machine egress billing/telemetry. Held behind an
/// `Arc` so the runtime owner can hand a cheap read handle to a flush thread
/// without exposing the rest of the queue set.
egress_bytes: Arc<AtomicU64>,
}
impl NetworkFrameQueues {
/// Create a new shared queue set wrapped in `Arc`.
pub fn shared(capacity: usize) -> Arc<Self> {
// The smoltcp poll loop waits on a single poller; both the guest-frame
// wake and the relay wake notify it, so they share one poller instance.
let poll_loop = WakePipe::new();
let relay_wake = poll_loop.share();
Arc::new(Self {
guest_to_host: ArrayQueue::new(capacity),
host_to_guest: ArrayQueue::new(capacity),
guest_wake: poll_loop,
host_wake: WakePipe::new(),
relay_wake,
shutting_down: AtomicBool::new(false),
egress_bytes: Arc::new(AtomicU64::new(0)),
})
}
/// Add `n` guest-outbound bytes to the egress counter. Relaxed ordering is
/// fine: the counter is a monotonic statistic, not a synchronization point.
pub fn add_egress_bytes(&self, n: u64) {
self.egress_bytes.fetch_add(n, Ordering::Relaxed);
}
/// Cumulative guest-outbound bytes for this NIC since boot.
pub fn egress_bytes(&self) -> u64 {
self.egress_bytes.load(Ordering::Relaxed)
}
/// A cheap, cloneable read handle to the egress counter, for a flush thread
/// owned by the launcher (the runtime itself is not `Clone`).
pub fn egress_counter(&self) -> Arc<AtomicU64> {
self.egress_bytes.clone()
}
/// Mark the runtime as shutting down and wake all waiters.
///
/// The wakes are part of shutdown correctness. Without them, a thread
/// blocked waiting on socket readiness could sleep indefinitely even though
/// the shutdown flag was already set.
pub fn begin_shutdown(&self) {
self.shutting_down.store(true, Ordering::SeqCst);
self.guest_wake.wake();
self.host_wake.wake();
self.relay_wake.wake();
}
/// Whether shutdown has been requested.
pub fn is_shutting_down(&self) -> bool {
self.shutting_down.load(Ordering::SeqCst)
}
}
/// Cross-thread wake notification built on a cross-platform poller.
///
/// The pattern is:
/// - one thread blocks waiting on the poller (optionally alongside registered
/// socket sources)
/// - another thread calls [`WakePipe::wake`] to unblock it
/// - the waiter resumes; the wake is auto-cleared by the next `wait`
///
/// Why a poller rather than a self-pipe: `polling::Poller::notify()` is a
/// portable cross-thread wakeup that works on Windows (where a `pipe(2)` is not
/// pollable by the IOCP/wepoll backend) as well as Unix. The same poller can
/// have socket sources registered on it, which lets the ICMP/UDP relay loops
/// wait on "wake OR any flow socket readable" in a single blocking call.
#[derive(Clone, Debug)]
pub struct WakePipe {
poller: Arc<Poller>,
}
impl WakePipe {
/// Create a wake notification with its own poller.
pub fn new() -> Self {
Self {
poller: Arc::new(Poller::new().expect("create poller for wake notification")),
}
}
/// Create another handle that shares this wake's underlying poller.
///
/// Two `WakePipe`s built this way notify the same waiter: useful when one
/// loop must wake on either of two logical events (the smoltcp loop wakes on
/// guest frames or relay data).
pub fn share(&self) -> Self {
Self {
poller: self.poller.clone(),
}
}
/// The underlying poller, so a caller can register socket sources on it and
/// block on "this wake OR a socket" in a single `wait`.
pub fn poller(&self) -> &Arc<Poller> {
&self.poller
}
/// Signal the waiting side.
///
/// Multiple wakes coalesce: until the waiter next blocks, repeated notifies
/// collapse into a single "there is pending wake state".
pub fn wake(&self) {
// A failed notify only means the waiter will rely on its poll timeout;
// it is never fatal.
let _ = self.poller.notify();
}
/// Drain pending wake state.
///
/// With a poller-backed waker the notification is consumed by `wait`
/// itself, so this is a no-op kept for API symmetry with the old self-pipe.
pub fn drain(&self) {}
/// Wait until woken or the timeout elapses.
///
/// Returns `Ok(true)` if the wait ended because of a wake (a `notify` or a
/// registered source becoming ready), `Ok(false)` if the timeout elapsed.
///
/// A `notify`-driven wakeup reports no events (the poller consumes the
/// notification internally), so a pure wake is detected as either a
/// non-empty event set or an early return: the wait unblocked before the
/// requested deadline.
pub fn wait(&self, timeout: Option<Duration>) -> std::io::Result<bool> {
let mut events = Events::new();
let start = std::time::Instant::now();
let count = self.poller.wait(&mut events, timeout)?;
if count > 0 {
return Ok(true);
}
match timeout {
// Without a deadline, the only way `wait` returns is a wake.
None => Ok(true),
// With a deadline, an early return means a `notify` woke us; a
// return at/after the deadline is a genuine timeout.
Some(timeout) => Ok(start.elapsed() < timeout),
}
}
}
impl Default for WakePipe {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wake_pipe_round_trip() {
let pipe = WakePipe::new();
pipe.wake();
assert!(pipe.wait(Some(Duration::from_millis(10))).unwrap());
pipe.drain();
assert!(!pipe.wait(Some(Duration::from_millis(1))).unwrap());
}
#[test]
fn shared_wake_notifies_same_waiter() {
let a = WakePipe::new();
let b = a.share();
// Waking the shared handle unblocks a waiter on the original.
b.wake();
assert!(a.wait(Some(Duration::from_millis(10))).unwrap());
a.drain();
assert!(!a.wait(Some(Duration::from_millis(1))).unwrap());
}
#[test]
fn queues_are_fifo() {
let queues = NetworkFrameQueues::shared(4);
queues.guest_to_host.push(vec![1, 2, 3]).unwrap();
queues.guest_to_host.push(vec![4, 5, 6]).unwrap();
assert_eq!(queues.guest_to_host.pop(), Some(vec![1, 2, 3]));
assert_eq!(queues.guest_to_host.pop(), Some(vec![4, 5, 6]));
assert_eq!(queues.guest_to_host.pop(), None);
}
}
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
//! Host-side TCP listeners for published virtio-net ports.
//!
//! Context
//! =======
//!
//! This module is the host-facing half of `-p HOST:GUEST` for the virtio-net
//! backend.
//!
//! The outbound virtio path already handles guest-initiated TCP:
//!
//! ```text
//! guest TCP connect -> smoltcp socket -> host TcpStream -> remote server
//! ```
//!
//! Published ports invert the initiator:
//!
//! ```text
//! host client -> host TcpListener -> accepted TcpStream
//! -> smoltcp creates gateway-side TCP connection to guest_ip:GUEST
//! -> relay thread bridges the accepted host socket to the guest flow
//! ```
//!
//! High-level flow:
//!
//! ```text
//! host client connects to 127.0.0.1:HOST (or [::1]:HOST)
//! -> TcpPortListeners accepts TcpStream
//! -> AcceptedTcpConnection sent over a bounded channel
//! -> relay_wake wakes the smoltcp poll loop
//! -> poll loop creates a guest-facing TCP socket to guest_ip:GUEST
//! -> once Established, tcp_relay uses the accepted host TcpStream directly
//! ```
use crate::queues::WakePipe;
use crate::PortMapping;
use std::io;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(25);
/// Maximum number of accepted published sockets queued for the poll loop.
pub const DEFAULT_PUBLISH_QUEUE_CAPACITY: usize = 64;
/// Accepted host TCP connection waiting for the smoltcp poll loop.
pub struct AcceptedTcpConnection {
/// Connected host-side socket returned by `accept(2)`.
pub stream: TcpStream,
/// Host port that accepted the connection.
pub host_port: u16,
/// Guest port the connection should be forwarded to.
pub guest_port: u16,
/// Remote peer that connected to the published port.
pub peer_addr: SocketAddr,
}
/// Running published-port listener set for one guest NIC.
pub struct TcpPortListeners {
shutdown: Arc<AtomicBool>,
handles: Vec<JoinHandle<()>>,
}
impl TcpPortListeners {
/// Start one non-blocking listener thread per published port.
pub fn start(
port_mappings: &[PortMapping],
tcp_sender: SyncSender<AcceptedTcpConnection>,
publish_wake: WakePipe,
) -> io::Result<Self> {
let shutdown = Arc::new(AtomicBool::new(false));
let mut handles = Vec::with_capacity(port_mappings.len());
// Published ports bind loopback by default. `SMOLVM_PUBLISH_ADDR`
// widens that for fleet nodes whose ingress proxy connects from
// another host (the control plane): `0.0.0.0` (or any address) makes
// the cross-host hop possible — pair it with a firewall on the port
// range, since whatever can reach the address can reach the port.
let publish_addr: Ipv4Addr = std::env::var("SMOLVM_PUBLISH_ADDR")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(Ipv4Addr::LOCALHOST);
let publish_v6 = if publish_addr == Ipv4Addr::UNSPECIFIED {
Ipv6Addr::UNSPECIFIED
} else {
Ipv6Addr::LOCALHOST
};
for mapping in port_mappings {
// The IPv4 listener is required; the IPv6 one is best-effort so
// hosts without IPv6 still publish normally.
let listener = match TcpListener::bind((publish_addr, mapping.host)) {
Ok(listener) => listener,
Err(err) => {
shutdown_all(&shutdown, &mut handles);
return Err(err);
}
};
let listener_v6 = match TcpListener::bind((publish_v6, mapping.host)) {
Ok(listener) => Some(listener),
Err(err) => {
tracing::debug!(
host_port = mapping.host,
error = %err,
"skipping IPv6 listener for published port"
);
None
}
};
for listener in std::iter::once(listener).chain(listener_v6) {
if let Err(err) = listener.set_nonblocking(true) {
shutdown_all(&shutdown, &mut handles);
return Err(err);
}
let tcp_sender = tcp_sender.clone();
let publish_wake = publish_wake.clone();
let shutdown_flag = shutdown.clone();
let host_port = mapping.host;
let guest_port = mapping.guest;
let handle = thread::Builder::new()
.name(format!("smolvm-tcp-{host_port}"))
.spawn(move || {
run_tcp_port_listener(
listener,
host_port,
guest_port,
tcp_sender,
publish_wake,
shutdown_flag,
)
})
.map_err(|err| {
shutdown_all(&shutdown, &mut handles);
io::Error::other(format!(
"failed to spawn published-port listener thread for {host_port}: {err}"
))
})?;
handles.push(handle);
}
}
Ok(Self { shutdown, handles })
}
}
impl Drop for TcpPortListeners {
fn drop(&mut self) {
shutdown_all(&self.shutdown, &mut self.handles);
}
}
fn shutdown_all(shutdown: &Arc<AtomicBool>, handles: &mut Vec<JoinHandle<()>>) {
shutdown.store(true, Ordering::SeqCst);
for handle in handles.drain(..) {
let _ = handle.join();
}
}
fn run_tcp_port_listener(
listener: TcpListener,
host_port: u16,
guest_port: u16,
tcp_sender: SyncSender<AcceptedTcpConnection>,
publish_wake: WakePipe,
shutdown: Arc<AtomicBool>,
) {
loop {
if shutdown.load(Ordering::SeqCst) {
return;
}
match listener.accept() {
Ok((stream, peer_addr)) => {
let accepted = AcceptedTcpConnection {
stream,
host_port,
guest_port,
peer_addr,
};
match tcp_sender.try_send(accepted) {
Ok(()) => publish_wake.wake(),
Err(TrySendError::Full(accepted)) => {
tracing::warn!(
host_port = accepted.host_port,
guest_port = accepted.guest_port,
peer_addr = %accepted.peer_addr,
"dropping published TCP connection because the accept queue is full"
);
}
Err(TrySendError::Disconnected(_)) => return,
}
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(ACCEPT_POLL_INTERVAL);
}
Err(err) => {
tracing::warn!(
host_port,
guest_port,
error = %err,
"published port listener accept failed"
);
thread::sleep(ACCEPT_POLL_INTERVAL);
}
}
}
}
/// Create the bounded channel used to hand accepted host sockets to the poll loop.
/// Each host port in the provided PortMapping has a listener. When the listener
/// accepts a TCP connection, it "sends" the TcpStream to the poll thread by putting
/// the AcceptedTcpConnection into this channel. The receiver consumes it in the
/// poll thread.
pub fn create_tcp_channel() -> (
SyncSender<AcceptedTcpConnection>,
mpsc::Receiver<AcceptedTcpConnection>,
) {
mpsc::sync_channel(DEFAULT_PUBLISH_QUEUE_CAPACITY)
}
+779
View File
@@ -0,0 +1,779 @@
//! TCP relay support for the virtio-net backend.
//!
//! Context
//! =======
//!
//! In the Phase 1 virtio-net design, guest TCP does not flow directly from the
//! guest to the outside network through the host kernel. Instead, the host-side
//! smoltcp runtime terminates the guest-visible TCP connection in userspace and
//! relays payloads to a normal host `TcpStream`.
//!
//! Conceptually:
//!
//! ```text
//! guest app
//! -> guest kernel TCP
//! -> Ethernet frame
//! -> smoltcp TCP socket (inside smolvm)
//! -> channel
//! -> host TcpStream
//! -> remote server
//! ```
//!
//! That means:
//! - the host runtime can observe every guest TCP byte stream on this NIC
//! - smoltcp owns the guest-facing TCP state machine
//! - the relay thread owns the host-facing TCP socket
//! - channels bridge payloads between them
use crate::egress::EgressPolicy;
use crate::queues::WakePipe;
use crate::virtio_net_log;
use smoltcp::iface::{Interface, SocketHandle, SocketSet};
use smoltcp::socket::tcp;
use smoltcp::wire::IpListenEndpoint;
use std::collections::{HashMap, HashSet};
use std::io::{self, Read, Write};
use std::net::{Ipv4Addr, Shutdown, SocketAddr, TcpStream};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
const TCP_RX_BUFFER_BYTES: usize = 64 * 1024;
const TCP_TX_BUFFER_BYTES: usize = 64 * 1024;
const MAX_CONNECTIONS: usize = 256;
const CHANNEL_CAPACITY: usize = 32;
const RELAY_BUFFER_BYTES: usize = 16 * 1024;
const CLOSE_RETRY_LIMIT: u16 = 64;
const PROXY_IDLE_SLEEP: Duration = Duration::from_millis(10);
const PUBLISHED_PORT_START: u16 = 49_152;
const PUBLISHED_PORT_END: u16 = 65_535;
/// Track all active guest TCP connections bridged through host sockets.
///
/// One entry corresponds to one `(guest source, destination)` tuple. The table
/// lives in the smoltcp poll thread and owns all guest-facing socket handles.
pub struct TcpRelayTable {
connections: HashMap<SocketHandle, TrackedConnection>,
connection_keys: HashSet<(SocketAddr, SocketAddr)>,
used_published_ports: HashSet<u16>,
next_published_port: u16,
max_connections: usize,
/// Outbound allow-list applied before opening a host connection for a
/// guest-initiated flow. Inbound published-port connections bypass it.
egress: EgressPolicy,
}
/// Newly established guest connection ready for a host relay thread.
///
/// The poll loop emits these once the guest-side smoltcp socket reaches
/// `Established`. At that point we can safely create the host-side relay
/// thread and give it channel endpoints for payload exchange.
pub struct NewTcpConnection {
/// Destination originally requested by the guest.
pub destination: SocketAddr,
/// How the host-side relay should be started.
pub relay_target: RelayTarget,
/// Guest-to-host payloads read from the smoltcp socket.
pub from_smoltcp: Receiver<Vec<u8>>,
/// Host-to-guest payloads written back into the smoltcp socket.
pub to_smoltcp: SyncSender<Vec<u8>>,
/// Shared relay exit state.
pub exit_state: RelayExitState,
}
#[derive(Debug)]
struct TrackedConnection {
// `source` and `destination` identify the guest-side flow.
source: SocketAddr,
destination: SocketAddr,
// guest -> host relay payloads
to_proxy: SyncSender<Vec<u8>>,
// host -> guest relay payloads
from_proxy: Receiver<Vec<u8>>,
// endpoints are held here until the guest-side handshake completes
pending_proxy_endpoints: Option<PendingProxyEndpoints>,
// once true, a dedicated host relay thread exists
relay_spawned: bool,
// partial guest->host payload already consumed from smoltcp but not yet
// accepted by the relay thread channel
buffered_guest_data: Option<Vec<u8>>,
// partial host->guest payload not yet fully accepted by smoltcp
buffered_proxy_data: Option<(Vec<u8>, usize)>,
// bounded retry count for closing with unsent buffered data
close_attempts: u16,
// relay thread termination mode observed by the poll loop
exit_state: RelayExitState,
// reserved local source port for published inbound connections
reserved_published_port: Option<u16>,
}
#[derive(Debug)]
struct PendingProxyEndpoints {
from_smoltcp: Receiver<Vec<u8>>,
to_smoltcp: SyncSender<Vec<u8>>,
relay_target: RelayTarget,
}
/// How a host-side TCP relay should obtain its remote socket.
#[derive(Debug)]
pub enum RelayTarget {
/// Open a new outbound host `TcpStream` to the destination.
Connect(SocketAddr),
/// Use an already-accepted host `TcpStream` from a published port listener.
Attached(TcpStream),
}
/// Host relay termination state shared between the poll loop and the relay thread.
///
/// The relay thread cannot mutate smoltcp sockets directly because those sockets
/// are owned by the poll loop thread. Instead it reports how it finished, and
/// the poll loop interprets that into guest-side socket actions:
/// - `Graceful` -> close guest socket cleanly
/// - `Abort` -> abort/reset guest socket
#[derive(Clone, Debug)]
pub struct RelayExitState {
inner: Arc<AtomicU8>,
}
/// How a host TCP relay thread terminated.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum RelayExitMode {
/// Relay thread is still running.
Running = 0,
/// Remote side closed normally; send FIN toward the guest.
Graceful = 1,
/// Remote connect or I/O failed; abort the guest TCP socket.
Abort = 2,
}
impl RelayExitState {
fn new() -> Self {
Self {
inner: Arc::new(AtomicU8::new(RelayExitMode::Running as u8)),
}
}
fn load(&self) -> RelayExitMode {
match self.inner.load(Ordering::Relaxed) {
1 => RelayExitMode::Graceful,
2 => RelayExitMode::Abort,
_ => RelayExitMode::Running,
}
}
fn store(&self, mode: RelayExitMode) {
self.inner.store(mode as u8, Ordering::Relaxed);
}
}
impl TcpRelayTable {
/// Create a new relay table.
pub fn new(max_connections: Option<usize>, egress: EgressPolicy) -> Self {
Self {
connections: HashMap::new(),
connection_keys: HashSet::new(),
used_published_ports: HashSet::new(),
next_published_port: PUBLISHED_PORT_START,
max_connections: max_connections.unwrap_or(MAX_CONNECTIONS),
egress,
}
}
/// Whether a relay socket already exists for the same guest source and destination.
pub fn has_socket_for(&self, source: &SocketAddr, destination: &SocketAddr) -> bool {
self.connection_keys.contains(&(*source, *destination))
}
/// Create a smoltcp TCP socket for a guest SYN.
///
/// Why this happens before full ingress processing:
/// - when the first guest SYN arrives, smoltcp needs a matching socket to
/// receive it
/// - the poll loop therefore pre-creates a listening socket keyed to the
/// destination the guest is trying to reach
/// - only after the guest-facing connection reaches `Established` do we
/// spawn the host relay thread
///
/// Data path after creation:
///
/// ```text
/// smoltcp socket --to_proxy channel--> host relay thread
/// host relay thread --from_proxy channel--> smoltcp socket
/// ```
pub fn create_tcp_socket(
&mut self,
source: SocketAddr,
destination: SocketAddr,
sockets: &mut SocketSet<'_>,
) -> bool {
if self.connections.len() >= self.max_connections {
tracing::warn!("dropping TCP connection because the relay table is full");
return false;
}
// Egress policy: drop the guest SYN before any socket is created when the
// destination isn't allowed, so the guest just sees the connection fail.
// Inbound published-port flows take a separate path and are unaffected.
if !self.egress.allows(destination.ip()) {
tracing::debug!(
%destination,
"virtio-net: blocking outbound connection by egress policy"
);
return false;
}
let rx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_RX_BUFFER_BYTES]);
let tx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_TX_BUFFER_BYTES]);
let mut socket = tcp::Socket::new(rx_buffer, tx_buffer);
let listen_endpoint = IpListenEndpoint {
addr: Some(destination.ip().into()),
port: destination.port(),
};
if socket.listen(listen_endpoint).is_err() {
return false;
}
let handle = sockets.add(socket);
let (to_proxy_tx, to_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
let (from_proxy_tx, from_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
let exit_state = RelayExitState::new();
self.connection_keys.insert((source, destination));
self.connections.insert(
handle,
TrackedConnection {
source,
destination,
to_proxy: to_proxy_tx,
from_proxy: from_proxy_rx,
pending_proxy_endpoints: Some(PendingProxyEndpoints {
from_smoltcp: to_proxy_rx,
to_smoltcp: from_proxy_tx,
relay_target: RelayTarget::Connect(destination),
}),
relay_spawned: false,
buffered_guest_data: None,
buffered_proxy_data: None,
close_attempts: 0,
exit_state,
reserved_published_port: None,
},
);
true
}
/// Create a guest-facing TCP connection for a published host socket.
///
/// This is the host->guest mirror of `create_tcp_socket`:
///
/// ```text
/// host client connects to published port
/// -> host listener accepts TcpStream
/// -> poll loop creates smoltcp TCP socket from gateway_ip:ephemeral
/// to guest_ip:guest_port
/// -> guest kernel sees a normal inbound TCP connection on guest_port
/// ```
///
/// The guest-visible source address is the gateway IP, not the original
/// host peer address. That keeps the first version simple and matches the
/// fact that this runtime is acting as a userspace gateway/proxy.
pub fn create_published_socket(
&mut self,
interface: &mut Interface,
gateway_ip: Ipv4Addr,
destination: SocketAddr,
host_stream: TcpStream,
sockets: &mut SocketSet<'_>,
) -> bool {
if self.connections.len() >= self.max_connections {
tracing::warn!("dropping published TCP connection because the relay table is full");
return false;
}
let Some(local_port) = self.allocate_published_port() else {
tracing::warn!(
"dropping published TCP connection because no gateway source port is available"
);
return false;
};
// Inbound published connections always target the guest's IPv4 on the
// internal link (the host listener family is independent of this).
let std::net::IpAddr::V4(destination_ip) = destination.ip() else {
self.used_published_ports.remove(&local_port);
return false;
};
let rx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_RX_BUFFER_BYTES]);
let tx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_TX_BUFFER_BYTES]);
let mut socket = tcp::Socket::new(rx_buffer, tx_buffer);
let local_endpoint = IpListenEndpoint {
addr: Some(gateway_ip.into()),
port: local_port,
};
if socket
.connect(
interface.context(),
(destination_ip, destination.port()),
local_endpoint,
)
.is_err()
{
self.used_published_ports.remove(&local_port);
return false;
}
let handle = sockets.add(socket);
let source = SocketAddr::new(std::net::IpAddr::V4(gateway_ip), local_port);
let (to_proxy_tx, to_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
let (from_proxy_tx, from_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
let exit_state = RelayExitState::new();
self.connection_keys.insert((source, destination));
self.connections.insert(
handle,
TrackedConnection {
source,
destination,
to_proxy: to_proxy_tx,
from_proxy: from_proxy_rx,
pending_proxy_endpoints: Some(PendingProxyEndpoints {
from_smoltcp: to_proxy_rx,
to_smoltcp: from_proxy_tx,
relay_target: RelayTarget::Attached(host_stream),
}),
relay_spawned: false,
buffered_guest_data: None,
buffered_proxy_data: None,
close_attempts: 0,
exit_state,
reserved_published_port: Some(local_port),
},
);
true
}
/// Relay TCP payloads between smoltcp sockets and host relay threads.
///
/// This runs in the poll thread. It is responsible for:
/// - draining bytes received from the guest-facing smoltcp socket and
/// pushing them toward the host relay thread
/// - draining bytes received from the host relay thread and writing them
/// back into the smoltcp socket
/// - interpreting relay exit state into guest-side `close()` or `abort()`
pub fn relay_data(&mut self, sockets: &mut SocketSet<'_>) {
let mut read_buffer = [0u8; RELAY_BUFFER_BYTES];
for (&handle, connection) in &mut self.connections {
if !connection.relay_spawned {
continue;
}
let socket = sockets.get_mut::<tcp::Socket>(handle);
match connection.exit_state.load() {
RelayExitMode::Abort => {
socket.abort();
continue;
}
RelayExitMode::Graceful => {
flush_proxy_data(socket, connection);
if connection.buffered_proxy_data.is_none() {
socket.close();
} else {
connection.close_attempts += 1;
if connection.close_attempts >= CLOSE_RETRY_LIMIT {
socket.abort();
}
}
continue;
}
RelayExitMode::Running => {}
}
flush_guest_data(connection);
while connection.buffered_guest_data.is_none() && socket.can_recv() {
match socket.recv_slice(&mut read_buffer) {
Ok(bytes_read) if bytes_read > 0 => {
let payload = read_buffer[..bytes_read].to_vec();
if !send_guest_payload(connection, payload) {
break;
}
}
_ => break,
}
}
flush_proxy_data(socket, connection);
}
}
/// Collect connections that reached ESTABLISHED and need a host relay thread.
///
/// The separation between `create_tcp_socket` and this method is important:
/// the guest TCP handshake is accepted first on the smoltcp side, and only
/// once that succeeds do we commit to opening the host-side `TcpStream`.
pub fn take_new_connections(&mut self, sockets: &mut SocketSet<'_>) -> Vec<NewTcpConnection> {
let mut new_connections = Vec::new();
for (&handle, connection) in &mut self.connections {
if connection.relay_spawned {
continue;
}
let socket = sockets.get::<tcp::Socket>(handle);
if socket.state() == tcp::State::Established {
connection.relay_spawned = true;
if let Some(endpoints) = connection.pending_proxy_endpoints.take() {
new_connections.push(NewTcpConnection {
destination: connection.destination,
relay_target: endpoints.relay_target,
from_smoltcp: endpoints.from_smoltcp,
to_smoltcp: endpoints.to_smoltcp,
exit_state: connection.exit_state.clone(),
});
}
}
}
new_connections
}
/// Remove closed sockets and drop their relay endpoints.
///
/// This is the final ownership cleanup step for a guest TCP flow.
pub fn cleanup_closed(&mut self, sockets: &mut SocketSet<'_>) {
let keys = &mut self.connection_keys;
let published_ports = &mut self.used_published_ports;
self.connections.retain(|&handle, connection| {
let socket = sockets.get::<tcp::Socket>(handle);
if socket.state() == tcp::State::Closed {
keys.remove(&(connection.source, connection.destination));
if let Some(port) = connection.reserved_published_port {
published_ports.remove(&port);
}
sockets.remove(handle);
false
} else {
true
}
});
}
fn allocate_published_port(&mut self) -> Option<u16> {
let start = self.next_published_port;
loop {
let candidate = self.next_published_port;
self.next_published_port = if candidate == PUBLISHED_PORT_END {
PUBLISHED_PORT_START
} else {
candidate + 1
};
if self.used_published_ports.insert(candidate) {
return Some(candidate);
}
if self.next_published_port == start {
return None;
}
}
}
}
/// Spawn one host TCP relay thread for an established guest connection.
///
/// Thread responsibilities:
/// - connect a host `TcpStream` to the guest-requested destination
/// - copy bytes guest->host from `from_smoltcp`
/// - copy bytes host->guest into `to_smoltcp`
/// - wake the poll loop when host->guest data arrives or guest->host backpressure eases
/// - report termination mode through `exit_state`
pub fn spawn_tcp_relay(
destination: SocketAddr,
relay_target: RelayTarget,
from_smoltcp: Receiver<Vec<u8>>,
to_smoltcp: SyncSender<Vec<u8>>,
relay_wake: Arc<WakePipe>,
exit_state: RelayExitState,
) {
let thread_name = format!("smolvm-tcp-{}", destination.port());
virtio_net_log!(
"virtio-net: spawning host TCP relay thread destination={} thread={}",
destination,
thread_name
);
let _ = thread::Builder::new().name(thread_name).spawn(move || {
run_tcp_relay(
destination,
relay_target,
from_smoltcp,
to_smoltcp,
relay_wake,
exit_state,
)
});
}
fn run_tcp_relay(
destination: SocketAddr,
relay_target: RelayTarget,
from_smoltcp: Receiver<Vec<u8>>,
to_smoltcp: SyncSender<Vec<u8>>,
relay_wake: Arc<WakePipe>,
exit_state: RelayExitState,
) {
// The relay thread is intentionally isolated from smoltcp internals. Its
// contract is just channels in, channels out, and an exit code back.
virtio_net_log!(
"virtio-net: host TCP relay thread started destination={}",
destination
);
match tcp_relay_loop(
destination,
relay_target,
from_smoltcp,
to_smoltcp,
relay_wake,
) {
Ok(mode) => {
virtio_net_log!(
"virtio-net: host TCP relay thread exited destination={} mode={:?}",
destination,
mode
);
exit_state.store(mode)
}
Err(err) => {
virtio_net_log!(
"virtio-net: host TCP relay failed destination={} error={}",
destination,
err
);
exit_state.store(RelayExitMode::Abort);
}
}
}
fn tcp_relay_loop(
destination: SocketAddr,
relay_target: RelayTarget,
from_smoltcp: Receiver<Vec<u8>>,
to_smoltcp: SyncSender<Vec<u8>>,
relay_wake: Arc<WakePipe>,
) -> io::Result<RelayExitMode> {
// Host-side flow:
//
// 1. Connect a normal host TcpStream to the destination.
// 2. Non-blockingly drain guest payloads from the channel into the socket.
// 3. Non-blockingly read remote payloads from the socket into the channel.
// 4. If neither side made progress, sleep briefly to avoid a hot spin loop.
let mut stream = match relay_target {
RelayTarget::Connect(destination) => {
virtio_net_log!(
"virtio-net: connecting host TCP relay socket destination={}",
destination
);
let stream = TcpStream::connect(destination)?;
virtio_net_log!(
"virtio-net: host TCP relay socket connected destination={}",
destination
);
stream
}
RelayTarget::Attached(stream) => {
virtio_net_log!(
"virtio-net: using accepted host TCP socket for published port guest_destination={} peer_addr={:?} local_addr={:?}",
destination,
stream.peer_addr().ok(),
stream.local_addr().ok()
);
stream
}
};
stream.set_nonblocking(true)?;
let mut guest_write_closed = false;
let mut guest_channel_closed = false;
let mut pending_guest_data: Option<(Vec<u8>, usize)> = None;
let mut read_buffer = [0u8; RELAY_BUFFER_BYTES];
loop {
let mut did_work = false;
if pending_guest_data.is_none() && !guest_channel_closed {
match from_smoltcp.try_recv() {
Ok(payload) => {
pending_guest_data = Some((payload, 0));
// Consuming from the bounded guest->host channel may free
// capacity for a payload buffered in the smoltcp poll
// thread. Wake it so backpressure clears promptly even for
// one-way guest->host streams.
relay_wake.wake();
did_work = true;
}
Err(TryRecvError::Empty) => {}
Err(TryRecvError::Disconnected) => {
guest_channel_closed = true;
}
}
}
if let Some((payload, offset)) = &mut pending_guest_data {
while *offset < payload.len() {
match stream.write(&payload[*offset..]) {
Ok(0) => {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"host TCP relay wrote zero bytes",
));
}
Ok(bytes_written) => {
*offset += bytes_written;
did_work = true;
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => break,
Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
Err(err) => return Err(err),
}
}
if *offset >= payload.len() {
pending_guest_data = None;
}
}
if guest_channel_closed && pending_guest_data.is_none() && !guest_write_closed {
// The guest side closed its write half. Mirror that toward the
// remote peer only after all buffered guest bytes were written.
let _ = stream.shutdown(Shutdown::Write);
guest_write_closed = true;
}
match stream.read(&mut read_buffer) {
Ok(0) => return Ok(RelayExitMode::Graceful),
Ok(bytes_read) => {
if to_smoltcp.send(read_buffer[..bytes_read].to_vec()).is_err() {
return Ok(RelayExitMode::Graceful);
}
relay_wake.wake();
did_work = true;
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
Err(err) => return Err(err),
}
if !did_work {
thread::sleep(PROXY_IDLE_SLEEP);
}
}
}
fn flush_guest_data(connection: &mut TrackedConnection) {
let Some(payload) = connection.buffered_guest_data.take() else {
return;
};
send_guest_payload(connection, payload);
}
fn send_guest_payload(connection: &mut TrackedConnection, payload: Vec<u8>) -> bool {
match connection.to_proxy.try_send(payload) {
Ok(()) => true,
Err(TrySendError::Full(payload)) => {
connection.buffered_guest_data = Some(payload);
false
}
Err(TrySendError::Disconnected(_)) => false,
}
}
fn flush_proxy_data(socket: &mut tcp::Socket<'_>, connection: &mut TrackedConnection) {
// smoltcp send windows may accept only part of an inbound host payload.
// `buffered_proxy_data` remembers the unwritten remainder so the next poll
// iteration can continue where it left off instead of dropping bytes.
if let Some((data, offset)) = &mut connection.buffered_proxy_data {
if socket.can_send() {
match socket.send_slice(&data[*offset..]) {
Ok(written) => {
*offset += written;
if *offset >= data.len() {
connection.buffered_proxy_data = None;
}
}
Err(_) => return,
}
} else {
return;
}
}
while connection.buffered_proxy_data.is_none() {
match connection.from_proxy.try_recv() {
Ok(payload) => {
if socket.can_send() {
match socket.send_slice(&payload) {
Ok(written) if written < payload.len() => {
connection.buffered_proxy_data = Some((payload, written));
}
Err(_) => {
connection.buffered_proxy_data = Some((payload, 0));
}
_ => {}
}
} else {
connection.buffered_proxy_data = Some((payload, 0));
}
}
Err(TryRecvError::Empty | TryRecvError::Disconnected) => break,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_connection(to_proxy: SyncSender<Vec<u8>>) -> TrackedConnection {
let (_from_proxy_tx, from_proxy) = mpsc::sync_channel(CHANNEL_CAPACITY);
TrackedConnection {
source: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 12_345),
destination: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 80),
to_proxy,
from_proxy,
pending_proxy_endpoints: None,
relay_spawned: true,
buffered_guest_data: None,
buffered_proxy_data: None,
close_attempts: 0,
exit_state: RelayExitState::new(),
reserved_published_port: None,
}
}
#[test]
fn guest_payload_is_buffered_when_relay_channel_is_full() {
let (to_proxy, from_smoltcp) = mpsc::sync_channel(1);
to_proxy.send(vec![1]).unwrap();
let mut connection = test_connection(to_proxy);
assert!(!send_guest_payload(&mut connection, vec![2]));
assert_eq!(connection.buffered_guest_data.as_deref(), Some(&[2][..]));
assert_eq!(from_smoltcp.recv().unwrap(), vec![1]);
flush_guest_data(&mut connection);
assert!(connection.buffered_guest_data.is_none());
assert_eq!(from_smoltcp.recv().unwrap(), vec![2]);
}
}
+537
View File
@@ -0,0 +1,537 @@
//! General UDP relay for the virtio-net backend.
//!
//! Context
//! =======
//!
//! DNS (UDP :53) is intercepted and answered by the gateway itself
//! (`stack.rs::process_dns_queries`). Every other guest UDP datagram used to be
//! dropped (`FrameAction::UnsupportedUdp`), which broke QUIC/HTTP-3, NTP, and
//! any DNS on a non-standard port. This module relays those flows to real host
//! UDP sockets, giving virtio-net parity with TSI (whose syscall-level hijack
//! gets UDP for free).
//!
//! Model — a tiny userspace NAT, mirroring the TCP relay's shape:
//!
//! ```text
//! guest datagram to D:port (≠ :53)
//! -> classify_guest_frame: FrameAction::UdpFlow
//! -> poll loop: egress check; ensure a smoltcp UDP socket bound to D:port
//! -> smoltcp ingress delivers the datagram into that socket
//! -> poll loop drains it and channels (guest, D, payload) to the relay thread
//! -> relay thread: one connected host UdpSocket per (guest, D) flow; send
//! -> replies: host socket readable -> channel back -> relay_wake
//! -> poll loop writes the reply into the D-bound smoltcp socket with
//! endpoint = guest, local_address = D (guest sees the reply come from D)
//! ```
//!
//! Flows are connectionless, so lifetime is NAT-style idle expiry: the relay
//! drops host sockets idle for [`FLOW_IDLE_TIMEOUT`]; the poll loop drops
//! destination sockets idle for [`DST_SOCKET_IDLE_TIMEOUT`]. Loss under
//! pressure (full channels / tables) is acceptable UDP semantics — logged,
//! never blocking.
use crate::egress::EgressPolicy;
use crate::queues::WakePipe;
use crate::virtio_net_log;
use polling::{Event, Events};
use smoltcp::iface::{SocketHandle, SocketSet};
use smoltcp::socket::udp::{PacketBuffer, PacketMetadata, Socket as UdpSocket, UdpMetadata};
use std::collections::HashMap;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket as HostUdpSocket};
use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
/// Max in-flight datagrams per direction before drops (UDP may drop).
const CHANNEL_CAPACITY: usize = 256;
/// Max concurrent (guest, destination) flows with live host sockets.
const MAX_FLOWS: usize = 1024;
/// Max destination-keyed smoltcp sockets in the poll loop.
const MAX_DST_SOCKETS: usize = 128;
/// Packet slots per destination socket buffer.
const UDP_PACKET_SLOTS: usize = 16;
/// Payload bytes per destination socket buffer (per direction).
const UDP_BUFFER_BYTES: usize = 64 * 1024;
/// Largest datagram we relay (gateway MTU bounds guest->host anyway).
const MAX_DATAGRAM_BYTES: usize = 65_535;
/// Drop a flow's host socket after this much inactivity.
const FLOW_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
/// Remove a destination's smoltcp socket after this much inactivity.
const DST_SOCKET_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
/// Relay thread poll ceiling, so shutdown and expiry are noticed promptly.
const RELAY_POLL_MAX_MS: i32 = 1000;
/// One relayed datagram, in either direction.
pub struct UdpDatagram {
/// Guest-side endpoint (source on egress, reply target on ingress).
pub guest: SocketAddr,
/// External destination the guest addressed (and replies come from).
pub destination: SocketAddr,
/// Datagram payload.
pub payload: Vec<u8>,
}
/// Channel pair connecting the poll loop and the relay thread.
pub struct UdpRelayChannels {
/// Poll loop -> relay thread.
pub to_relay: SyncSender<UdpDatagram>,
/// Relay thread -> poll loop.
pub from_relay: Receiver<UdpDatagram>,
/// Wakes the relay thread after `to_relay` sends.
pub relay_thread_wake: WakePipe,
}
/// Start the UDP relay thread. Returns the poll-loop-side channel endpoints.
///
/// `reply_wake` is the smoltcp poll loop's existing relay wake pipe — pulsed
/// whenever a reply is queued so the loop wakes to deliver it to the guest.
/// The thread exits when `shutdown` reports true (checked at least once per
/// [`RELAY_POLL_MAX_MS`]).
pub fn start_udp_relay(
reply_wake: Arc<WakePipe>,
shutdown: Arc<dyn Fn() -> bool + Send + Sync>,
) -> UdpRelayChannels {
let (to_relay_tx, to_relay_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
let (from_relay_tx, from_relay_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
let relay_thread_wake = WakePipe::new();
let thread_wake = relay_thread_wake.clone();
let _ = thread::Builder::new()
.name("smolvm-udp-relay".into())
.spawn(move || {
run_udp_relay(
to_relay_rx,
from_relay_tx,
thread_wake,
reply_wake,
shutdown,
);
});
UdpRelayChannels {
to_relay: to_relay_tx,
from_relay: from_relay_rx,
relay_thread_wake,
}
}
/// Relay-thread state for one (guest, destination) flow.
struct UdpFlow {
socket: HostUdpSocket,
guest: SocketAddr,
destination: SocketAddr,
last_active: Instant,
}
fn run_udp_relay(
outbound: Receiver<UdpDatagram>,
inbound: SyncSender<UdpDatagram>,
wake: WakePipe,
reply_wake: Arc<WakePipe>,
shutdown: Arc<dyn Fn() -> bool + Send + Sync>,
) {
let mut flows: HashMap<(SocketAddr, SocketAddr), UdpFlow> = HashMap::new();
let mut recv_buf = vec![0u8; MAX_DATAGRAM_BYTES];
loop {
if shutdown() {
return;
}
// Outbound: guest datagrams handed over by the poll loop.
loop {
match outbound.try_recv() {
Ok(datagram) => {
let key = (datagram.guest, datagram.destination);
if !flows.contains_key(&key) {
if flows.len() >= MAX_FLOWS {
virtio_net_log!(
"virtio-net: dropping UDP flow {} -> {} (flow table full)",
datagram.guest,
datagram.destination
);
continue;
}
match create_flow_socket(datagram.destination) {
Ok(socket) => {
flows.insert(
key,
UdpFlow {
socket,
guest: datagram.guest,
destination: datagram.destination,
last_active: Instant::now(),
},
);
}
Err(err) => {
virtio_net_log!(
"virtio-net: failed to open host UDP socket for {} -> {}: {}",
datagram.guest,
datagram.destination,
err
);
continue;
}
}
}
let flow = flows.get_mut(&key).expect("flow inserted above");
flow.last_active = Instant::now();
// Best-effort send; UDP loss is allowed.
let _ = flow.socket.send(&datagram.payload);
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => return,
}
}
// Inbound: poll all flow sockets for replies. The wake's poller blocks
// on "wake OR any flow socket readable" in a single call; the wake is a
// notify (no key), and each flow socket is registered at key `slot + 1`.
let poller = wake.poller();
let keys: Vec<(SocketAddr, SocketAddr)> = flows.keys().copied().collect();
for (slot, key) in keys.iter().enumerate() {
// SAFETY: the socket is owned by `flows` and is deleted from the
// poller below before the next iteration may drop it.
let _ = unsafe { poller.add(&flows[key].socket, Event::readable(slot + 1)) };
}
let mut events = Events::new();
let _ = poller.wait(
&mut events,
Some(Duration::from_millis(RELAY_POLL_MAX_MS as u64)),
);
// A pending notify is consumed by `wait`; nothing else to drain.
let mut ready: Vec<bool> = vec![false; keys.len()];
for event in events.iter() {
if event.key >= 1 && event.key - 1 < ready.len() {
ready[event.key - 1] = true;
}
}
// Deregister sockets before the next rebuild so a closed flow's socket
// is never left registered on the poller.
for key in &keys {
let _ = poller.delete(&flows[key].socket);
}
let mut woke_reply = false;
for (slot, key) in keys.iter().enumerate() {
if !ready[slot] {
continue;
}
let Some(flow) = flows.get_mut(key) else {
continue;
};
// Drain everything ready on this socket. WouldBlock ends the drain;
// ECONNREFUSED (ICMP unreachable surfaced by the connected socket)
// and friends just mean the flow is dead-ish — idle expiry handles it.
while let Ok(len) = flow.socket.recv(&mut recv_buf) {
flow.last_active = Instant::now();
let reply = UdpDatagram {
guest: flow.guest,
destination: flow.destination,
payload: recv_buf[..len].to_vec(),
};
match inbound.try_send(reply) {
Ok(()) => woke_reply = true,
Err(TrySendError::Full(_)) => {
virtio_net_log!(
"virtio-net: dropping UDP reply for {} (inbound queue full)",
flow.guest
);
}
Err(TrySendError::Disconnected(_)) => return,
}
}
}
if woke_reply {
reply_wake.wake();
}
// NAT-style idle expiry.
let now = Instant::now();
flows.retain(|_, flow| now.duration_since(flow.last_active) < FLOW_IDLE_TIMEOUT);
}
}
/// Open a non-blocking host UDP socket connected to `destination`.
///
/// `connect` pins the peer so `send`/`recv` apply and stray traffic from other
/// peers is filtered by the kernel — each flow is its own little NAT pinhole.
fn create_flow_socket(destination: SocketAddr) -> std::io::Result<HostUdpSocket> {
let bind_addr: SocketAddr = if destination.is_ipv4() {
(Ipv4Addr::UNSPECIFIED, 0).into()
} else {
(Ipv6Addr::UNSPECIFIED, 0).into()
};
let socket = HostUdpSocket::bind(bind_addr)?;
socket.connect(destination)?;
socket.set_nonblocking(true)?;
Ok(socket)
}
/// Poll-loop-side table of destination-keyed smoltcp UDP sockets.
///
/// One socket per (destination address, port) — the UDP mirror of
/// `TcpRelayTable::create_tcp_socket`'s destination-keyed listen sockets. The
/// guest's own endpoint comes from receive metadata, so all guest flows to the
/// same destination share one smoltcp socket.
pub struct UdpSocketTable {
sockets: HashMap<SocketAddr, SocketHandle>,
last_active: HashMap<SocketAddr, Instant>,
}
impl UdpSocketTable {
pub fn new() -> Self {
Self {
sockets: HashMap::new(),
last_active: HashMap::new(),
}
}
/// Ensure a smoltcp UDP socket exists for `destination` so the staged guest
/// datagram has somewhere to land. Returns false when the table is full or
/// the bind fails (the caller drops the frame — UDP semantics).
pub fn ensure_socket(&mut self, destination: SocketAddr, sockets: &mut SocketSet<'_>) -> bool {
if self.sockets.contains_key(&destination) {
self.last_active.insert(destination, Instant::now());
return true;
}
if self.sockets.len() >= MAX_DST_SOCKETS {
virtio_net_log!(
"virtio-net: dropping UDP datagram to {} (destination socket table full)",
destination
);
return false;
}
let rx = PacketBuffer::new(
vec![PacketMetadata::EMPTY; UDP_PACKET_SLOTS],
vec![0u8; UDP_BUFFER_BYTES],
);
let tx = PacketBuffer::new(
vec![PacketMetadata::EMPTY; UDP_PACKET_SLOTS],
vec![0u8; UDP_BUFFER_BYTES],
);
let mut socket = UdpSocket::new(rx, tx);
if socket
.bind(smoltcp::wire::IpListenEndpoint {
addr: Some(destination.ip().into()),
port: destination.port(),
})
.is_err()
{
return false;
}
let handle = sockets.add(socket);
self.sockets.insert(destination, handle);
self.last_active.insert(destination, Instant::now());
true
}
/// Drain guest datagrams from every destination socket toward the relay
/// thread. Returns true if anything was forwarded (the caller then wakes
/// the relay thread).
pub fn drain_to_relay(
&mut self,
sockets: &mut SocketSet<'_>,
to_relay: &SyncSender<UdpDatagram>,
) -> bool {
let mut forwarded = false;
for (&destination, &handle) in &self.sockets {
let socket = sockets.get_mut::<UdpSocket>(handle);
while socket.can_recv() {
let Ok((payload, metadata)) = socket.recv() else {
break;
};
self.last_active.insert(destination, Instant::now());
let guest = endpoint_to_socket_addr(metadata.endpoint);
let datagram = UdpDatagram {
guest,
destination,
payload: payload.to_vec(),
};
match to_relay.try_send(datagram) {
Ok(()) => forwarded = true,
Err(TrySendError::Full(_)) => {
virtio_net_log!(
"virtio-net: dropping guest UDP datagram to {} (relay queue full)",
destination
);
}
Err(TrySendError::Disconnected(_)) => return forwarded,
}
}
}
forwarded
}
/// Deliver relay replies into the matching destination socket so smoltcp
/// sends them to the guest, sourced from the original destination address.
pub fn deliver_replies(
&mut self,
sockets: &mut SocketSet<'_>,
from_relay: &Receiver<UdpDatagram>,
) {
while let Ok(reply) = from_relay.try_recv() {
let Some(&handle) = self.sockets.get(&reply.destination) else {
// Destination socket already expired; the reply is late.
continue;
};
self.last_active.insert(reply.destination, Instant::now());
let socket = sockets.get_mut::<UdpSocket>(handle);
let metadata = UdpMetadata {
endpoint: smoltcp::wire::IpEndpoint {
addr: reply.guest.ip().into(),
port: reply.guest.port(),
},
local_address: Some(reply.destination.ip().into()),
meta: Default::default(),
};
if socket.send_slice(&reply.payload, metadata).is_err() {
virtio_net_log!(
"virtio-net: dropping UDP reply to {} (guest socket buffer full)",
reply.guest
);
}
}
}
/// Remove destination sockets with no traffic for [`DST_SOCKET_IDLE_TIMEOUT`].
pub fn expire_idle(&mut self, sockets: &mut SocketSet<'_>) {
let now = Instant::now();
let expired: Vec<SocketAddr> = self
.last_active
.iter()
.filter(|(_, last)| now.duration_since(**last) >= DST_SOCKET_IDLE_TIMEOUT)
.map(|(dst, _)| *dst)
.collect();
for destination in expired {
if let Some(handle) = self.sockets.remove(&destination) {
sockets.remove(handle);
}
self.last_active.remove(&destination);
}
}
}
impl Default for UdpSocketTable {
fn default() -> Self {
Self::new()
}
}
/// Whether the gateway should relay a guest UDP datagram to this destination.
/// DNS (:53) is excluded — it has its own intercept-and-filter path. Egress
/// policy applies exactly as for TCP (static CIDRs + DNS-learned IPs).
pub fn should_relay_udp(destination: SocketAddr, egress: &EgressPolicy) -> bool {
destination.port() != 53 && egress.allows(destination.ip())
}
fn endpoint_to_socket_addr(endpoint: smoltcp::wire::IpEndpoint) -> SocketAddr {
let ip: std::net::IpAddr = endpoint.addr.into();
SocketAddr::new(ip, endpoint.port)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
#[test]
fn flow_socket_round_trip() {
// Echo server on the host loopback.
let server = HostUdpSocket::bind("127.0.0.1:0").unwrap();
let server_addr = server.local_addr().unwrap();
let flow = create_flow_socket(server_addr).unwrap();
flow.send(b"ping").unwrap();
let mut buf = [0u8; 16];
let (len, peer) = server.recv_from(&mut buf).unwrap();
assert_eq!(&buf[..len], b"ping");
server.send_to(b"pong", peer).unwrap();
// Non-blocking: poll briefly for the reply.
let deadline = Instant::now() + Duration::from_secs(2);
loop {
match flow.recv(&mut buf) {
Ok(len) => {
assert_eq!(&buf[..len], b"pong");
break;
}
Err(_) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
Err(e) => panic!("no reply: {e}"),
}
}
}
#[test]
fn relay_thread_round_trips_a_datagram() {
let server = HostUdpSocket::bind("127.0.0.1:0").unwrap();
let server_addr = server.local_addr().unwrap();
let reply_wake = Arc::new(WakePipe::new());
let stop = Arc::new(AtomicBool::new(false));
let stop_flag = stop.clone();
let channels = start_udp_relay(
reply_wake.clone(),
Arc::new(move || stop_flag.load(Ordering::Relaxed)),
);
let guest: SocketAddr = "100.96.0.2:40000".parse().unwrap();
channels
.to_relay
.send(UdpDatagram {
guest,
destination: server_addr,
payload: b"hello".to_vec(),
})
.unwrap();
channels.relay_thread_wake.wake();
// Server sees the datagram and answers.
let mut buf = [0u8; 16];
server
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let (len, peer) = server.recv_from(&mut buf).unwrap();
assert_eq!(&buf[..len], b"hello");
server.send_to(b"world", peer).unwrap();
// The reply arrives on the inbound channel addressed to the guest flow.
let reply = channels
.from_relay
.recv_timeout(Duration::from_secs(2))
.unwrap();
assert_eq!(reply.guest, guest);
assert_eq!(reply.destination, server_addr);
assert_eq!(reply.payload, b"world");
stop.store(true, Ordering::Relaxed);
channels.relay_thread_wake.wake();
}
#[test]
fn should_relay_respects_dns_carveout_and_policy() {
let open = EgressPolicy::unrestricted();
assert!(should_relay_udp("1.2.3.4:123".parse().unwrap(), &open));
assert!(!should_relay_udp("1.2.3.4:53".parse().unwrap(), &open));
// Public CIDR — a private allow-list entry would be overridden by the
// egress hard-floor (see egress.rs tests).
let restricted = EgressPolicy::from_allowed_cidrs(Some(&["8.8.8.0/24".into()]));
assert!(should_relay_udp(
"8.8.8.3:123".parse().unwrap(),
&restricted
));
assert!(!should_relay_udp(
"1.2.3.4:123".parse().unwrap(),
&restricted
));
}
}
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "smolvm-pack"
version = "1.5.2"
edition = "2021"
description = "Single-binary packaging for smolvm"
license = "Apache-2.0"
repository = "https://github.com/smol-machines/smolvm"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
smolvm-protocol = { path = "../smolvm-protocol", version = "1.5.2" }
tar = "0.4"
zstd = "0.13"
crc32fast = "1.4"
tempfile = "3"
sha2 = "0.10"
dirs = "5"
time = { version = "0.3", features = ["formatting"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.60", features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
"Win32_System_IO",
] }
+930
View File
@@ -0,0 +1,930 @@
//! Asset collection and compression for packed binaries.
//!
//! This module handles discovering and packaging runtime assets:
//! - Runtime libraries (libkrun, libkrunfw)
//! - Agent rootfs
//! - OCI image layers
use std::fs::{self, File};
use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use crate::format::{AssetEntry, AssetInventory, LayerEntry};
use crate::{PackError, Result};
/// Convert a digest string to a filename for layer tars.
///
/// Strips an optional `sha256:` prefix and uses the full remaining digest hex.
/// Returns an error if the digest portion is shorter than 12 characters.
fn digest_to_filename(digest: &str) -> Result<String> {
let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
if hex.len() < 12 {
return Err(PackError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"digest too short for filename: '{}' ({} chars, need 12)",
digest,
hex.len()
),
)));
}
// The filename becomes `layers/{hex}.tar` joined onto the staging dir, so
// the digest MUST be pure hex — otherwise a malicious `.smolmachine` with a
// digest like `sha256:../../evil` would write the layer bytes outside the
// staging root (path traversal). A real content digest is always hex.
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(PackError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("digest is not valid hex: '{}'", digest),
)));
}
Ok(format!("{}.tar", hex))
}
/// Compression level for zstd (3 = zstd default, fast with good ratio).
/// Level 19 was ~100x slower for only ~10% better compression.
pub const ZSTD_LEVEL: i32 = 3;
/// Find a pre-formatted disk template by filename.
///
/// Searches in order:
/// 1. `~/.smolvm/{filename}` (installed location)
/// 2. Next to the current executable (development)
fn find_existing_template(filename: &str) -> Option<PathBuf> {
if let Some(home) = dirs::home_dir() {
let path = home.join(".smolvm").join(filename);
if path.exists() {
return Some(path);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let path = dir.join(filename);
if path.exists() {
return Some(path);
}
}
}
None
}
/// Asset collector for gathering runtime components.
pub struct AssetCollector {
staging_dir: PathBuf,
inventory: AssetInventory,
}
impl AssetCollector {
/// Create a new asset collector with a staging directory.
pub fn new(staging_dir: PathBuf) -> Result<Self> {
fs::create_dir_all(&staging_dir)?;
fs::create_dir_all(staging_dir.join("layers"))?;
Ok(Self {
staging_dir,
inventory: AssetInventory {
libraries: Vec::new(),
agent_rootfs: AssetEntry {
path: "agent-rootfs.tar".to_string(),
size: 0,
},
layers: Vec::new(),
storage_template: None,
overlay_template: None,
overlay_logical_size: None,
},
})
}
/// Get the staging directory path.
pub fn staging_dir(&self) -> &Path {
&self.staging_dir
}
/// Discover and copy runtime libraries from the given lib directory.
///
/// Always copies:
/// - libkrun.dylib / libkrun.so — VM runtime
/// - libkrunfw.5.dylib / libkrunfw.so.5 — kernel firmware
///
/// Copies when present (GPU passthrough for `gpu = true` guests):
/// - macOS: libvirglrenderer.1.dylib, libMoltenVK.dylib, libepoxy.0.dylib
/// - Linux: libvirglrenderer.so.1, libepoxy.so.0, virgl_render_server binary
///
/// GPU Vulkan ICDs (ANV, RADV) are hardware-specific and cannot be bundled.
/// When GPU libs are bundled, loading them adds ~3ms overhead even for non-GPU
/// workloads (lib load is unavoidable; virglrenderer init is deferred to GPU use).
pub fn collect_libraries(&mut self, lib_dir: &Path) -> Result<()> {
fs::create_dir_all(self.staging_dir.join("lib"))?;
let lib_names = if cfg!(target_os = "macos") {
vec!["libkrun.dylib", "libkrunfw.5.dylib"]
} else if cfg!(target_os = "windows") {
// Must match smolvm's loader (util::libkrun_filename): WHP uses the
// Windows DLL names, not the Linux .so names.
vec!["krun.dll", "libkrunfw.dll"]
} else {
vec!["libkrun.so", "libkrunfw.so.5"]
};
for name in lib_names {
let src = lib_dir.join(name);
if !src.exists() {
return Err(PackError::AssetNotFound(format!(
"library not found: {}",
src.display()
)));
}
let dst = self.staging_dir.join("lib").join(name);
fs::copy(&src, &dst)?;
let metadata = fs::metadata(&dst)?;
self.inventory.libraries.push(AssetEntry {
path: format!("lib/{}", name),
size: metadata.len(),
});
}
// On macOS, bundle GPU rendering libraries when present in the lib dir.
// The virglrenderer chain (Venus/Vulkan) enables hardware-accelerated GPU
// passthrough for guests using virtio-gpu. All paths use @loader_path so
// they resolve relative to where libkrun.dylib is loaded from.
#[cfg(target_os = "macos")]
{
let gpu_libs = [
"libvirglrenderer.1.dylib",
"libMoltenVK.dylib",
"libepoxy.0.dylib",
];
for name in &gpu_libs {
let src = lib_dir.join(name);
if src.exists() {
let dst = self.staging_dir.join("lib").join(name);
fs::copy(&src, &dst)?;
let metadata = fs::metadata(&dst)?;
self.inventory.libraries.push(AssetEntry {
path: format!("lib/{}", name),
size: metadata.len(),
});
}
}
}
// On Linux, bundle GPU rendering libraries and render server when present.
// virglrenderer + epoxy enable Venus/Vulkan via virtio-gpu.
// virgl_render_server is the subprocess libkrun spawns during Venus init.
// GPU Vulkan ICDs (ANV, RADV) are hardware-specific and cannot be bundled.
#[cfg(target_os = "linux")]
{
let gpu_libs = ["libvirglrenderer.so.1", "libepoxy.so.0"];
for name in &gpu_libs {
let src = lib_dir.join(name);
if src.exists() {
let dst = self.staging_dir.join("lib").join(name);
fs::copy(&src, &dst)?;
let metadata = fs::metadata(&dst)?;
self.inventory.libraries.push(AssetEntry {
path: format!("lib/{}", name),
size: metadata.len(),
});
}
}
let server_src = lib_dir.join("virgl_render_server");
if server_src.exists() {
let server_dst = self.staging_dir.join("lib").join("virgl_render_server");
fs::copy(&server_src, &server_dst)?;
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&server_dst, fs::Permissions::from_mode(0o755))?;
let metadata = fs::metadata(&server_dst)?;
self.inventory.libraries.push(AssetEntry {
path: "lib/virgl_render_server".to_string(),
size: metadata.len(),
});
}
}
Ok(())
}
/// Copy the agent rootfs directory and create a tarball.
pub fn collect_agent_rootfs(&mut self, rootfs_dir: &Path) -> Result<()> {
if !rootfs_dir.exists() {
return Err(PackError::AssetNotFound(format!(
"agent rootfs not found: {}",
rootfs_dir.display()
)));
}
let tar_path = self.staging_dir.join("agent-rootfs.tar");
let tar_file = File::create(&tar_path)?;
let mut tar_builder = tar::Builder::new(BufWriter::new(tar_file));
// Don't follow symlinks - preserve them as-is
tar_builder.follow_symlinks(false);
// The agent-rootfs directory doubles as the virtiofs mount the host
// writes its per-boot readiness markers into (`.smolvm-ready.<hash>`,
// see `AGENT_READY_MARKER`). Those are host-side runtime artifacts, not
// part of the guest init system, and they accumulate across boots — and
// a VM that ran under per-VM-uid isolation leaves them owned by a
// foreign uid with mode 0600, unreadable to the packer. `append_dir_all`
// over the whole directory then hard-failed the entire pack with an
// opaque "tar error: Permission denied". Walk the top level instead,
// skip the markers, and name the offending path on any real I/O error.
let mut entries: Vec<fs::DirEntry> =
fs::read_dir(rootfs_dir)?.collect::<std::io::Result<_>>()?;
// Deterministic ordering so the tar (and its hash) is reproducible.
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let name = entry.file_name();
if name
.to_string_lossy()
.starts_with(smolvm_protocol::AGENT_READY_MARKER)
{
continue;
}
let path = entry.path();
let tar_err = |e: std::io::Error| PackError::Tar(format!("{}: {e}", path.display()));
if entry.file_type()?.is_dir() {
tar_builder.append_dir_all(&name, &path).map_err(tar_err)?;
} else {
// Regular file or symlink (follow_symlinks(false) archives the
// link itself rather than its target).
tar_builder
.append_path_with_name(&path, &name)
.map_err(tar_err)?;
}
}
tar_builder
.finish()
.map_err(|e| PackError::Tar(e.to_string()))?;
let metadata = fs::metadata(&tar_path)?;
self.inventory.agent_rootfs = AssetEntry {
path: "agent-rootfs.tar".to_string(),
size: metadata.len(),
};
Ok(())
}
/// Add an OCI layer tarball.
pub fn add_layer(&mut self, digest: &str, layer_data: &[u8]) -> Result<()> {
let filename = digest_to_filename(digest)?;
let path = format!("layers/{}", filename);
let dst = self.staging_dir.join(&path);
fs::write(&dst, layer_data)?;
self.inventory.layers.push(LayerEntry {
digest: digest.to_string(),
path,
size: layer_data.len() as u64,
});
Ok(())
}
/// Get the staging path where a layer file should be written.
///
/// Call this before streaming the layer to get the destination path,
/// then call `register_layer()` after writing to register it in the inventory.
pub fn layer_staging_path(&self, digest: &str) -> PathBuf {
let filename = digest_to_filename(digest)
.expect("layer digest must be sha256:<hex> with at least 12 hex chars");
self.staging_dir.join(format!("layers/{}", filename))
}
/// Register a layer that was already written to its staging path.
///
/// Use after streaming a layer directly to `layer_staging_path()`.
pub fn register_layer(&mut self, digest: &str) -> Result<()> {
let filename = digest_to_filename(digest)?;
let path = format!("layers/{}", filename);
let dst = self.staging_dir.join(&path);
let metadata = fs::metadata(&dst)?;
self.inventory.layers.push(LayerEntry {
digest: digest.to_string(),
path,
size: metadata.len(),
});
Ok(())
}
/// Add an OCI layer from a file path.
pub fn add_layer_from_file(&mut self, digest: &str, layer_path: &Path) -> Result<()> {
let filename = digest_to_filename(digest)?;
let path = format!("layers/{}", filename);
let dst = self.staging_dir.join(&path);
fs::copy(layer_path, &dst)?;
let metadata = fs::metadata(&dst)?;
self.inventory.layers.push(LayerEntry {
digest: digest.to_string(),
path,
size: metadata.len(),
});
Ok(())
}
/// Create and collect a pre-formatted ext4 storage template.
///
/// Creates a small sparse ext4 disk image that can be used as a template
/// for the storage disk at runtime. This eliminates the need for mkfs.ext4
/// on first boot and improves reliability.
///
/// Tries in order:
/// 1. Copy an existing pre-formatted template from `~/.smolvm/` or next to the exe
/// 2. Format a new one with `mkfs.ext4` (requires e2fsprogs)
///
/// The template is a 512MB sparse file (actual size ~100KB when empty).
pub fn create_storage_template(&mut self) -> Result<()> {
use std::io::{Seek, SeekFrom, Write};
use std::process::Command;
const TEMPLATE_SIZE: u64 = 512 * 1024 * 1024; // 512MB virtual size
const TEMPLATE_NAME: &str = "storage.ext4";
let template_path = self.staging_dir.join(TEMPLATE_NAME);
// Try to copy from an existing pre-formatted template first.
// This avoids requiring e2fsprogs on the build machine.
if let Some(existing) = find_existing_template("storage-template.ext4") {
// Hole-preserving copy: the shipped storage-template.ext4 is a large
// (multi-GiB) sparse file, and a plain fs::copy densifies it into its
// full logical size of zeros on some Linux filesystems/mounts —
// ballooning the staging dir and failing pack builds with ENOSPC.
crate::extract::sparse_copy(&existing, &template_path)?;
let metadata = fs::metadata(&template_path)?;
self.inventory.storage_template = Some(AssetEntry {
path: TEMPLATE_NAME.to_string(),
size: metadata.len(),
});
return Ok(());
}
// No pre-formatted template found — create one with mkfs.ext4.
// Create sparse file
let mut file = File::create(&template_path)?;
// On Windows/NTFS, File::create makes a dense file; seeking past the end
// and writing a tail byte would allocate every intermediate block.
#[cfg(windows)]
crate::extract::mark_file_sparse(&file)?;
file.seek(SeekFrom::Start(TEMPLATE_SIZE - 1))?;
file.write_all(&[0])?;
file.sync_all()?;
drop(file);
// Find mkfs.ext4
let mkfs_paths = [
"/opt/homebrew/opt/e2fsprogs/sbin/mkfs.ext4",
"/usr/local/opt/e2fsprogs/sbin/mkfs.ext4",
"/opt/homebrew/sbin/mkfs.ext4",
"/usr/local/sbin/mkfs.ext4",
"/sbin/mkfs.ext4",
"/usr/sbin/mkfs.ext4",
"mkfs.ext4",
];
let mkfs_path = mkfs_paths
.iter()
.find(|p| {
if p.contains('/') {
std::path::Path::new(p).exists()
} else {
Command::new(p).arg("--version").output().is_ok()
}
})
.ok_or_else(|| {
// Windows has no host mkfs.ext4, so point the user at the
// guest-VM recipe for staging a template (the agent rootfs has
// e2fsprogs). On Unix the fix is just installing e2fsprogs.
#[cfg(windows)]
let msg = "storage-template.ext4 not found, and Windows has no host \
mkfs.ext4 to create one. Format a small template inside a guest VM \
once and place it next to smolvm.exe (or in %USERPROFILE%\\.smolvm\\):\n \
smolvm machine create --name mktmpl --volume <dir>:/out\n \
smolvm machine start --name mktmpl\n \
smolvm machine exec --name mktmpl -- /bin/busybox sh -c \
\"truncate -s 512M /out/storage-template.ext4 && \
mkfs.ext4 -F -q -m 0 /out/storage-template.ext4\"\n \
smolvm machine delete --name mktmpl --force\n \
then copy <dir>\\storage-template.ext4 next to smolvm.exe";
#[cfg(not(windows))]
let msg = "mkfs.ext4 not found. Install e2fsprogs or place a pre-formatted \
storage-template.ext4 in ~/.smolvm/";
PackError::AssetNotFound(msg.into())
})?;
// Format with ext4
// Reset SIGCHLD to default before spawning to avoid issues after agent stop
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGCHLD, libc::SIG_DFL);
}
let mut child = Command::new(mkfs_path)
.args([
"-F", // Force (don't ask)
"-q", // Quiet
"-m", "0", // No reserved blocks
"-L", "smolvm", // Label
])
.arg(&template_path)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| PackError::AssetNotFound(format!("failed to spawn mkfs.ext4: {}", e)))?;
let status = child.wait().map_err(|e| {
PackError::AssetNotFound(format!("failed to wait for mkfs.ext4: {}", e))
})?;
if !status.success() {
return Err(PackError::AssetNotFound(
"mkfs.ext4 failed to format storage template".into(),
));
}
// Get actual file size (sparse, so much smaller than 512MB)
let metadata = fs::metadata(&template_path)?;
self.inventory.storage_template = Some(AssetEntry {
path: TEMPLATE_NAME.to_string(),
size: metadata.len(),
});
Ok(())
}
/// Add an overlay disk template from an existing VM.
///
/// Copies the VM's overlay disk (overlay.raw) to the staging directory as
/// `overlay.raw`, stripping trailing sparse holes so only actual data bytes
/// enter the tar/zstd pipeline. For a typical 10 GiB overlay with ~50 MB
/// of real ext4 data this reduces pack time by ~100x.
///
/// The original full size is recorded in `overlay_logical_size` so that
/// the extraction path can restore the sparse skeleton with `ftruncate`.
pub fn add_overlay_template(&mut self, path: &Path) -> Result<()> {
if !path.exists() {
return Err(PackError::AssetNotFound(format!(
"overlay disk not found: {}",
path.display()
)));
}
const OVERLAY_NAME: &str = "overlay.raw";
let dst = self.staging_dir.join(OVERLAY_NAME);
let (logical_size, truncated_size) = sparse_copy_overlay(path, &dst)?;
self.inventory.overlay_template = Some(AssetEntry {
path: OVERLAY_NAME.to_string(),
size: truncated_size,
});
// Record the original full disk size so extract can restore it.
if logical_size > truncated_size {
self.inventory.overlay_logical_size = Some(logical_size);
}
Ok(())
}
/// Get the current asset inventory.
pub fn inventory(&self) -> &AssetInventory {
&self.inventory
}
/// Consume the collector and return the final inventory.
pub fn into_inventory(self) -> AssetInventory {
self.inventory
}
/// Compress staged assets into a single zstd-compressed tarball.
///
/// When `exclude_libs` is true, the `lib/` directory is excluded
/// (two-file mode: libs are embedded in the stub binary instead).
/// When false, everything is included (single-file mode).
pub fn compress(&self, output: &Path, exclude_libs: bool) -> Result<u64> {
let output_file = File::create(output)?;
let encoder = zstd::stream::Encoder::new(output_file, ZSTD_LEVEL)
.map_err(|e| PackError::Compression(e.to_string()))?;
let mut tar_builder = tar::Builder::new(encoder);
// Sort entries for deterministic tar ordering (consistent checksums)
let mut entries: Vec<_> = fs::read_dir(&self.staging_dir)?
.filter_map(|e| e.ok())
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let name = entry.file_name();
if exclude_libs && name == "lib" {
continue; // libs go in the stub, not the sidecar
}
let path = entry.path();
if path.is_dir() {
tar_builder
.append_dir_all(name.to_string_lossy().as_ref(), &path)
.map_err(|e| PackError::Tar(e.to_string()))?;
} else {
tar_builder
.append_path_with_name(&path, name.to_string_lossy().as_ref())
.map_err(|e| PackError::Tar(e.to_string()))?;
}
}
let encoder = tar_builder
.into_inner()
.map_err(|e| PackError::Tar(e.to_string()))?;
encoder
.finish()
.map_err(|e| PackError::Compression(e.to_string()))?;
let metadata = fs::metadata(output)?;
Ok(metadata.len())
}
}
// =============================================================================
// Sparse-aware overlay copy
// =============================================================================
/// Copy a sparse overlay disk to `dst`, stripping trailing zeros.
///
/// Scans backwards from the end of the source to find the last non-zero byte,
/// then copies only bytes `[0, last_nonzero+1]` to `dst`, skipping zero
/// chunks in the forward pass.
///
/// `SEEK_DATA`/`SEEK_HOLE` is avoided because APFS reports zero-fill extents
/// (efficiently stored but containing zeros) as "data", making lseek-based
/// hole detection return the full file size even when 90%+ is zeros.
/// Content scanning works correctly on both APFS and Linux ext4/xfs.
///
/// Returns the original full (logical) size so the caller can record it for
/// the extraction side to restore the sparse skeleton via `ftruncate`.
/// Returns `(logical_size, truncated_size)`.
fn sparse_copy_overlay(src: &Path, dst: &Path) -> std::io::Result<(u64, u64)> {
let mut src_file = File::open(src)?;
let logical_size = src_file.metadata()?.len();
// Scan backwards to find the last non-zero byte (= safe truncation point).
// On APFS, zero-fill regions are served from page cache without disk I/O,
// so even scanning 8+ GiB of trailing zeros takes only ~100200 ms.
let truncated_size = find_last_data_byte(&mut src_file, logical_size)?;
// Create destination as a sparse skeleton; keep the handle for writing.
let mut dst_file = File::create(dst)?;
// On Windows/NTFS, File::create makes a non-sparse file: set_len-ing to the
// (large) truncated size and then writing a chunk at a high offset would
// zero-fill/allocate the entire gap, ballooning a ~50 MB overlay to ~10 GiB
// of real disk. Mark it sparse first so only written extents consume space —
// matching the implicit sparse behavior of Unix filesystems.
#[cfg(windows)]
crate::extract::mark_file_sparse(&dst_file)?;
dst_file.set_len(truncated_size)?;
if truncated_size == 0 {
return Ok((logical_size, 0));
}
// Forward copy: read [0, truncated_size) in 512 KiB chunks,
// writing only non-zero chunks (zero chunks remain as holes).
src_file.seek(SeekFrom::Start(0))?;
let mut buf = vec![0u8; 512 * 1024];
let mut offset: u64 = 0;
while offset < truncated_size {
let to_read = (truncated_size - offset).min(buf.len() as u64) as usize;
let n = src_file.read(&mut buf[..to_read])?;
if n == 0 {
break;
}
let chunk = &buf[..n];
if chunk.iter().any(|&b| b != 0) {
dst_file.seek(SeekFrom::Start(offset))?;
dst_file.write_all(chunk)?;
}
offset += n as u64;
}
Ok((logical_size, truncated_size))
}
/// Find the truncation point: offset of the last non-zero byte + 1.
///
/// Reads the file backwards in 1 MiB chunks until a non-zero byte is found.
/// Returns 0 if the entire file is zeros.
fn find_last_data_byte(file: &mut File, logical_size: u64) -> std::io::Result<u64> {
if logical_size == 0 {
return Ok(0);
}
const CHUNK: u64 = 1024 * 1024; // 1 MiB scan chunk
let mut buf = vec![0u8; CHUNK as usize];
let mut pos = logical_size;
while pos > 0 {
let chunk_start = pos.saturating_sub(CHUNK);
let chunk_size = (pos - chunk_start) as usize;
file.seek(SeekFrom::Start(chunk_start))?;
let n = file.read(&mut buf[..chunk_size])?;
if n == 0 {
break;
}
// Scan backwards for the last non-zero byte in this chunk.
for i in (0..n).rev() {
if buf[i] != 0 {
return Ok(chunk_start + i as u64 + 1);
}
}
pos = chunk_start;
}
Ok(0) // Entire file is zeros
}
/// Decompress a zstd-compressed assets blob.
pub fn decompress_assets(compressed: &[u8], output_dir: &Path) -> Result<()> {
fs::create_dir_all(output_dir)?;
let decoder = zstd::stream::Decoder::new(compressed)
.map_err(|e| PackError::Compression(e.to_string()))?;
let mut archive = tar::Archive::new(decoder);
archive
.unpack(output_dir)
.map_err(|e| PackError::Tar(e.to_string()))?;
Ok(())
}
/// Decompress assets from a file.
pub fn decompress_assets_from_file(compressed_path: &Path, output_dir: &Path) -> Result<()> {
fs::create_dir_all(output_dir)?;
let file = File::open(compressed_path)?;
let decoder =
zstd::stream::Decoder::new(file).map_err(|e| PackError::Compression(e.to_string()))?;
let mut archive = tar::Archive::new(decoder);
archive
.unpack(output_dir)
.map_err(|e| PackError::Tar(e.to_string()))?;
Ok(())
}
/// Calculate CRC32 checksum of data.
pub fn crc32(data: &[u8]) -> u32 {
crc32fast::hash(data)
}
/// Calculate CRC32 checksum of a file.
pub fn crc32_file(path: &Path) -> Result<u32> {
let mut file = File::open(path)?;
let mut hasher = crc32fast::Hasher::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher.finalize())
}
/// Calculate CRC32 checksum of multiple sections of a file.
pub fn crc32_file_range(path: &Path, offset: u64, size: u64) -> Result<u32> {
use std::io::{Seek, SeekFrom};
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(offset))?;
let mut hasher = crc32fast::Hasher::new();
let mut remaining = size;
let mut buf = [0u8; 64 * 1024];
while remaining > 0 {
let to_read = remaining.min(buf.len() as u64) as usize;
let n = file.read(&mut buf[..to_read])?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
remaining -= n as u64;
}
Ok(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn digest_to_filename_rejects_path_traversal() {
// A valid hex digest is accepted.
assert_eq!(
digest_to_filename("sha256:abcdef012345").unwrap(),
"abcdef012345.tar"
);
// Non-hex / traversal digests are rejected before becoming a path.
for bad in [
"sha256:../../../../etc/evil",
"sha256:..%2f..%2fevil",
"sha256:abc/def/ghij",
"sha256:abcdefabcdeZ",
] {
assert!(
digest_to_filename(bad).is_err(),
"should reject non-hex digest: {bad}"
);
}
}
#[test]
fn test_find_last_data_byte_all_zero() {
let temp = tempfile::NamedTempFile::new().unwrap();
fs::write(temp.path(), vec![0u8; 4096]).unwrap();
let mut file = File::open(temp.path()).unwrap();
assert_eq!(find_last_data_byte(&mut file, 4096).unwrap(), 0);
}
#[test]
fn test_find_last_data_byte_trailing_zeros() {
let temp = tempfile::NamedTempFile::new().unwrap();
let mut data = vec![0u8; 4100];
data[99] = 0xAB; // non-zero at 99, then 4000 trailing zeros
fs::write(temp.path(), &data).unwrap();
let mut file = File::open(temp.path()).unwrap();
assert_eq!(find_last_data_byte(&mut file, 4100).unwrap(), 100);
}
#[test]
fn test_find_last_data_byte_nonzero_at_end() {
// Boundary: no trailing zeros — function must return full length.
let temp = tempfile::NamedTempFile::new().unwrap();
let mut data = vec![0u8; 1024];
data[1023] = 1;
fs::write(temp.path(), &data).unwrap();
let mut file = File::open(temp.path()).unwrap();
assert_eq!(find_last_data_byte(&mut file, 1024).unwrap(), 1024);
}
#[test]
fn test_sparse_copy_overlay_all_zero() {
// All-zero source: truncated_size=0, early exit before forward copy.
let temp_dir = tempfile::tempdir().unwrap();
let src = temp_dir.path().join("src.raw");
let dst = temp_dir.path().join("dst.raw");
fs::write(&src, vec![0u8; 8192]).unwrap();
let (logical, truncated) = sparse_copy_overlay(&src, &dst).unwrap();
assert_eq!(logical, 8192);
assert_eq!(truncated, 0);
assert_eq!(fs::metadata(&dst).unwrap().len(), 0);
}
#[test]
fn test_sparse_copy_overlay_trailing_zeros_and_interior_holes() {
// Verifies: trailing zeros are stripped, sizes are returned, interior
// holes (zero chunks in the forward pass) are preserved in the copy.
let temp_dir = tempfile::tempdir().unwrap();
let src = temp_dir.path().join("src.raw");
let dst = temp_dir.path().join("dst.raw");
let mut data = vec![0u8; 8192];
data[0] = 0x01; // non-zero at start
data[511] = 0xFF; // last non-zero byte; trailing 7680 bytes are zeros
fs::write(&src, &data).unwrap();
let (logical, truncated) = sparse_copy_overlay(&src, &dst).unwrap();
assert_eq!(logical, 8192);
assert_eq!(truncated, 512);
let dst_data = fs::read(&dst).unwrap();
assert_eq!(dst_data[0], 0x01);
assert_eq!(dst_data[511], 0xFF);
assert_eq!(dst_data[256], 0x00); // interior zero is preserved
}
#[test]
fn test_crc32_basic() {
let data = b"hello world";
let checksum = crc32(data);
assert_eq!(checksum, 0x0D4A_1185); // Known CRC32 value
}
#[test]
fn test_crc32_empty() {
let data = b"";
let checksum = crc32(data);
assert_eq!(checksum, 0); // CRC32 of empty data is 0
}
#[test]
fn test_asset_collector_staging() {
let temp_dir = tempfile::tempdir().unwrap();
let staging = temp_dir.path().join("staging");
let _collector = AssetCollector::new(staging.clone()).unwrap();
// lib/ is only created when collect_libraries() is called
assert!(!staging.join("lib").exists());
assert!(staging.join("layers").exists());
}
#[test]
fn collect_agent_rootfs_excludes_ready_markers() {
// The agent-rootfs dir doubles as the host's per-boot readiness-marker
// mount (`.smolvm-ready.<hash>`). Those markers must never be packed
// into the guest image — and on a host that ran uid-isolated VMs they
// can be foreign-owned/unreadable, which used to hard-fail the whole
// pack ("tar error: Permission denied"). Verify they're skipped while
// real rootfs content is preserved.
let temp = tempfile::tempdir().unwrap();
let rootfs = temp.path().join("rootfs");
fs::create_dir_all(rootfs.join("bin")).unwrap();
fs::create_dir_all(rootfs.join("etc")).unwrap();
fs::write(rootfs.join("bin/sh"), b"#!/bin/sh\n").unwrap();
fs::write(rootfs.join("etc/hostname"), b"vm\n").unwrap();
fs::write(rootfs.join("init"), b"agent").unwrap();
fs::write(
rootfs.join(format!("{}.deadbeef", smolvm_protocol::AGENT_READY_MARKER)),
b"1",
)
.unwrap();
fs::write(
rootfs.join(format!("{}.cafef00d", smolvm_protocol::AGENT_READY_MARKER)),
b"1",
)
.unwrap();
let staging = temp.path().join("staging");
let mut collector = AssetCollector::new(staging.clone()).unwrap();
collector.collect_agent_rootfs(&rootfs).unwrap();
let tar_path = staging.join("agent-rootfs.tar");
let names: Vec<String> = tar::Archive::new(File::open(&tar_path).unwrap())
.entries()
.unwrap()
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
.collect();
assert!(
names.iter().any(|n| n.ends_with("bin/sh")),
"real rootfs file missing from pack: {names:?}"
);
assert!(
names.iter().any(|n| n.ends_with("etc/hostname")),
"real rootfs file missing from pack: {names:?}"
);
assert!(
names.iter().any(|n| n.ends_with("init")),
"top-level agent binary missing from pack: {names:?}"
);
assert!(
!names.iter().any(|n| n.contains(".smolvm-ready")),
"readiness marker leaked into the pack: {names:?}"
);
}
#[test]
fn test_compression_roundtrip() {
let temp_dir = tempfile::tempdir().unwrap();
let staging = temp_dir.path().join("staging");
let output = temp_dir.path().join("output");
// Create a file in staging
fs::create_dir_all(&staging).unwrap();
let test_file = staging.join("test.txt");
fs::write(&test_file, b"hello world").unwrap();
// Create collector and compress
let collector = AssetCollector::new(staging).unwrap();
let compressed = temp_dir.path().join("assets.tar.zst");
collector.compress(&compressed, false).unwrap();
// Decompress and verify
decompress_assets_from_file(&compressed, &output).unwrap();
let restored = output.join("test.txt");
assert!(restored.exists());
assert_eq!(fs::read_to_string(&restored).unwrap(), "hello world");
}
}
+257
View File
@@ -0,0 +1,257 @@
//! Packed binary auto-detection.
//!
//! Determines whether the current executable is a packed binary and what mode
//! it is running in: section-embedded (macOS), append-embedded, sidecar, or
//! normal CLI.
//!
//! Called at the very start of `main()` before clap parsing so that a packed
//! binary (e.g. `./my-app echo hello`) shows packed-binary help rather
//! than the full smolvm CLI.
use crate::format::{PackFooter, FOOTER_SIZE};
use crate::packer::{read_footer_from_sidecar, sidecar_path_for};
use std::path::{Path, PathBuf};
/// The detected packed binary mode.
pub enum PackedMode {
/// Assets embedded in a Mach-O `__SMOLVM,__smolvm` section (macOS single-file).
#[cfg(target_os = "macos")]
Section {
/// Parsed manifest from the section.
manifest: Box<crate::format::PackManifest>,
/// CRC32 checksum from the section header.
checksum: u32,
/// Pointer to compressed assets in the section (valid for process lifetime).
assets_ptr: *const u8,
/// Size of compressed assets.
assets_size: usize,
},
/// Assets appended to the binary (Linux single-file, or macOS fallback).
Embedded {
/// Path to the current executable.
exe_path: PathBuf,
/// Footer read from end of binary.
footer: PackFooter,
},
/// Assets in a `.smolmachine` sidecar file alongside the binary.
Sidecar {
/// Path to the `.smolmachine` file.
sidecar_path: PathBuf,
/// Footer read from end of sidecar.
footer: PackFooter,
},
}
/// Detect whether this process is running as a packed binary.
///
/// Checks in order:
/// 1. macOS Mach-O section (`__SMOLVM,__smolvm`) with `SMOLSECT` magic
/// 2. `<exe>.smolmachine` sidecar file with valid footer
/// 3. `SMOLPACK` footer appended to own binary with `assets_offset > 0`
///
/// Returns `None` for normal `smolvm` invocations (no false positives).
pub fn detect_packed_mode() -> Option<PackedMode> {
let exe_path = std::env::current_exe().ok()?;
// 1. macOS section mode
#[cfg(target_os = "macos")]
{
if let Some(mode) = try_section_mode() {
return Some(mode);
}
}
// 2. Sidecar mode: <exe>.smolmachine next to binary
if let Some(mode) = try_sidecar_mode(&exe_path) {
return Some(mode);
}
// 3. Embedded-append mode: SMOLPACK footer at end of own binary
if let Some(mode) = try_embedded_mode(&exe_path) {
return Some(mode);
}
None
}
fn try_sidecar_mode(exe_path: &Path) -> Option<PackedMode> {
let sidecar = sidecar_path_for(exe_path);
if !sidecar.exists() {
return None;
}
let footer = read_footer_from_sidecar(&sidecar).ok()?;
Some(PackedMode::Sidecar {
sidecar_path: sidecar,
footer,
})
}
fn try_embedded_mode(exe_path: &Path) -> Option<PackedMode> {
let footer = read_footer_direct(exe_path).ok()?;
// assets_offset > 0 means assets are appended after the stub in the binary
if footer.assets_offset > 0 {
Some(PackedMode::Embedded {
exe_path: exe_path.to_path_buf(),
footer,
})
} else {
None
}
}
/// Read footer directly from end of file without trying sidecar first.
///
/// Unlike `packer::read_footer()` which checks for a sidecar, this reads
/// the last 64 bytes of the given file and parses them as a `PackFooter`.
fn read_footer_direct(path: &Path) -> crate::Result<PackFooter> {
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
let mut file = File::open(path)?;
let file_size = file.metadata()?.len();
if file_size < FOOTER_SIZE as u64 {
return Err(crate::PackError::InvalidMagic);
}
file.seek(SeekFrom::End(-(FOOTER_SIZE as i64)))?;
let mut buf = [0u8; FOOTER_SIZE];
file.read_exact(&mut buf)?;
PackFooter::from_bytes(&buf)
}
// ---------------------------------------------------------------------------
// macOS Mach-O section reading
// ---------------------------------------------------------------------------
#[cfg(target_os = "macos")]
fn try_section_mode() -> Option<PackedMode> {
let embedded = read_embedded_section()?;
Some(PackedMode::Section {
manifest: Box::new(embedded.manifest),
checksum: embedded.header.checksum,
assets_ptr: embedded.assets_ptr,
assets_size: embedded.assets_size,
})
}
/// Data read from the `__SMOLVM,__smolvm` Mach-O section.
#[cfg(target_os = "macos")]
struct EmbeddedData {
header: crate::format::SectionHeader,
manifest: crate::format::PackManifest,
assets_ptr: *const u8,
assets_size: usize,
}
/// Try to read embedded data from the `__SMOLVM,__smolvm` Mach-O section.
///
/// Returns `None` if:
/// - Not running on macOS
/// - Section doesn't exist
/// - Section contains only the build-time placeholder (not `SMOLSECT` magic)
#[cfg(target_os = "macos")]
fn read_embedded_section() -> Option<EmbeddedData> {
use crate::format::{PackManifest, SectionHeader, SECTION_HEADER_SIZE, SECTION_MAGIC};
extern "C" {
fn getsectiondata(
mhp: *const MachHeader64,
segname: *const i8,
sectname: *const i8,
size: *mut usize,
) -> *const u8;
}
#[repr(C)]
struct MachHeader64 {
magic: u32,
cputype: i32,
cpusubtype: i32,
filetype: u32,
ncmds: u32,
sizeofcmds: u32,
flags: u32,
reserved: u32,
}
extern "C" {
fn _dyld_get_image_header(image_index: u32) -> *const MachHeader64;
}
unsafe {
let header = _dyld_get_image_header(0);
if header.is_null() {
return None;
}
let segname = c"__SMOLVM";
let sectname = c"__smolvm";
let mut size: usize = 0;
let data_ptr = getsectiondata(header, segname.as_ptr(), sectname.as_ptr(), &mut size);
if data_ptr.is_null() || size < SECTION_HEADER_SIZE {
return None;
}
// Check for SMOLSECT magic (placeholder contains different marker)
let magic_bytes = std::slice::from_raw_parts(data_ptr, 8);
if magic_bytes != SECTION_MAGIC {
return None;
}
// Parse section header
let header_bytes = std::slice::from_raw_parts(data_ptr, SECTION_HEADER_SIZE);
let section_header = SectionHeader::from_bytes(header_bytes).ok()?;
// Validate sizes
let expected_size = SECTION_HEADER_SIZE
+ section_header.manifest_size as usize
+ section_header.assets_size as usize;
if size < expected_size {
return None;
}
// Parse manifest
let manifest_start = data_ptr.add(SECTION_HEADER_SIZE);
let manifest_bytes =
std::slice::from_raw_parts(manifest_start, section_header.manifest_size as usize);
let manifest = PackManifest::from_json(manifest_bytes).ok()?;
// Assets follow the manifest
let assets_ptr = manifest_start.add(section_header.manifest_size as usize);
Some(EmbeddedData {
header: section_header,
manifest,
assets_ptr,
assets_size: section_header.assets_size as usize,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_returns_none_for_normal_binary() {
// The test binary is a normal Rust executable, not a packed binary.
assert!(detect_packed_mode().is_none());
}
#[test]
fn test_read_footer_direct_rejects_short_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("short.bin");
std::fs::write(&path, b"too short").unwrap();
assert!(read_footer_direct(&path).is_err());
}
#[test]
fn test_read_footer_direct_rejects_invalid_magic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("no_magic.bin");
std::fs::write(&path, [0u8; 128]).unwrap();
assert!(read_footer_direct(&path).is_err());
}
}
File diff suppressed because it is too large Load Diff
+653
View File
@@ -0,0 +1,653 @@
//! `.smolmachine` binary format specification.
//!
//! # Overview
//!
//! A `.smolmachine` is a portable, self-contained microVM artifact. It bundles
//! everything needed to run a workload: OCI image layers, the agent rootfs,
//! runtime libraries (libkrun), and a manifest describing the configuration.
//!
//! # File Layout
//!
//! A `.smolmachine` file is a zstd-compressed tar archive with a JSON manifest
//! appended as a footer. The manifest is also stored inside the OCI registry
//! as the config blob when pushed.
//!
//! ```text
//! +---------------------------+
//! | Assets Blob (zstd tar) | 30-150 MB
//! | - agent-rootfs.tar | Guest init system
//! | - layers/*.tar | OCI image layers
//! | - lib/libkrun.* | Runtime libraries (platform-specific)
//! | - storage.ext4 (opt) | Pre-formatted disk template
//! | - overlay.raw (opt) | VM snapshot (VM mode only)
//! +---------------------------+
//! | Manifest (JSON) | ~2 KB (PackManifest)
//! +---------------------------+
//! | Footer (64 bytes) |
//! | - magic: "SMOLPACK" |
//! | - version: 1 |
//! | - offsets + sizes |
//! | - CRC32 checksum |
//! +---------------------------+
//! ```
//!
//! # OCI Registry Representation
//!
//! When pushed to an OCI registry, a `.smolmachine` is stored as:
//! - **Config blob**: `PackManifest` JSON (`application/vnd.smolmachines.machine.config.v1+json`)
//! - **Layer blob**: The full `.smolmachine` file (`application/vnd.smolmachines.smolmachine.v1`)
//! - **Manifest**: Standard OCI Image Manifest referencing both blobs
//!
//! # Execution Modes
//!
//! - **Container mode** (default): OCI image layers are unpacked and run via crun.
//! - **VM mode**: An overlay disk snapshot is restored directly into the VM.
use serde::{Deserialize, Serialize};
use crate::{PackError, Result};
/// Magic bytes identifying a packed smolvm binary.
pub const MAGIC: &[u8; 8] = b"SMOLPACK";
/// Magic bytes for embedded section header.
pub const SECTION_MAGIC: &[u8; 8] = b"SMOLSECT";
/// Magic bytes for libs footer appended to the stub binary.
pub const LIBS_MAGIC: &[u8; 8] = b"SMOLLIBS";
/// Current format version.
pub const FORMAT_VERSION: u32 = 1;
/// Extension for sidecar assets file.
pub const SIDECAR_EXTENSION: &str = ".smolmachine";
/// Footer size in bytes (fixed).
pub const FOOTER_SIZE: usize = 64;
/// Embedded section header size (fixed).
pub const SECTION_HEADER_SIZE: usize = 32;
/// Libs footer size in bytes (fixed).
pub const LIBS_FOOTER_SIZE: usize = 32;
/// Header for data embedded in the __SMOLVM,__smolvm Mach-O section.
///
/// This format is used for macOS single-file binaries where assets are
/// stored inside the executable's Mach-O structure, allowing proper code signing.
///
/// Layout (32 bytes total):
/// ```text
/// Offset Size Field
/// 0 8 magic ("SMOLSECT")
/// 8 4 version (u32 LE)
/// 12 4 manifest_size (u32 LE)
/// 16 8 assets_size (u64 LE)
/// 24 4 checksum (u32 LE)
/// 28 4 reserved (zeroes)
/// ```
///
/// Following the header:
/// - Manifest JSON (manifest_size bytes)
/// - Compressed assets (assets_size bytes)
#[derive(Debug, Clone, Copy)]
pub struct SectionHeader {
/// Size of manifest JSON in bytes.
pub manifest_size: u32,
/// Size of compressed assets in bytes.
pub assets_size: u64,
/// CRC32 checksum of manifest + assets.
pub checksum: u32,
}
impl SectionHeader {
/// Serialize header to bytes.
pub fn to_bytes(&self) -> [u8; SECTION_HEADER_SIZE] {
let mut buf = [0u8; SECTION_HEADER_SIZE];
// Magic
buf[0..8].copy_from_slice(SECTION_MAGIC);
// Version
buf[8..12].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
// Manifest size
buf[12..16].copy_from_slice(&self.manifest_size.to_le_bytes());
// Assets size
buf[16..24].copy_from_slice(&self.assets_size.to_le_bytes());
// Checksum
buf[24..28].copy_from_slice(&self.checksum.to_le_bytes());
// Reserved (already zeroed)
buf
}
/// Deserialize header from bytes.
pub fn from_bytes(buf: &[u8]) -> Result<Self> {
if buf.len() < SECTION_HEADER_SIZE {
return Err(PackError::InvalidMagic);
}
// Validate magic
if &buf[0..8] != SECTION_MAGIC {
return Err(PackError::InvalidMagic);
}
// Check version
let version = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
if version != FORMAT_VERSION {
return Err(PackError::UnsupportedVersion(version));
}
Ok(Self {
manifest_size: u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]),
assets_size: u64::from_le_bytes([
buf[16], buf[17], buf[18], buf[19], buf[20], buf[21], buf[22], buf[23],
]),
checksum: u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]),
})
}
}
/// Footer appended to the stub binary to locate embedded runtime libraries.
///
/// The stub reads its own last 32 bytes to find the compressed libs bundle,
/// extracts them to a cache directory, and dlopen's libkrun from there.
/// This keeps the .smolmachine sidecar cross-platform (no platform-specific libs).
///
/// Layout (32 bytes total):
/// ```text
/// Offset Size Field
/// 0 8 magic ("SMOLLIBS")
/// 8 4 version (u32 LE)
/// 12 8 libs_offset (u64 LE) - offset to compressed libs blob
/// 20 8 libs_size (u64 LE) - size of compressed libs blob
/// 28 4 reserved (zeroes)
/// ```
#[derive(Debug, Clone, Copy)]
pub struct LibsFooter {
/// Offset from start of file to the compressed libs blob.
pub libs_offset: u64,
/// Size of the compressed libs blob.
pub libs_size: u64,
}
impl LibsFooter {
/// Serialize footer to bytes.
pub fn to_bytes(&self) -> [u8; LIBS_FOOTER_SIZE] {
let mut buf = [0u8; LIBS_FOOTER_SIZE];
buf[0..8].copy_from_slice(LIBS_MAGIC);
buf[8..12].copy_from_slice(&1u32.to_le_bytes()); // version 1
buf[12..20].copy_from_slice(&self.libs_offset.to_le_bytes());
buf[20..28].copy_from_slice(&self.libs_size.to_le_bytes());
// 28..32 reserved (zeroed)
buf
}
/// Deserialize footer from bytes.
pub fn from_bytes(buf: &[u8; LIBS_FOOTER_SIZE]) -> Result<Self> {
if &buf[0..8] != LIBS_MAGIC {
return Err(PackError::InvalidMagic);
}
let version = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
if version != 1 {
return Err(PackError::UnsupportedVersion(version));
}
Ok(Self {
libs_offset: u64::from_le_bytes([
buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19],
]),
libs_size: u64::from_le_bytes([
buf[20], buf[21], buf[22], buf[23], buf[24], buf[25], buf[26], buf[27],
]),
})
}
}
/// Fixed-size footer at the end of a packed binary.
///
/// Layout (64 bytes total):
/// ```text
/// Offset Size Field
/// 0 8 magic ("SMOLPACK")
/// 8 4 version (u32 LE)
/// 12 8 stub_size (u64 LE) - size of stub executable
/// 20 8 assets_offset (u64 LE) - offset to compressed assets
/// 28 8 assets_size (u64 LE) - size of compressed assets
/// 36 8 manifest_offset (u64 LE) - offset to manifest JSON
/// 44 8 manifest_size (u64 LE) - size of manifest JSON
/// 52 4 checksum (u32 LE) - CRC32 of assets + manifest
/// 56 8 reserved (zeroes)
/// ```
#[derive(Debug, Clone, Copy)]
pub struct PackFooter {
/// Size of the stub executable.
pub stub_size: u64,
/// Offset to compressed assets blob.
pub assets_offset: u64,
/// Size of compressed assets blob.
pub assets_size: u64,
/// Offset to manifest JSON.
pub manifest_offset: u64,
/// Size of manifest JSON.
pub manifest_size: u64,
/// CRC32 checksum of assets + manifest.
pub checksum: u32,
}
impl PackFooter {
/// Serialize footer to bytes.
pub fn to_bytes(&self) -> [u8; FOOTER_SIZE] {
let mut buf = [0u8; FOOTER_SIZE];
// Magic
buf[0..8].copy_from_slice(MAGIC);
// Version
buf[8..12].copy_from_slice(&FORMAT_VERSION.to_le_bytes());
// Stub size
buf[12..20].copy_from_slice(&self.stub_size.to_le_bytes());
// Assets offset and size
buf[20..28].copy_from_slice(&self.assets_offset.to_le_bytes());
buf[28..36].copy_from_slice(&self.assets_size.to_le_bytes());
// Manifest offset and size
buf[36..44].copy_from_slice(&self.manifest_offset.to_le_bytes());
buf[44..52].copy_from_slice(&self.manifest_size.to_le_bytes());
// Checksum
buf[52..56].copy_from_slice(&self.checksum.to_le_bytes());
// Reserved (already zeroed)
buf
}
/// Deserialize footer from bytes.
pub fn from_bytes(buf: &[u8; FOOTER_SIZE]) -> Result<Self> {
// Validate magic
if &buf[0..8] != MAGIC {
return Err(PackError::InvalidMagic);
}
// Check version
let version = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
if version != FORMAT_VERSION {
return Err(PackError::UnsupportedVersion(version));
}
Ok(Self {
stub_size: u64::from_le_bytes([
buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19],
]),
assets_offset: u64::from_le_bytes([
buf[20], buf[21], buf[22], buf[23], buf[24], buf[25], buf[26], buf[27],
]),
assets_size: u64::from_le_bytes([
buf[28], buf[29], buf[30], buf[31], buf[32], buf[33], buf[34], buf[35],
]),
manifest_offset: u64::from_le_bytes([
buf[36], buf[37], buf[38], buf[39], buf[40], buf[41], buf[42], buf[43],
]),
manifest_size: u64::from_le_bytes([
buf[44], buf[45], buf[46], buf[47], buf[48], buf[49], buf[50], buf[51],
]),
checksum: u32::from_le_bytes([buf[52], buf[53], buf[54], buf[55]]),
})
}
}
/// Execution mode for packed binaries.
///
/// Determines how commands are executed at runtime:
/// - `Container`: commands run inside a crun container (OCI layers)
/// - `Vm`: commands run directly in the VM rootfs (overlay disk)
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum PackMode {
/// Container mode: OCI image layers + crun container execution.
#[default]
Container,
/// VM mode: overlay disk + direct VM execution.
Vm,
}
/// Manifest describing the packed image and configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackManifest {
/// Execution mode (container or VM).
#[serde(default)]
pub mode: PackMode,
/// Original image reference (e.g., "alpine:latest").
pub image: String,
/// Image digest (sha256:...).
pub digest: String,
/// Target platform (e.g., "linux/arm64").
pub platform: String,
/// Entrypoint command (from image config or override).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entrypoint: Vec<String>,
/// Default command arguments (from image config or override).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cmd: Vec<String>,
/// Default environment variables.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<String>,
/// Secret references carried with the pack. Each maps an env var name to a
/// reference (host store entry, host env var, or file) — never an inline
/// value. The plaintext is resolved on the run host at exec time, so a
/// `.smolmachine` never contains secret material at rest.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub secret_refs: std::collections::BTreeMap<String, smolvm_protocol::SecretRef>,
/// Working directory (from image config or override).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workdir: Option<String>,
/// Default number of vCPUs.
pub cpus: u8,
/// Default memory in MiB.
pub mem: u32,
/// Total extracted (on-disk) image size in bytes.
/// Used to auto-size the storage disk at runtime.
#[serde(default)]
pub image_size: u64,
/// Whether outbound networking is enabled by default.
#[serde(default)]
pub network: bool,
/// Enable GPU acceleration (Vulkan via virtio-gpu).
#[serde(default)]
pub gpu: bool,
/// Host platform this .smolmachine runs on (e.g., "darwin/arm64").
/// Distinct from `platform` which is the guest architecture (always linux).
/// Used for registry Image Index resolution.
pub host_platform: String,
/// RFC 3339 timestamp when this machine was packed.
pub created: String,
/// smolvm version that built this machine (e.g., "0.1.15").
pub smolvm_version: String,
/// Asset inventory - files included in the assets blob.
pub assets: AssetInventory,
}
/// Inventory of assets included in the packed binary.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetInventory {
/// Runtime libraries (relative paths within assets).
pub libraries: Vec<AssetEntry>,
/// Agent rootfs tarball.
pub agent_rootfs: AssetEntry,
/// OCI layer tarballs.
pub layers: Vec<LayerEntry>,
/// Pre-formatted storage disk template (optional).
/// When present, copied to cache on first run instead of formatting at runtime.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage_template: Option<AssetEntry>,
/// Overlay disk template (optional, VM mode only).
/// Contains the VM's persistent rootfs state from a `--from-vm` pack.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overlay_template: Option<AssetEntry>,
/// Original sparse size of the overlay disk, in bytes (optional, VM mode only).
///
/// When set, the overlay.raw entry in the archive is a truncated copy with
/// the trailing sparse hole removed. On extraction the file is extended to
/// this size via `ftruncate`, restoring the sparse skeleton the VM expects.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overlay_logical_size: Option<u64>,
}
/// An asset file entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetEntry {
/// Path within the assets archive.
pub path: String,
/// Uncompressed size in bytes.
pub size: u64,
}
/// An OCI layer entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayerEntry {
/// Layer digest (sha256:...).
pub digest: String,
/// Path within the assets archive.
pub path: String,
/// Uncompressed size in bytes.
pub size: u64,
}
/// Generate an RFC 3339 timestamp for the current time in UTC.
fn rfc3339_now() -> String {
let now = time::OffsetDateTime::now_utc();
now.format(&time::format_description::well_known::Rfc3339)
.expect("RFC 3339 formatting should never fail for a valid OffsetDateTime")
}
impl PackManifest {
/// Create a new manifest with default values.
pub fn new(image: String, digest: String, platform: String, host_platform: String) -> Self {
Self {
mode: PackMode::default(),
image,
digest,
platform,
entrypoint: Vec::new(),
cmd: Vec::new(),
env: Vec::new(),
secret_refs: std::collections::BTreeMap::new(),
workdir: None,
cpus: 1,
mem: 256,
image_size: 0,
network: false,
gpu: false,
host_platform,
created: rfc3339_now(),
smolvm_version: env!("CARGO_PKG_VERSION").to_string(),
assets: AssetInventory {
libraries: Vec::new(),
agent_rootfs: AssetEntry {
path: "agent-rootfs.tar".to_string(),
size: 0,
},
layers: Vec::new(),
storage_template: None,
overlay_template: None,
overlay_logical_size: None,
},
}
}
/// Serialize manifest to JSON.
pub fn to_json(&self) -> Result<Vec<u8>> {
Ok(serde_json::to_vec_pretty(self)?)
}
/// Deserialize manifest from JSON.
pub fn from_json(data: &[u8]) -> Result<Self> {
Ok(serde_json::from_slice(data)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_secret_refs_roundtrip() {
// Secret refs survive a JSON round-trip through the manifest, and are
// omitted entirely when empty (skip_serializing_if) so existing packs
// stay byte-compatible.
let empty = PackManifest::new(
"alpine".to_string(),
"sha256:abc".to_string(),
"linux/arm64".to_string(),
"linux/arm64".to_string(),
);
let json = String::from_utf8(empty.to_json().unwrap()).unwrap();
assert!(
!json.contains("secret_refs"),
"empty secret_refs must not be serialized"
);
let mut m = empty;
m.secret_refs.insert(
"DB_PASSWORD".to_string(),
smolvm_protocol::SecretRef {
from_env: Some("PGPASSWORD".to_string()),
from_file: None,
},
);
let restored = PackManifest::from_json(&m.to_json().unwrap()).unwrap();
assert_eq!(restored.secret_refs.len(), 1);
assert_eq!(
restored.secret_refs["DB_PASSWORD"].from_env.as_deref(),
Some("PGPASSWORD")
);
}
#[test]
fn test_footer_roundtrip() {
let footer = PackFooter {
stub_size: 512 * 1024,
assets_offset: 512 * 1024,
assets_size: 50 * 1024 * 1024,
manifest_offset: 512 * 1024 + 50 * 1024 * 1024,
manifest_size: 2048,
checksum: 0xDEADBEEF,
};
let bytes = footer.to_bytes();
assert_eq!(bytes.len(), FOOTER_SIZE);
let restored = PackFooter::from_bytes(&bytes).unwrap();
assert_eq!(restored.stub_size, footer.stub_size);
assert_eq!(restored.assets_offset, footer.assets_offset);
assert_eq!(restored.assets_size, footer.assets_size);
assert_eq!(restored.manifest_offset, footer.manifest_offset);
assert_eq!(restored.manifest_size, footer.manifest_size);
assert_eq!(restored.checksum, footer.checksum);
}
#[test]
fn test_footer_invalid_magic() {
let mut bytes = [0u8; FOOTER_SIZE];
bytes[0..8].copy_from_slice(b"BADMAGIC");
let result = PackFooter::from_bytes(&bytes);
assert!(matches!(result, Err(PackError::InvalidMagic)));
}
#[test]
fn test_footer_unsupported_version() {
let mut bytes = [0u8; FOOTER_SIZE];
bytes[0..8].copy_from_slice(MAGIC);
bytes[8..12].copy_from_slice(&99u32.to_le_bytes()); // Bad version
let result = PackFooter::from_bytes(&bytes);
assert!(matches!(result, Err(PackError::UnsupportedVersion(99))));
}
#[test]
fn test_manifest_roundtrip() {
let mut manifest = PackManifest::new(
"alpine:latest".to_string(),
"sha256:abc123".to_string(),
"linux/arm64".to_string(),
"darwin/arm64".to_string(),
);
manifest.cpus = 2;
manifest.mem = 1024;
manifest.entrypoint = vec!["/bin/sh".to_string()];
manifest.env = vec!["PATH=/usr/local/bin:/usr/bin:/bin".to_string()];
manifest.assets.libraries.push(AssetEntry {
path: "lib/libkrun.dylib".to_string(),
size: 4 * 1024 * 1024,
});
let json = manifest.to_json().unwrap();
let restored = PackManifest::from_json(&json).unwrap();
assert_eq!(restored.image, "alpine:latest");
assert_eq!(restored.digest, "sha256:abc123");
assert_eq!(restored.cpus, 2);
assert_eq!(restored.mem, 1024);
assert_eq!(restored.entrypoint, vec!["/bin/sh"]);
assert_eq!(restored.assets.libraries.len(), 1);
}
#[test]
fn test_manifest_json_format() {
let manifest = PackManifest::new(
"ubuntu:22.04".to_string(),
"sha256:def456".to_string(),
"linux/amd64".to_string(),
"linux/amd64".to_string(),
);
let json = String::from_utf8(manifest.to_json().unwrap()).unwrap();
assert!(json.contains("\"image\": \"ubuntu:22.04\""));
assert!(json.contains("\"platform\": \"linux/amd64\""));
// Phase 0 fields: verify key names serialize correctly
assert!(json.contains("\"host_platform\": \"linux/amd64\""));
assert!(json.contains("\"smolvm_version\""));
assert!(json.contains("\"created\""));
}
#[test]
fn test_pack_mode_default_is_container() {
assert_eq!(PackMode::default(), PackMode::Container);
}
#[test]
fn test_pack_mode_vm_roundtrip() {
let mut manifest = PackManifest::new(
"vm://myvm".to_string(),
"none".to_string(),
"linux/arm64".to_string(),
"darwin/arm64".to_string(),
);
manifest.mode = PackMode::Vm;
manifest.assets.overlay_template = Some(AssetEntry {
path: "overlay.raw".to_string(),
size: 2 * 1024 * 1024 * 1024,
});
let json = manifest.to_json().unwrap();
let restored = PackManifest::from_json(&json).unwrap();
assert_eq!(restored.mode, PackMode::Vm);
assert!(restored.assets.overlay_template.is_some());
assert_eq!(
restored.assets.overlay_template.unwrap().path,
"overlay.raw"
);
}
}
+78
View File
@@ -0,0 +1,78 @@
//! `.smolmachine` packaging for smolvm.
//!
//! This crate provides functionality to package an OCI image and all runtime assets
//! into a portable `.smolmachine` artifact that can be pushed to a registry,
//! distributed, and run without smolvm installed.
//!
//! See [`format`] for the full binary format specification.
#![deny(missing_docs)]
pub mod assets;
pub mod detect;
pub mod extract;
pub mod format;
#[cfg(target_os = "macos")]
pub mod macho;
pub mod packer;
pub mod signing;
pub use detect::{detect_packed_mode, PackedMode};
pub use format::{
PackFooter, PackManifest, PackMode, SectionHeader, FOOTER_SIZE, MAGIC, SECTION_HEADER_SIZE,
SECTION_MAGIC, SIDECAR_EXTENSION,
};
pub use packer::{
read_footer, read_footer_from_sidecar, read_manifest, read_manifest_from_sidecar,
sidecar_path_for, verify_sidecar_checksum, Packer,
};
use thiserror::Error;
/// Errors that can occur during pack operations.
#[derive(Debug, Error)]
pub enum PackError {
/// I/O error.
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// JSON serialization error.
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
/// Invalid magic bytes in footer.
#[error("invalid magic: expected SMOLPACK")]
InvalidMagic,
/// Unsupported format version.
#[error("unsupported version: {0}")]
UnsupportedVersion(u32),
/// Checksum mismatch.
#[error("checksum mismatch: expected {expected:08x}, got {actual:08x}")]
ChecksumMismatch {
/// Expected checksum.
expected: u32,
/// Actual checksum.
actual: u32,
},
/// Asset not found.
#[error("asset not found: {0}")]
AssetNotFound(String),
/// Compression error.
#[error("compression error: {0}")]
Compression(String),
/// Signing error.
#[error("signing error: {0}")]
Signing(String),
/// Tar archive error.
#[error("tar error: {0}")]
Tar(String),
}
/// Result type for pack operations.
pub type Result<T> = std::result::Result<T, PackError>;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
//! Code signing support for packed executables.
//!
//! On macOS, executables that use Hypervisor.framework must be signed
//! with the appropriate entitlements.
use std::path::Path;
#[cfg(target_os = "macos")]
use std::fs;
#[cfg(target_os = "macos")]
use std::process::Command;
#[cfg(target_os = "macos")]
use crate::PackError;
use crate::Result;
/// Default entitlements for packed binaries on macOS.
///
/// - `hypervisor`: access to Hypervisor.framework for the microVM
/// - `cs.disable-library-validation`: allow dlopen of libkrun from
/// the extracted cache directory (ad-hoc signed libraries)
/// - `cs.allow-jit`: a forkable golden (`machine start --forkable`) maps guest
/// RAM from a file-backed region that HVF executes guest code from; under the
/// hardened runtime that needs a JIT entitlement or `krun_start_enter` returns
/// -22. (`allow-unsigned-executable-memory` also works; `allow-jit` is the
/// narrower capability. Harmless for non-forkable VMs, which use anon RAM.)
#[cfg(target_os = "macos")]
const HYPERVISOR_ENTITLEMENTS: &str = r#"<?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/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>
"#;
/// Sign a binary with hypervisor entitlements (macOS only).
///
/// This uses ad-hoc signing (no certificate required) which is sufficient
/// for local development and distribution.
#[cfg(target_os = "macos")]
pub fn sign_with_hypervisor_entitlements(binary_path: &Path) -> Result<()> {
// Reset SIGCHLD to default before spawning codesign.
// The agent VM startup installs a SIGCHLD handler that reaps all children
// via waitpid(-1), which races with Command::output()'s internal waitpid
// and causes ECHILD (os error 10).
unsafe {
libc::signal(libc::SIGCHLD, libc::SIG_DFL);
}
// Create temporary entitlements file
let temp_dir = tempfile::tempdir()?;
let entitlements_path = temp_dir.path().join("entitlements.plist");
fs::write(&entitlements_path, HYPERVISOR_ENTITLEMENTS)?;
// Run codesign
let output = Command::new("codesign")
.args([
"--force",
"--sign",
"-", // Ad-hoc signing
"--entitlements",
])
.arg(&entitlements_path)
.arg(binary_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PackError::Signing(format!(
"codesign failed: {}",
stderr.trim()
)));
}
Ok(())
}
/// Sign a binary with hypervisor entitlements (no-op on non-macOS).
#[cfg(not(target_os = "macos"))]
pub fn sign_with_hypervisor_entitlements(_binary_path: &Path) -> Result<()> {
// No signing needed on Linux
Ok(())
}
/// Check if a binary is signed (macOS only).
#[cfg(target_os = "macos")]
pub fn is_signed(binary_path: &Path) -> Result<bool> {
let output = Command::new("codesign")
.args(["--verify", "--verbose"])
.arg(binary_path)
.output()?;
Ok(output.status.success())
}
/// Check if a binary is signed (always false on non-macOS).
#[cfg(not(target_os = "macos"))]
pub fn is_signed(_binary_path: &Path) -> Result<bool> {
Ok(false)
}
/// Get signature information for a binary (macOS only).
#[cfg(target_os = "macos")]
pub fn get_signature_info(binary_path: &Path) -> Result<Option<SignatureInfo>> {
let output = Command::new("codesign")
.args(["--display", "--verbose=2"])
.arg(binary_path)
.output()?;
if !output.status.success() {
return Ok(None);
}
let stderr = String::from_utf8_lossy(&output.stderr);
let is_adhoc = stderr.contains("Signature=adhoc");
Ok(Some(SignatureInfo {
is_adhoc,
raw_output: stderr.to_string(),
}))
}
/// Get signature information (always None on non-macOS).
#[cfg(not(target_os = "macos"))]
pub fn get_signature_info(_binary_path: &Path) -> Result<Option<SignatureInfo>> {
Ok(None)
}
/// Information about a code signature.
#[derive(Debug, Clone)]
pub struct SignatureInfo {
/// Whether this is ad-hoc signed.
pub is_adhoc: bool,
/// Raw codesign output.
pub raw_output: String,
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
#[cfg(target_os = "macos")]
fn test_sign_binary() {
let temp_dir = tempfile::tempdir().unwrap();
let binary_path = temp_dir.path().join("test_binary");
// Create a minimal Mach-O binary (just the header)
// This is a valid but non-functional binary that codesign can process
let mut file = fs::File::create(&binary_path).unwrap();
// Minimal arm64 Mach-O header
let macho_header: [u8; 32] = [
0xCF, 0xFA, 0xED, 0xFE, // Magic (64-bit)
0x0C, 0x00, 0x00, 0x01, // CPU type (ARM64)
0x00, 0x00, 0x00, 0x00, // CPU subtype
0x02, 0x00, 0x00, 0x00, // File type (MH_EXECUTE)
0x00, 0x00, 0x00, 0x00, // Number of load commands
0x00, 0x00, 0x00, 0x00, // Size of load commands
0x00, 0x00, 0x00, 0x00, // Flags
0x00, 0x00, 0x00, 0x00, // Reserved
];
file.write_all(&macho_header).unwrap();
drop(file);
// Make executable
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&binary_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&binary_path, perms).unwrap();
}
// Sign it
let result = sign_with_hypervisor_entitlements(&binary_path);
// This may fail if the binary isn't valid enough for codesign,
// but we're testing that the function runs without panicking
if let Err(e) = &result {
eprintln!("Signing failed (expected for minimal test binary): {}", e);
}
}
#[test]
#[cfg(target_os = "macos")]
fn test_entitlements_format() {
// Verify the entitlements XML is valid
assert!(HYPERVISOR_ENTITLEMENTS.contains("com.apple.security.hypervisor"));
assert!(HYPERVISOR_ENTITLEMENTS.contains("cs.disable-library-validation"));
assert!(HYPERVISOR_ENTITLEMENTS.contains("<true/>"));
}
}
+176
View File
@@ -0,0 +1,176 @@
//! End-to-end test of the shared, content-addressed pack extraction
//! (`extract_sidecar_shared`) against a *real* `.smolmachine` sidecar — real
//! zstd stream, real tar, real footer/checksum — on a real filesystem.
//!
//! Linux-only: the shared store backs the Linux fleet's per-VM idmapped bind
//! mount (kernel ≥5.12); macOS uses a per-machine case-sensitive sparse image,
//! so the whole module compiles to nothing elsewhere. This validates the
//! *extraction half* (extract-once, content-addressed, locked down); the mount
//! half is validated by the root crate's `tests/idmap_mount.rs`.
#![cfg(target_os = "linux")]
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use smolvm_pack::assets::AssetCollector;
use smolvm_pack::extract::{extract_sidecar, extract_sidecar_shared, shared_extract_enabled};
use smolvm_pack::format::PackManifest;
use smolvm_pack::{read_footer_from_sidecar, sidecar_path_for, Packer};
/// Build a tiny but *valid* OCI-style layer: a raw tar archive carrying one
/// file. `post_process_extraction` untars every `layers/*.tar`, so the payload
/// must be a real tar (a raw byte blob would fail with "failed to read entire
/// block" — exactly what a real OCI layer never does).
fn layer_tar(filename: &str, content: &[u8]) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Regular);
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, filename, content).unwrap();
builder.into_inner().unwrap()
}
/// Build a real sidecar `.smolmachine` carrying two OCI layers, returning the
/// sidecar path (under `dir`).
fn build_real_sidecar(dir: &Path) -> std::path::PathBuf {
let stub_path = dir.join("stub");
fs::write(&stub_path, b"#!/bin/sh\necho stub").unwrap();
let mut collector = AssetCollector::new(dir.join("staging")).unwrap();
collector
.add_layer(
"sha256:aaa111aaa111bbb222",
&layer_tar("etc/base.conf", b"base layer file"),
)
.unwrap();
collector
.add_layer(
"sha256:ccc333ccc333ddd444",
&layer_tar("etc/top.conf", b"top layer file"),
)
.unwrap();
let manifest = PackManifest::new(
"test:latest".to_string(),
"sha256:test".to_string(),
"linux/x86_64".to_string(),
"linux/x86_64".to_string(),
);
let output = dir.join("packed");
Packer::new(manifest)
.with_stub(&stub_path)
.with_assets(collector)
.pack(&output)
.unwrap();
sidecar_path_for(&output)
}
/// Recursively collect (relative-path -> file bytes) for every regular file in a
/// tree, so the shared extraction can be compared byte-for-byte to a plain one.
fn file_map(root: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
let mut out = std::collections::BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(d) = stack.pop() {
for entry in fs::read_dir(&d).unwrap() {
let entry = entry.unwrap();
let ft = entry.file_type().unwrap();
let p = entry.path();
if ft.is_dir() {
stack.push(p);
} else if ft.is_file() {
let rel = p.strip_prefix(root).unwrap().to_string_lossy().to_string();
out.insert(rel, fs::read(&p).unwrap());
}
}
}
out
}
#[test]
fn shared_extraction_is_content_addressed_idempotent_and_locked_down() {
let tmp = tempfile::tempdir().unwrap();
let sidecar = build_real_sidecar(tmp.path());
let footer = read_footer_from_sidecar(&sidecar).unwrap();
let shared_root = tmp.path().join("_shared");
let pack_plain = tmp.path().join("vm-plain/pack");
// First machine extracts into the shared store; a plain per-machine
// extraction is the byte-for-byte reference.
let shared_dir = extract_sidecar_shared(&sidecar, &shared_root, &footer, false).unwrap();
extract_sidecar(&sidecar, &pack_plain, &footer, false, false).unwrap();
// 1) Content-addressed: the shared copy lives at `_shared/<checksum>`.
assert_eq!(
shared_dir,
shared_root.join(format!("{:08x}", footer.checksum)),
"shared dir must be keyed by the footer checksum"
);
assert!(shared_dir.is_dir(), "shared extraction dir must exist");
// 2) The shared view is byte-identical to a plain per-machine extraction —
// the idmapped mount only remaps ownership, never content.
let reference = file_map(&pack_plain);
assert!(!reference.is_empty(), "extraction produced no files");
assert_eq!(
file_map(&shared_dir),
reference,
"shared content must match a plain extraction byte-for-byte"
);
// 3) Idempotent reuse: a second machine with the same checksum reuses the one
// extracted tree — no second decode, no second store dir. This is the
// cold-start tax the shared store removes.
let shared_dir2 = extract_sidecar_shared(&sidecar, &shared_root, &footer, false).unwrap();
assert_eq!(
shared_dir2, shared_dir,
"second machine must reuse the copy"
);
let store_dirs: Vec<_> = fs::read_dir(&shared_root)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.collect();
assert_eq!(
store_dirs.len(),
1,
"only one content-addressed extraction may exist for one checksum"
);
// 4) Locked down to 0700: the store root and the checksum dir are owner-only,
// so a sibling VM dropped to a *different* uid cannot read the shared copy
// directly — it must go through its own idmapped mount (#456 isolation).
for dir in [&shared_root, &shared_dir] {
let mode = fs::metadata(dir).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode,
0o700,
"{} must be 0700, got {:o}",
dir.display(),
mode
);
}
}
#[test]
fn kill_switch_disables_the_shared_store() {
// The handler consults `shared_extract_enabled()` to choose the shared vs
// per-machine path; `SMOLVM_DISABLE_SHARED_EXTRACT` forces the fallback
// without a redeploy. (Run as its own test fn so the env mutation cannot race
// the content test on cargo's parallel test threads.)
assert!(
shared_extract_enabled(),
"shared store should be enabled by default on Linux"
);
std::env::set_var("SMOLVM_DISABLE_SHARED_EXTRACT", "1");
assert!(
!shared_extract_enabled(),
"kill-switch must disable the shared store"
);
std::env::remove_var("SMOLVM_DISABLE_SHARED_EXTRACT");
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "smolvm-protocol"
version = "1.5.2"
edition = "2021"
description = "Protocol types for smolvm host-guest communication"
license = "Apache-2.0"
repository = "https://github.com/smol-machines/smolvm"
[dependencies]
base64 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
+73
View File
@@ -0,0 +1,73 @@
//! Shared environment-variable contract between the host launcher and guest agent.
//!
//! These names form the protocol boundary between:
//! - the host-side launcher, which decides what features to enable
//! - the guest agent, which reads these vars on startup and acts accordingly
//!
//! They should be treated as stable protocol constants rather than ad hoc
//! launcher strings.
/// Standard "enabled" value for boolean `SMOLVM_*` sentinel env vars.
///
/// The host writes this when a feature is enabled; the guest agent
/// compares against it. A single canonical value prevents `true` / `yes` /
/// `1` mismatches between the two sides.
pub const VALUE_ON: &str = "1";
/// Env var the host sets on guest init to signal GPU acceleration was requested.
///
/// Present means "host asked for GPU"; the guest agent reads this and emits a
/// post-boot sanity log confirming whether `/dev/dri/*` nodes actually appeared.
/// Absent means no GPU was requested.
///
/// This is a boolean sentinel — the value is [`VALUE_ON`] when set.
pub const GPU: &str = "SMOLVM_GPU";
/// Env var the host sets on guest init to signal Rosetta 2 x86_64 translation
/// was requested (and is available on the host).
///
/// Present means the host has attached the RosettaLinux runtime as the virtiofs
/// tag [`crate::ROSETTA_TAG`]; the guest agent reads this on startup and, if set,
/// mounts that runtime at [`crate::ROSETTA_GUEST_PATH`] and registers the ptrace
/// wrapper with `binfmt_misc` as the interpreter for x86_64 ELF binaries. Absent
/// means no Rosetta was requested.
///
/// This is a boolean sentinel — the value is [`VALUE_ON`] when set.
pub const ROSETTA: &str = "SMOLVM_ROSETTA";
/// Filename of this VM's readiness marker, written by the agent into the virtiofs
/// rootfs when boot completes. Per VM (so concurrent boots don't race on one
/// shared file); the host pre-creates and polls the same name. Unset → the agent
/// falls back to the shared [`crate::AGENT_READY_MARKER`] constant.
pub const READY_MARKER: &str = "SMOLVM_READY_MARKER";
/// Host wall-clock at VM launch, nanoseconds since the Unix epoch. The agent
/// uses this to set the guest clock when the hypervisor gives the guest no
/// readable paravirt clock (e.g. WHP on Windows, where the guest otherwise
/// boots at ~1999 and every TLS cert validation fails). The agent only applies
/// it when the guest clock already looks obviously wrong, so it never fights an
/// accurate kvmclock (Linux/KVM) or HVF-seeded RTC (macOS).
pub const HOST_TIME_NS: &str = "SMOLVM_HOST_TIME_NS";
/// Selects whether the guest should configure a real virtio NIC.
pub const BACKEND: &str = "SMOLVM_NETWORK_BACKEND";
/// Canonical backend value meaning "configure guest virtio-net".
pub const BACKEND_VIRTIO_NET: &str = "virtio-net";
/// Guest IPv4 address.
pub const GUEST_IP: &str = "SMOLVM_NETWORK_GUEST_IP";
/// Guest-visible default gateway IPv4 address.
pub const GATEWAY: &str = "SMOLVM_NETWORK_GATEWAY";
/// Guest subnet prefix length.
pub const PREFIX_LEN: &str = "SMOLVM_NETWORK_PREFIX_LEN";
/// Guest MAC address in colon-separated string form.
pub const GUEST_MAC: &str = "SMOLVM_NETWORK_GUEST_MAC";
/// Guest IPv6 (ULA) address. Optional: absent means IPv4-only guest config.
pub const GUEST_IP6: &str = "SMOLVM_NETWORK_GUEST_IP6";
/// Guest-visible default gateway IPv6 address.
pub const GATEWAY6: &str = "SMOLVM_NETWORK_GATEWAY6";
/// Guest IPv6 prefix length.
pub const PREFIX_LEN6: &str = "SMOLVM_NETWORK_PREFIX_LEN6";
/// Guest-visible DNS server IPv4 address.
pub const DNS: &str = "SMOLVM_NETWORK_DNS";
/// Enables the guest-side DNS filtering proxy.
pub const DNS_FILTER: &str = "SMOLVM_DNS_FILTER";
+212
View File
@@ -0,0 +1,212 @@
//! Image reference canonicalization.
//!
//! OCI image references have many valid spellings for the same image.
//! This module provides [`normalize_image_ref`], which maps every spelling
//! to a single canonical form so that cache keys, log messages, and
//! protocol messages are consistent regardless of how the caller spelled
//! the reference.
/// Canonicalize an OCI image reference.
///
/// All equivalent spellings of the same image produce an identical string,
/// which is safe to use as a cache key or protocol field.
///
/// # Normalization rules (applied in order)
///
/// 1. `index.docker.io` is rewritten to `docker.io` (legacy alias).
/// 2. A missing registry defaults to `docker.io`.
/// 3. Single-component names on `docker.io` receive the `library/` prefix
/// (e.g. `alpine` → `docker.io/library/alpine`).
/// 4. A missing tag defaults to `:latest`. When a digest (`@sha256:…`) is
/// present it takes precedence and any tag is dropped.
///
/// # Examples
///
/// ```
/// use smolvm_protocol::normalize_image_ref;
///
/// assert_eq!(normalize_image_ref("alpine"),
/// "docker.io/library/alpine:latest");
/// assert_eq!(normalize_image_ref("alpine:3.20"),
/// "docker.io/library/alpine:3.20");
/// assert_eq!(normalize_image_ref("docker.io/alpine:3.20"),
/// "docker.io/library/alpine:3.20");
/// assert_eq!(normalize_image_ref("docker.io/library/alpine:3.20"),
/// "docker.io/library/alpine:3.20");
/// assert_eq!(normalize_image_ref("ghcr.io/owner/repo:v1"),
/// "ghcr.io/owner/repo:v1");
/// ```
pub fn normalize_image_ref(image: &str) -> String {
// Local image sources (host-resolved `local:<hash>` / `local-dir:<path>`)
// are not registry references — pass them through unchanged.
if image.starts_with("local:") || image.starts_with("local-dir:") {
return image.to_string();
}
// 1. Resolve index.docker.io alias.
let owned;
let image = if let Some(rest) = image.strip_prefix("index.docker.io/") {
owned = format!("docker.io/{rest}");
owned.as_str()
} else {
image
};
// 2. and 3. Separate the digest, and the tag from the bare repository.
let (ref_no_tag, tag, digest) = split_suffix(image);
// 4. Detect registry: the first '/'-delimited component is a registry
// hostname when it contains '.' or ':' (port). Everything else is
// an implicit docker.io reference.
let (registry, path) = split_registry(ref_no_tag);
// 5. Single-component docker.io paths get the `library/` prefix.
let canonical_path = if registry == "docker.io" && !path.contains('/') {
format!("library/{path}")
} else {
path.to_string()
};
// 6. Suffix: digest wins over tag; absent tag defaults to `:latest`.
let suffix = match digest {
Some(d) => format!("@{d}"),
None => format!(":{}", tag.unwrap_or("latest")),
};
format!("{registry}/{canonical_path}{suffix}")
}
/// Split an image reference into `(repo, tag, digest)`.
///
/// The repo is the `registry/repository` portion with no suffix.
///
/// The digest is everything after '@'. When a digest is present the tag is
/// informational (the digest is authoritative), but both are still returned.
///
/// The tag separator is the last ':' with no '/' after it. A colon that is
/// part of a registry hostname (e.g. `localhost:5000/repo`) always has a '/'
/// after it, so this rule does not mistake a port for a tag.
fn split_suffix(image: &str) -> (&str, Option<&str>, Option<&str>) {
let (ref_no_digest, digest) = match image.split_once('@') {
Some((left, right)) => (left, Some(right)),
None => (image, None),
};
let (repo, tag) = match ref_no_digest.rfind(':') {
Some(pos) if !ref_no_digest[pos..].contains('/') => {
(&ref_no_digest[..pos], Some(&ref_no_digest[pos + 1..]))
}
_ => (ref_no_digest, None),
};
(repo, tag, digest)
}
/// Strip the tag and/or digest suffix from an image reference, returning the registry/repo
///
/// # Examples
///
/// ```
/// use smolvm_protocol::image_repo;
///
/// assert_eq!(image_repo("ghcr.io/owner/repo@sha256:abc"), "ghcr.io/owner/repo");
/// assert_eq!(image_repo("ghcr.io/owner/repo:v1"), "ghcr.io/owner/repo");
/// ```
pub fn image_repo(image: &str) -> &str {
split_suffix(image).0
}
/// Split a tag-free, digest-free image string into `(registry, path)`.
///
/// Returns `("docker.io", whole_string)` when no explicit registry is found.
fn split_registry(image: &str) -> (&str, &str) {
if let Some(slash) = image.find('/') {
let prefix = &image[..slash];
if prefix.contains('.') || prefix.contains(':') {
return (prefix, &image[slash + 1..]);
}
}
("docker.io", image)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_image_ref() {
let cases: &[(&str, &str)] = &[
// Bare name and with tag — docker.io library prefix + :latest default.
("alpine", "docker.io/library/alpine:latest"),
("alpine:3.20", "docker.io/library/alpine:3.20"),
// Explicit docker.io without library/ — library/ is inserted.
("docker.io/alpine:3.20", "docker.io/library/alpine:3.20"),
// Already canonical — idempotent.
(
"docker.io/library/alpine:3.20",
"docker.io/library/alpine:3.20",
),
// index.docker.io legacy alias.
(
"index.docker.io/library/alpine",
"docker.io/library/alpine:latest",
),
// library/ without a registry prefix.
("library/alpine", "docker.io/library/alpine:latest"),
// User-namespaced docker.io image — no extra library/ prefix.
("myuser/myimage:v2", "docker.io/myuser/myimage:v2"),
// Non-docker.io registry.
("ghcr.io/owner/repo", "ghcr.io/owner/repo:latest"),
("ghcr.io/owner/repo:v1", "ghcr.io/owner/repo:v1"),
// Port in registry — colon-detection must not confuse port with tag.
("localhost:5000/myimage:dev", "localhost:5000/myimage:dev"),
];
for (input, expected) in cases {
assert_eq!(
normalize_image_ref(input),
*expected,
"normalize_image_ref({input:?})"
);
}
}
#[test]
fn test_normalize_digest_refs() {
let digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
// Digest without tag.
assert_eq!(
normalize_image_ref(&format!("alpine@{digest}")),
format!("docker.io/library/alpine@{digest}"),
);
// Digest with tag — tag is dropped, digest is authoritative.
assert_eq!(
normalize_image_ref(&format!("alpine:3.20@{digest}")),
format!("docker.io/library/alpine@{digest}"),
);
}
#[test]
fn test_image_repo() {
let digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
// Digest suffix is stripped.
assert_eq!(
image_repo(&format!("ghcr.io/owner/repo@{digest}")),
"ghcr.io/owner/repo",
);
// Tag suffix is stripped.
assert_eq!(image_repo("ghcr.io/owner/repo:v1"), "ghcr.io/owner/repo");
// No suffix — returned unchanged.
assert_eq!(image_repo("ghcr.io/owner/repo"), "ghcr.io/owner/repo");
// Port in registry — colon-detection must not confuse port with tag.
assert_eq!(
image_repo("localhost:5000/myimage:dev"),
"localhost:5000/myimage",
);
assert_eq!(
image_repo("localhost:5000/myimage"),
"localhost:5000/myimage"
);
}
}
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
//! Retry utilities for transient failure recovery.
//!
//! Provides exponential backoff retry logic for operations that may fail
//! transiently (network issues, resource contention, etc.).
//!
//! This module is shared between the host and agent to ensure consistent
//! retry behavior across the system.
use std::thread;
use std::time::Duration;
use tracing::{debug, warn};
// ============================================================================
// Retry Configuration Constants
// ============================================================================
//
// These values control retry behavior for different operation types.
// They balance between recovering from transient failures and failing fast
// for permanent errors.
/// Default maximum retry attempts for general operations.
/// 3 attempts = 1 initial + 2 retries, typically sufficient for transient issues.
const DEFAULT_MAX_ATTEMPTS: u32 = 3;
/// Default initial delay between retries (100ms).
/// Short enough to not add noticeable latency, long enough to let transient
/// issues resolve (e.g., brief network hiccups).
const DEFAULT_INITIAL_DELAY_MS: u64 = 100;
/// Default maximum delay cap (5 seconds).
/// Prevents exponential backoff from growing too large while keeping
/// total retry time reasonable.
const DEFAULT_MAX_DELAY_SECS: u64 = 5;
/// Standard exponential backoff multiplier.
/// Each retry waits 2x longer than the previous (100ms -> 200ms -> 400ms...).
const BACKOFF_MULTIPLIER: f64 = 2.0;
/// Maximum retry attempts for network operations (image pulls, registry calls).
/// 4 attempts allows recovery from longer network interruptions while still
/// failing within a reasonable time (~45 seconds worst case).
const NETWORK_MAX_ATTEMPTS: u32 = 4;
/// Initial delay for network operations (500ms).
/// Longer than default because network issues often need more time to resolve
/// (DNS propagation, connection pool recovery, rate limit windows).
const NETWORK_INITIAL_DELAY_MS: u64 = 500;
/// Maximum delay for network operations (5 seconds).
/// Capped to keep total retry time reasonable. If network issues persist
/// beyond this, they're likely not transient.
const NETWORK_MAX_DELAY_SECS: u64 = 5;
/// Initial delay for connection operations (100ms).
/// Short because the agent should already be running — we're handling brief
/// unavailability during high load or socket setup under concurrent boot.
const CONNECTION_INITIAL_DELAY_MS: u64 = 100;
/// Maximum delay for connection operations (2 seconds).
const CONNECTION_MAX_DELAY_SECS: u64 = 2;
/// Configuration for retry behavior.
#[derive(Debug, Clone)]
pub struct RetryConfig {
/// Maximum number of attempts (including the initial attempt).
pub max_attempts: u32,
/// Initial delay between retries.
pub initial_delay: Duration,
/// Maximum delay between retries (caps exponential growth).
pub max_delay: Duration,
/// Multiplier for exponential backoff (typically 2.0).
pub backoff_multiplier: f64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: DEFAULT_MAX_ATTEMPTS,
initial_delay: Duration::from_millis(DEFAULT_INITIAL_DELAY_MS),
max_delay: Duration::from_secs(DEFAULT_MAX_DELAY_SECS),
backoff_multiplier: BACKOFF_MULTIPLIER,
}
}
}
impl RetryConfig {
/// Create a config optimized for network operations (image pulls, registry calls).
///
/// Uses longer delays and more attempts because network issues often need
/// more time to resolve (DNS, rate limits, connection pool recovery).
pub fn for_network() -> Self {
Self {
max_attempts: NETWORK_MAX_ATTEMPTS,
initial_delay: Duration::from_millis(NETWORK_INITIAL_DELAY_MS),
max_delay: Duration::from_secs(NETWORK_MAX_DELAY_SECS),
backoff_multiplier: BACKOFF_MULTIPLIER,
}
}
/// Create a config for socket/connection operations.
///
/// Uses short delays because the agent should already be running.
/// These retries handle brief unavailability during high load.
pub fn for_connection() -> Self {
Self {
max_attempts: 6,
initial_delay: Duration::from_millis(CONNECTION_INITIAL_DELAY_MS),
max_delay: Duration::from_secs(CONNECTION_MAX_DELAY_SECS),
backoff_multiplier: BACKOFF_MULTIPLIER,
}
}
}
/// Execute an operation with retry logic.
///
/// The `should_retry` function determines whether a given error is transient
/// and worth retrying.
///
/// # Example
///
/// ```ignore
/// let result = retry_with_backoff(
/// RetryConfig::for_network(),
/// "fetch manifest",
/// || fetch_manifest(image),
/// |e| is_transient_network_error(&e.to_string()),
/// );
/// ```
pub fn retry_with_backoff<T, E, F, R>(
config: RetryConfig,
operation_name: &str,
mut operation: F,
should_retry: R,
) -> Result<T, E>
where
F: FnMut() -> Result<T, E>,
R: Fn(&E) -> bool,
E: std::fmt::Display,
{
let mut attempt = 0;
let mut delay = config.initial_delay;
loop {
attempt += 1;
match operation() {
Ok(result) => {
if attempt > 1 {
debug!(
operation = %operation_name,
attempts = attempt,
"operation succeeded after retry"
);
}
return Ok(result);
}
Err(e) => {
if attempt >= config.max_attempts {
warn!(
operation = %operation_name,
attempts = attempt,
error = %e,
"operation failed after max attempts"
);
return Err(e);
}
if !should_retry(&e) {
debug!(
operation = %operation_name,
attempt = attempt,
error = %e,
"operation failed with non-retryable error"
);
return Err(e);
}
warn!(
operation = %operation_name,
attempt = attempt,
max_attempts = config.max_attempts,
delay_ms = delay.as_millis(),
error = %e,
"operation failed, will retry"
);
thread::sleep(delay);
// Exponential backoff with cap
delay = Duration::from_secs_f64(
(delay.as_secs_f64() * config.backoff_multiplier)
.min(config.max_delay.as_secs_f64()),
);
}
}
}
}
/// Check if an error message indicates a transient network failure.
///
/// This is a heuristic based on common error messages from network tools
/// and registries.
pub fn is_transient_network_error(error_msg: &str) -> bool {
let error_lower = error_msg.to_lowercase();
// Connection errors (transient)
if error_lower.contains("connection refused")
|| error_lower.contains("connection reset")
|| error_lower.contains("connection timed out")
|| error_lower.contains("network is unreachable")
|| error_lower.contains("no route to host")
|| error_lower.contains("temporary failure")
|| error_lower.contains("try again")
|| error_lower.contains("resource temporarily unavailable")
{
return true;
}
// DNS errors (often transient)
if error_lower.contains("name resolution")
|| error_lower.contains("dns")
|| error_lower.contains("could not resolve")
|| error_lower.contains("no such host")
{
return true;
}
// HTTP errors that may be transient
if error_lower.contains("502 bad gateway")
|| error_lower.contains("503 service unavailable")
|| error_lower.contains("504 gateway timeout")
|| error_lower.contains("429 too many requests")
{
return true;
}
// Registry-specific transient errors
if error_lower.contains("toomanyrequests")
|| error_lower.contains("rate limit")
|| error_lower.contains("quota exceeded")
{
return true;
}
// I/O errors that may be transient
if error_lower.contains("broken pipe")
|| error_lower.contains("interrupted")
|| error_lower.contains("eagain")
|| error_lower.contains("ewouldblock")
{
return true;
}
false
}
/// Check if an error message indicates a permanent failure (don't retry).
pub fn is_permanent_error(error_msg: &str) -> bool {
let error_lower = error_msg.to_lowercase();
// Authentication/authorization errors
if error_lower.contains("401 unauthorized")
|| error_lower.contains("403 forbidden")
|| error_lower.contains("authentication required")
|| error_lower.contains("access denied")
{
return true;
}
// Not found errors
if error_lower.contains("404 not found")
|| error_lower.contains("manifest unknown")
|| error_lower.contains("name unknown")
|| error_lower.contains("repository does not exist")
{
return true;
}
// Invalid input
if error_lower.contains("invalid reference")
|| error_lower.contains("invalid image")
|| error_lower.contains("malformed")
{
return true;
}
false
}
/// Check if an I/O error is likely transient and worth retrying.
pub fn is_transient_io_error(error: &std::io::Error) -> bool {
use std::io::ErrorKind;
matches!(
error.kind(),
ErrorKind::ConnectionRefused
| ErrorKind::ConnectionReset
| ErrorKind::ConnectionAborted
| ErrorKind::NotConnected
| ErrorKind::BrokenPipe
| ErrorKind::TimedOut
| ErrorKind::Interrupted
| ErrorKind::WouldBlock
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[test]
fn test_retry_success_first_attempt() {
let result: Result<i32, &str> =
retry_with_backoff(RetryConfig::default(), "test", || Ok(42), |_| true);
assert_eq!(result.unwrap(), 42);
}
#[test]
fn test_retry_success_after_failures() {
let attempts = RefCell::new(0);
let result: Result<i32, &str> = retry_with_backoff(
RetryConfig {
max_attempts: 3,
initial_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(10),
backoff_multiplier: 2.0,
},
"test",
|| {
*attempts.borrow_mut() += 1;
if *attempts.borrow() < 3 {
Err("transient error")
} else {
Ok(42)
}
},
|_| true,
);
assert_eq!(result.unwrap(), 42);
assert_eq!(*attempts.borrow(), 3);
}
#[test]
fn test_retry_exhausted() {
let attempts = RefCell::new(0);
let result: Result<i32, &str> = retry_with_backoff(
RetryConfig {
max_attempts: 3,
initial_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(10),
backoff_multiplier: 2.0,
},
"test",
|| {
*attempts.borrow_mut() += 1;
Err("always fails")
},
|_| true,
);
assert!(result.is_err());
assert_eq!(*attempts.borrow(), 3);
}
#[test]
fn test_retry_non_retryable_error() {
let attempts = RefCell::new(0);
let result: Result<i32, &str> = retry_with_backoff(
RetryConfig::default(),
"test",
|| {
*attempts.borrow_mut() += 1;
Err("permanent error")
},
|_| false, // Never retry
);
assert!(result.is_err());
assert_eq!(*attempts.borrow(), 1);
}
#[test]
fn test_transient_network_errors() {
assert!(is_transient_network_error("connection refused"));
assert!(is_transient_network_error("Connection timed out"));
assert!(is_transient_network_error("503 Service Unavailable"));
assert!(is_transient_network_error("rate limit exceeded"));
assert!(!is_transient_network_error("404 not found"));
assert!(!is_transient_network_error("some random error"));
}
#[test]
fn test_permanent_errors() {
assert!(is_permanent_error("401 Unauthorized"));
assert!(is_permanent_error("404 Not Found"));
assert!(is_permanent_error("manifest unknown"));
assert!(!is_permanent_error("connection refused"));
assert!(!is_permanent_error("503 Service Unavailable"));
}
#[test]
fn test_config_presets() {
let network = RetryConfig::for_network();
assert_eq!(network.max_attempts, 4);
assert_eq!(network.initial_delay, Duration::from_millis(500));
let connection = RetryConfig::for_connection();
assert_eq!(connection.max_attempts, 6);
assert_eq!(connection.initial_delay, Duration::from_millis(100));
}
}

Some files were not shown because too many files have changed in this diff Show More