Skip to content

Asset Pipeline

The asset pipeline moves kernel, initrd, and rootfs images from build through to boot. Assets are per-architecture (arm64 for Apple Silicon, x86_64 for Linux/KVM), integrity-checked with BLAKE3 hashes at every stage, and distributed via a version-scoped manifest.

Profile V2 payloads are the build authority for release assets. The capsem-admin CLI derives a temporary build workspace, renders the existing Jinja2 Dockerfile templates, and produces per-architecture assets:

capsem.profile.v2 -> capsem-admin image build -> generated workspace -> assets/{arch}/

Two build templates exist:

TemplateOutputWhat it does
kernelvmlinuz, initrd.imgBuilds a minimal Linux kernel from defconfig
rootfsrootfs.squashfsBuilds the full guest filesystem with packages, runtimes, and tools

The build process also cross-compiles the canonical guest binaries (capsem-pty-agent, capsem-net-proxy, capsem-dns-proxy, capsem-mcp-server, capsem-sysutil) for the target architecture and injects them into the rootfs.

assets/
arm64/
vmlinuz
initrd.img
rootfs.squashfs
vmlinuz-<hash16>
initrd-<hash16>.img
rootfs-<hash16>.squashfs
x86_64/
vmlinuz
initrd.img
rootfs.squashfs
vmlinuz-<hash16>
initrd-<hash16>.img
rootfs-<hash16>.squashfs
manifest.json
manifest.json.minisig
B3SUMS
CommandWhat it does
just build-assetsFull build using config/profiles/base/coding.profile.toml: kernel + rootfs + checksums
just runRepack initrd with latest guest binaries, rebuild app, sign, boot
capsem-admin image build config/profiles/base/coding.profile.toml --arch arm64 --template rootfsBuild one template for one arch

The manifest (assets/manifest.json, format 2) is a single top-level file covering every arch. Asset versions and binary versions are tracked independently with compatibility ranges (min_binary, min_assets):

{
"format": 2,
"assets": {
"current": "2026.0421.30",
"releases": {
"2026.0421.30": {
"date": "2026-04-21",
"deprecated": false,
"min_binary": "1.0.0",
"arches": {
"arm64": {
"vmlinuz": {"hash": "<64-char blake3>", "size": 7797248},
"initrd.img": {"hash": "<blake3>", "size": 2314963},
"rootfs.squashfs": {"hash": "<blake3>", "size": 454230016}
},
"x86_64": { "...": "..." }
}
}
}
},
"binaries": {
"current": "1.0.1776688771",
"releases": {
"1.0.1776688771": {
"date": "2026-04-21",
"deprecated": false,
"min_assets": "2026.0421.30"
}
}
}
}

Key points:

  • Single file, not per-arch. Arches are nested under assets.releases.<ver>.arches.<arch>.
  • Filenames are bare ("vmlinuz", not "arm64/vmlinuz") — the arch map provides the context.
  • Hashes are BLAKE3, 64 lowercase hex characters. Format is validated by asset_manager.rs; non-format-2 manifests are rejected.
  • Compatibility is explicit. min_binary on an asset release and min_assets on a binary release define the allowed pairings for upgrades and downloads.
ProducerUsed byWhen
docker.py:generate_checksums()just build-assetsAfter full image builds
scripts/gen_manifest.pyjust _pack-initrdAfter injecting updated guest binaries into initrd

Both emit the same format-2 schema and use the same YYYY.MMDD.patch same-day increment rules. scripts/create_hash_assets.py then creates <stem>-<hex16>.<ext> hardlinks so the dev layout matches the content-addressable names used by the installed layout.

Asset hashes are not baked into the binary at compile time — that would tie every binary release to a specific asset release and defeat the min_binary/min_assets compatibility model. Instead, the binary is hash-agnostic; the manifest on disk is authoritative, and its authenticity is established by a minisign signature verified against a pubkey baked into the binary (config/manifest-sign.pub, key id 93A070CBB288AC9B).

At boot (crates/capsem-core/src/vm/boot.rs):

  1. asset_manager::load_verified_manifest_for_assets(assets, require_signature) reads manifest.json from the assets dir or its parent, and verifies the sibling manifest.json.minisig against the baked release pubkey.
  2. ManifestV2::expected_hashes_current(host_manifest_arch()) looks up the kernel/initrd/rootfs hashes for the current release on the host arch (aarch64 -> arm64 mapped).
  3. The hashes are passed to VmConfig::builder() via expected_kernel_hash / expected_initrd_hash / expected_disk_hash; VmConfig::build() hashes the files and refuses to boot on mismatch.

Failure modes:

  • No manifest at all: local development layouts may skip hash verification ([boot-audit] asset hash verification disabled) for fresh checkouts without assets built yet. Installed package layouts require a signed manifest.
  • Manifest present, no .minisig: local debug builds can proceed only in development layouts. Installed macOS .pkg and Linux .deb layouts hard-fail — an untrusted manifest must not drive hash verification.
  • Manifest present, .minisig invalid: always hard-fail, regardless of build profile. A signature mismatch is a loud signal.

Manifests are signed during the release workflow (scripts/check-release-workflow.sh uses minisign -Sm assets/manifest.json). The corresponding pubkey in config/manifest-sign.pub is included via include_str! at compile time, so the signing/verification loop is self-contained and does not depend on any TLS or external trust root.

resolve_assets_dir() searches these locations in order, returning the first that contains vmlinuz:

  1. CAPSEM_ASSETS_DIR environment variable (dev override)
  2. macOS .app bundle Contents/Resources/
  3. ./assets (workspace root)
  4. ../../assets (from crate directory)

For each candidate, it checks per-arch first (candidate/{arch}/vmlinuz), then flat (candidate/vmlinuz).

ManifestV2::resolve() selects the compatible asset release for the running binary, then resolves hash-named assets in either the flat development layout or the installed per-arch layout:

  1. Flat: {assets_dir}/<stem>-<hash16>.<ext>
  2. Per-arch: {assets_dir}/{arch}/<stem>-<hash16>.<ext>

If rootfs is not found locally, create_asset_manager() loads the manifest and initiates download:

  1. Loads manifest.json from assets dir or its parent (handles per-arch layout)
  2. Creates AssetManager with per-arch download directory (~/.capsem/assets/{arch}/)
  3. Downloads from GitHub Releases with HTTP resume support (Range headers)
  4. Verifies BLAKE3 hash after download, deletes on mismatch
  5. Atomically renames temp file to final path

boot_vm() builds VmConfig with manifest-selected asset paths and hashes:

VmConfig::builder()
.kernel_path(assets/{arch}/vmlinuz-<hash16>) + expected_kernel_hash
.initrd_path(assets/{arch}/initrd-<hash16>.img) + expected_initrd_hash
.disk_path(assets/{arch}/rootfs-<hash16>.squashfs) + expected_disk_hash
.build() // verifies all hashes

build() calls verify_hash() for each file — reads in 64KB chunks, computes BLAKE3, compares with expected. A HashMismatch error prevents boot entirely.

Assets are verified at multiple points:

WhenWhereWhat happens on mismatch
After downloadasset_manager.rsTemp file deleted, download retried
Before bootvm/config.rsConfigError::HashMismatch, boot prevented

Both use BLAKE3 with 64-character hex format. Both checks source their expected hashes from the same manifest.json on disk — the boot check just re-reads it via load_manifest_for_assets() at boot_vm() time.

  • A Capsem binary supports exactly one architecture (no runtime switching); std::env::consts::ARCH is used to select the manifest arch key.
  • host_manifest_arch() maps aarch64 -> arm64 (the key used in the manifest).
  • The manifest has separate hash entries per arch — no cross-arch confusion is possible.
flowchart LR
    subgraph Build
        Profile[Profile V2 payload] --> Admin[capsem-admin image build]
        Admin --> Builder[capsem-builder]
        Builder --> Assets[assets/arm64/]
        Builder --> Checksums[manifest.json]
    end

    subgraph Runtime
        Checksums --> LoadManifest[load_manifest_for_assets]
        LoadManifest --> ExpectedHashes[expected_hashes_current]
        ExpectedHashes --> Boot[boot_vm]
        Assets --> Resolve[resolve_assets_dir]
        Resolve --> Boot
        Boot --> Verify[verify_hash BLAKE3]
        Verify --> VZ[VZLinuxBootLoader]
    end