From cc15b601ea9fca0e5b054f2f4e2efc65540b771e Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 24 Jun 2026 01:04:42 +0000 Subject: [PATCH 1/8] feat(vmlinux): vendor upstream kernel manifest --- priv/vmlinux/manifest.json | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 priv/vmlinux/manifest.json diff --git a/priv/vmlinux/manifest.json b/priv/vmlinux/manifest.json new file mode 100644 index 00000000..d142741a --- /dev/null +++ b/priv/vmlinux/manifest.json @@ -0,0 +1,34 @@ +{ + "sha" : "54d6c3f843c01fb55107a024cca5bf60af235c42", + "builds" : [ { + "name" : "x86_64-5.10", + "arch" : "x86_64", + "version" : "5.10.259", + "asset" : "vmlinux-x86_64-5.10", + "sha256" : "96b8f6a665536e560777f9634773b0ff01a41c24dda2e4ecd0f395b18f5cfa62" + }, { + "name" : "x86_64-6.1", + "arch" : "x86_64", + "version" : "6.1.176", + "asset" : "vmlinux-x86_64-6.1", + "sha256" : "3a9a115ce4c07951dd83f9ba048607a890e59118d21d23f5f3135e41ef2ff05f" + }, { + "name" : "aarch64-5.10", + "arch" : "aarch64", + "version" : "5.10.259", + "asset" : "vmlinux-aarch64-5.10", + "sha256" : "853306d4d1c9110208c5df21c8602bc40d68556ce7fd3519e2f08e296460606a" + }, { + "name" : "aarch64-6.1", + "arch" : "aarch64", + "version" : "6.1.176", + "asset" : "vmlinux-aarch64-6.1", + "sha256" : "d7ecc631a3b59e0d66c8960b35a3374f402e45fb46179507c5aa48b4629e3017" + }, { + "name" : "x86_64-5.10-no-acpi", + "arch" : "x86_64", + "version" : "5.10.259", + "asset" : "vmlinux-x86_64-5.10-no-acpi", + "sha256" : "1b8bea12ee405322cad329f0a3ff20e36bb801bc11baabde9832b69f29072709" + } ] +} \ No newline at end of file From 49639859ce6d624820defbdab09df70c0681e148 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 24 Jun 2026 01:09:04 +0000 Subject: [PATCH 2/8] refactor(redist): extract Hyper.Redist.Sha256 from Targz --- lib/hyper/redist/sha256.ex | 20 ++++++++++++++++++++ lib/hyper/redist/targz.ex | 20 +++----------------- test/hyper/redist/sha256_test.exs | 15 +++++++++++++++ 3 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 lib/hyper/redist/sha256.ex create mode 100644 test/hyper/redist/sha256_test.exs diff --git a/lib/hyper/redist/sha256.ex b/lib/hyper/redist/sha256.ex new file mode 100644 index 00000000..4aa8ccd8 --- /dev/null +++ b/lib/hyper/redist/sha256.ex @@ -0,0 +1,20 @@ +defmodule Hyper.Redist.Sha256 do + @moduledoc "Streaming SHA-256 of a file, returned as lowercase hex." + + @doc "Streaming SHA-256 of the file at `path`, as lowercase hex." + @spec file(Path.t()) :: String.t() + def file(path) do + path + |> File.open!([:read, :binary, :raw], fn io -> hash_io(io, :crypto.hash_init(:sha256)) end) + |> :crypto.hash_final() + |> Base.encode16(case: :lower) + end + + # Fold the file through the hash context in 2 MiB chunks. + defp hash_io(io, ctx) do + case :file.read(io, 2 * 1024 * 1024) do + {:ok, data} -> hash_io(io, :crypto.hash_update(ctx, data)) + :eof -> ctx + end + end +end diff --git a/lib/hyper/redist/targz.ex b/lib/hyper/redist/targz.ex index 738c513b..42425aa4 100644 --- a/lib/hyper/redist/targz.ex +++ b/lib/hyper/redist/targz.ex @@ -4,6 +4,8 @@ defmodule Hyper.Redist.Targz do into a directory. """ + alias Hyper.Redist.Sha256 + @doc """ Download `url`, verify its SHA-256 equals `sha256` (lowercase hex), and extract the archive into `dest_dir`. @@ -28,26 +30,10 @@ defmodule Hyper.Redist.Targz do end defp verify_checksum(path, expected) do - actual = sha256_file(path) + actual = Sha256.file(path) if actual == expected, do: :ok, else: {:error, {:checksum_mismatch, expected, actual}} end - # Streaming SHA-256 of a file, returned as lowercase hex. - defp sha256_file(path) do - path - |> File.open!([:read, :binary, :raw], fn io -> hash_io(io, :crypto.hash_init(:sha256)) end) - |> :crypto.hash_final() - |> Base.encode16(case: :lower) - end - - # Fold the file through the hash context in 2 MiB chunks. - defp hash_io(io, ctx) do - case :file.read(io, 2 * 1024 * 1024) do - {:ok, data} -> hash_io(io, :crypto.hash_update(ctx, data)) - :eof -> ctx - end - end - defp extract(tar_path, dest_dir) do File.mkdir_p!(dest_dir) diff --git a/test/hyper/redist/sha256_test.exs b/test/hyper/redist/sha256_test.exs new file mode 100644 index 00000000..a2ed075f --- /dev/null +++ b/test/hyper/redist/sha256_test.exs @@ -0,0 +1,15 @@ +defmodule Hyper.Redist.Sha256Test do + use ExUnit.Case, async: true + + alias Hyper.Redist.Sha256 + + test "file/1 returns the lowercase-hex streaming SHA-256 of the file" do + dir = System.tmp_dir!() + path = Path.join(dir, "sha256-#{System.unique_integer([:positive])}.bin") + File.write!(path, "hyper") + on_exit(fn -> File.rm(path) end) + + expected = :crypto.hash(:sha256, "hyper") |> Base.encode16(case: :lower) + assert Sha256.file(path) == expected + end +end From 602f3389208bc993667d5485ec7d86138d8197b4 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 24 Jun 2026 01:11:54 +0000 Subject: [PATCH 3/8] feat(redist): add Hyper.Redist.File single-file fetcher --- lib/hyper/redist/file.ex | 54 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 lib/hyper/redist/file.ex diff --git a/lib/hyper/redist/file.ex b/lib/hyper/redist/file.ex new file mode 100644 index 00000000..f4214cd4 --- /dev/null +++ b/lib/hyper/redist/file.ex @@ -0,0 +1,54 @@ +defmodule Hyper.Redist.File do + @moduledoc """ + Fetches a single raw file from a URL, verifies its SHA-256, and installs it at + a destination path. The raw-file analogue of `Hyper.Redist.Targz` (used for + assets that ship as plain files rather than tarballs, e.g. vmlinux images). + """ + + alias Hyper.Redist.Sha256 + + @doc """ + Download `url`, verify its SHA-256 equals `sha256` (lowercase hex), and install + the file at `dest_path` (creating parent directories). The download lands in a + temp dir first, so a failed verify never leaves a partial file at `dest_path`. + """ + @spec install(String.t(), String.t(), Path.t()) :: + :ok + | {:error, + {:download_failed, non_neg_integer()} + | {:download_error, term()} + | {:checksum_mismatch, String.t(), String.t()} + | {:install_failed, term()}} + def install(url, sha256, dest_path) do + Sys.Tmp.with_tempdir("hyper-redist", fn tmp -> + tmp_file = Path.join(tmp, "download") + + with :ok <- fetch(url, tmp_file), + :ok <- verify_checksum(tmp_file, sha256) do + place(tmp_file, dest_path) + end + end) + end + + defp verify_checksum(path, expected) do + actual = Sha256.file(path) + if actual == expected, do: :ok, else: {:error, {:checksum_mismatch, expected, actual}} + end + + defp place(src, dest) do + File.mkdir_p!(Path.dirname(dest)) + + case File.cp(src, dest) do + :ok -> :ok + {:error, reason} -> {:error, {:install_failed, reason}} + end + end + + defp fetch(url, dest_path) do + case Req.get(url, into: File.stream!(dest_path), redirect: true, max_redirects: 5) do + {:ok, %Req.Response{status: 200}} -> :ok + {:ok, %Req.Response{status: status}} -> {:error, {:download_failed, status}} + {:error, reason} -> {:error, {:download_error, reason}} + end + end +end From 922b1da6694f980c62b39fa34826d0f0de297469 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 24 Jun 2026 01:16:09 +0000 Subject: [PATCH 4/8] feat(vmlinux): embed and parse the kernel manifest --- lib/hyper/node/fire_vmm/vm_linux/manifest.ex | 83 +++++++++++++++++++ .../node/fire_vmm/vm_linux/manifest/build.ex | 13 +++ .../node/fire_vmm/vm_linux/manifest_test.exs | 33 ++++++++ 3 files changed, 129 insertions(+) create mode 100644 lib/hyper/node/fire_vmm/vm_linux/manifest.ex create mode 100644 lib/hyper/node/fire_vmm/vm_linux/manifest/build.ex create mode 100644 test/hyper/node/fire_vmm/vm_linux/manifest_test.exs diff --git a/lib/hyper/node/fire_vmm/vm_linux/manifest.ex b/lib/hyper/node/fire_vmm/vm_linux/manifest.ex new file mode 100644 index 00000000..df63ef75 --- /dev/null +++ b/lib/hyper/node/fire_vmm/vm_linux/manifest.ex @@ -0,0 +1,83 @@ +defmodule Hyper.Node.FireVMM.VmLinux.Manifest do + @moduledoc """ + The statically-embedded vmlinux release manifest. + + `priv/vmlinux/manifest.json` is vendored verbatim from a pinned + `github.com/harmont-dev/hyper-vmlinux` release and read at compile time, so the + per-build SHA-256 sums (used by `Hyper.Node.FireVMM.VmLinux.Provider` to verify + downloads) are baked into the compiled module and cannot drift from what was + published. `@external_resource` forces a recompile if the file changes. + + All functions here are pure; they never touch the network or the filesystem. + """ + + alias Hyper.Node.FireVMM.VmLinux.Manifest.Build + + @repo "harmont-dev/hyper-vmlinux" + @manifest_path "priv/vmlinux/manifest.json" + @external_resource @manifest_path + + @decoded @manifest_path |> File.read!() |> Jason.decode!() + @sha @decoded["sha"] + @builds (for b <- @decoded["builds"] do + arch = + case b["arch"] do + "x86_64" -> :x86_64 + "aarch64" -> :aarch64 + end + + %Build{ + name: b["name"], + arch: arch, + version: b["version"], + asset: b["asset"], + sha256: b["sha256"] + } + end) + + @doc "All builds in the manifest." + @spec builds() :: [Build.t()] + def builds, do: @builds + + @doc "All builds matching `arch`." + @spec builds_for(Sys.Arch.t()) :: [Build.t()] + def builds_for(arch), do: Enum.filter(@builds, &(&1.arch == arch)) + + @doc "Look up a build by its `name` (e.g. \"x86_64-6.1\")." + @spec fetch(String.t()) :: {:ok, Build.t()} | :error + def fetch(name) do + case Enum.find(@builds, &(&1.name == name)) do + nil -> :error + build -> {:ok, build} + end + end + + @doc """ + The default build for `arch`: the highest `version`, breaking ties toward the + shorter `name` (so a plain build wins over a variant like `-no-acpi`). Returns + `nil` if the manifest has no build for `arch`. + """ + @spec default_for(Sys.Arch.t()) :: Build.t() | nil + def default_for(arch) do + arch + |> builds_for() + |> Enum.sort(&preferred?/2) + |> List.first() + end + + @doc "The download URL for `build`'s asset on the pinned release." + @spec asset_url(Build.t()) :: String.t() + def asset_url(%Build{asset: asset}) do + "https://github.com/#{@repo}/releases/download/release-#{@sha}/#{asset}" + end + + # True if `a` is the more-preferred default than `b`: higher version first, + # then shorter name (plain build over variant). + defp preferred?(%Build{} = a, %Build{} = b) do + case Version.compare(a.version, b.version) do + :gt -> true + :lt -> false + :eq -> String.length(a.name) <= String.length(b.name) + end + end +end diff --git a/lib/hyper/node/fire_vmm/vm_linux/manifest/build.ex b/lib/hyper/node/fire_vmm/vm_linux/manifest/build.ex new file mode 100644 index 00000000..8566bf63 --- /dev/null +++ b/lib/hyper/node/fire_vmm/vm_linux/manifest/build.ex @@ -0,0 +1,13 @@ +defmodule Hyper.Node.FireVMM.VmLinux.Manifest.Build do + @moduledoc "One kernel build from the manifest." + @enforce_keys [:name, :arch, :version, :asset, :sha256] + defstruct [:name, :arch, :version, :asset, :sha256] + + @type t :: %__MODULE__{ + name: String.t(), + arch: Sys.Arch.t(), + version: String.t(), + asset: String.t(), + sha256: String.t() + } +end diff --git a/test/hyper/node/fire_vmm/vm_linux/manifest_test.exs b/test/hyper/node/fire_vmm/vm_linux/manifest_test.exs new file mode 100644 index 00000000..c2025eb6 --- /dev/null +++ b/test/hyper/node/fire_vmm/vm_linux/manifest_test.exs @@ -0,0 +1,33 @@ +defmodule Hyper.Node.FireVMM.VmLinux.ManifestTest do + use ExUnit.Case, async: true + + alias Hyper.Node.FireVMM.VmLinux.Manifest + alias Hyper.Node.FireVMM.VmLinux.Manifest.Build + + test "builds_for/1 returns only builds for the given arch, with atom archs" do + x86 = Manifest.builds_for(:x86_64) + assert Enum.all?(x86, &(&1.arch == :x86_64)) + assert "x86_64-6.1" in Enum.map(x86, & &1.name) + assert Enum.all?(Manifest.builds_for(:aarch64), &(&1.arch == :aarch64)) + end + + test "fetch/1 finds a build by name and rejects unknown names" do + assert {:ok, %Build{asset: "vmlinux-x86_64-6.1", arch: :x86_64}} = + Manifest.fetch("x86_64-6.1") + + assert Manifest.fetch("does-not-exist") == :error + end + + test "default_for/1 selects the highest-version build for the arch" do + assert %Build{name: "x86_64-6.1"} = Manifest.default_for(:x86_64) + assert %Build{name: "aarch64-6.1"} = Manifest.default_for(:aarch64) + end + + test "asset_url/1 points at the pinned release tag" do + build = Manifest.default_for(:x86_64) + + assert Manifest.asset_url(build) == + "https://github.com/harmont-dev/hyper-vmlinux/releases/download/" <> + "release-54d6c3f843c01fb55107a024cca5bf60af235c42/vmlinux-x86_64-6.1" + end +end From 4afcdfb1b4f97c167d8215e5165f417d072a140d Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 24 Jun 2026 01:21:16 +0000 Subject: [PATCH 5/8] feat(vmlinux): add VmLinux.Provider that installs all kernels for the arch --- lib/hyper/config.ex | 4 + lib/hyper/node/fire_vmm/vm_linux/provider.ex | 92 +++++++++++++++++++ .../node/fire_vmm/vm_linux/provider_test.exs | 43 +++++++++ 3 files changed, 139 insertions(+) create mode 100644 lib/hyper/node/fire_vmm/vm_linux/provider.ex create mode 100644 test/hyper/node/fire_vmm/vm_linux/provider_test.exs diff --git a/lib/hyper/config.ex b/lib/hyper/config.ex index dfca0af6..6f834611 100644 --- a/lib/hyper/config.ex +++ b/lib/hyper/config.ex @@ -58,6 +58,10 @@ defmodule Hyper.Config do @spec firecracker_install_dir :: Path.t() def firecracker_install_dir, do: Path.join(redist_dir(), "firecracker") + @doc "Directory where `Hyper.Node.FireVMM.VmLinux.Provider` installs guest kernels." + @spec vmlinux_install_dir :: Path.t() + def vmlinux_install_dir, do: Path.join(redist_dir(), "vmlinux") + @doc """ Path to the directory where all VM chroot's are created (`/jails`). diff --git a/lib/hyper/node/fire_vmm/vm_linux/provider.ex b/lib/hyper/node/fire_vmm/vm_linux/provider.ex new file mode 100644 index 00000000..217693c2 --- /dev/null +++ b/lib/hyper/node/fire_vmm/vm_linux/provider.ex @@ -0,0 +1,92 @@ +defmodule Hyper.Node.FireVMM.VmLinux.Provider do + @moduledoc """ + Installs the guest-kernel (vmlinux) images for the current architecture into + `Hyper.Config.vmlinux_install_dir/0` (`/redist/vmlinux`). + + The available kernels and their SHA-256 sums come from the statically-embedded + `Hyper.Node.FireVMM.VmLinux.Manifest`. `ensure_installed/0` installs *every* + build for this node's architecture and is idempotent: if all the expected + images are already present it returns `:ok` without touching the network. + Otherwise it fetches each missing image via `Hyper.Redist.File` (download, + SHA-256 verify, install). + + This is the download-side counterpart to operator-provided kernels; see + `Hyper.Node.Vmlinux`, which prefers an operator-configured path and falls back + to `default_path/1` here. + """ + + alias Hyper.Node.FireVMM.VmLinux.Manifest + alias Hyper.Redist + + @doc "Ensure every kernel for this node's architecture is installed." + @spec ensure_installed() :: :ok | {:error, term()} + def ensure_installed do + with {:ok, arch} <- Sys.Arch.current() do + builds = Manifest.builds_for(arch) + + case install_state(install_dir(), builds) do + :ok -> :ok + {:error, :not_installed} -> install_all(builds) + {:error, :bad_install} -> reinstall(builds) + end + end + end + + @doc "Absolute path to the installed kernel for build `name` (e.g. \"x86_64-6.1\")." + @spec path(String.t()) :: {:ok, Path.t()} | {:error, {:unknown_build, String.t()}} + def path(name) do + case Manifest.fetch(name) do + {:ok, build} -> {:ok, build_path(install_dir(), build)} + :error -> {:error, {:unknown_build, name}} + end + end + + @doc "Absolute path to the default (highest-version) kernel for `arch`." + @spec default_path(Sys.Arch.t()) :: {:ok, Path.t()} | {:error, {:no_kernel, Sys.Arch.t()}} + def default_path(arch) do + case Manifest.default_for(arch) do + nil -> {:error, {:no_kernel, arch}} + build -> {:ok, build_path(install_dir(), build)} + end + end + + @doc """ + Install state of `builds` under `dir`: `:ok` if every asset file is present; + `{:error, :not_installed}` if none are; `{:error, :bad_install}` if only some + are (a partial/corrupt install - `Hyper.Redist.File` keeps existing files, so + the remedy is to wipe and reinstall). + """ + @spec install_state(Path.t(), [Manifest.Build.t()]) :: + :ok | {:error, :not_installed | :bad_install} + def install_state(dir, builds) do + present = Enum.count(builds, &File.regular?(build_path(dir, &1))) + + cond do + present == length(builds) -> :ok + present == 0 -> {:error, :not_installed} + true -> {:error, :bad_install} + end + end + + defp install_all(builds) do + Enum.reduce_while(builds, :ok, fn build, :ok -> + case Redist.File.install( + Manifest.asset_url(build), + build.sha256, + build_path(install_dir(), build) + ) do + :ok -> {:cont, :ok} + {:error, _} = err -> {:halt, err} + end + end) + end + + defp reinstall(builds) do + _ = File.rm_rf!(install_dir()) + install_all(builds) + end + + defp build_path(dir, build), do: Path.join(dir, build.asset) + + defp install_dir, do: Hyper.Config.vmlinux_install_dir() +end diff --git a/test/hyper/node/fire_vmm/vm_linux/provider_test.exs b/test/hyper/node/fire_vmm/vm_linux/provider_test.exs new file mode 100644 index 00000000..af15c8fa --- /dev/null +++ b/test/hyper/node/fire_vmm/vm_linux/provider_test.exs @@ -0,0 +1,43 @@ +defmodule Hyper.Node.FireVMM.VmLinux.ProviderTest do + use ExUnit.Case, async: true + + alias Hyper.Node.FireVMM.VmLinux.{Manifest, Provider} + + setup do + dir = Path.join(System.tmp_dir!(), "vmlinux-prov-#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + # Use a real arch that exists in the manifest; both x86_64 and aarch64 do. + {:ok, dir: dir, builds: Manifest.builds_for(:x86_64)} + end + + test "install_state/2 is :not_installed when no asset files are present", %{ + dir: dir, + builds: builds + } do + assert Provider.install_state(dir, builds) == {:error, :not_installed} + end + + test "install_state/2 is :ok when every asset file is present", %{dir: dir, builds: builds} do + for b <- builds, do: File.write!(Path.join(dir, b.asset), "kernel") + assert Provider.install_state(dir, builds) == :ok + end + + test "install_state/2 is :bad_install when only some asset files are present", %{ + dir: dir, + builds: builds + } do + [first | _] = builds + File.write!(Path.join(dir, first.asset), "kernel") + assert Provider.install_state(dir, builds) == {:error, :bad_install} + end + + test "default_path/1 resolves under the configured install dir", %{builds: _} do + assert {:ok, path} = Provider.default_path(:x86_64) + assert path == Path.join(Hyper.Config.vmlinux_install_dir(), "vmlinux-x86_64-6.1") + end + + test "path/1 rejects an unknown build name" do + assert Provider.path("nope") == {:error, {:unknown_build, "nope"}} + end +end From 25bf6a9c5e5a2e0917adcd50f717ba4a83b10cd2 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 24 Jun 2026 01:25:14 +0000 Subject: [PATCH 6/8] feat(vmlinux): resolve kernels via Provider, operator config wins --- lib/hyper/node.ex | 1 + lib/hyper/node/vmlinux.ex | 60 ++++++++++++++++++++++++++++----------- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/lib/hyper/node.ex b/lib/hyper/node.ex index e82f46bc..1a4adacf 100644 --- a/lib/hyper/node.ex +++ b/lib/hyper/node.ex @@ -145,6 +145,7 @@ defmodule Hyper.Node do def test_system do with {:ok, _} <- Hyper.Node.Config.Budget.load(), :ok <- Hyper.Node.FireVMM.Provider.ensure_installed(), + :ok <- Hyper.Node.FireVMM.VmLinux.Provider.ensure_installed(), :ok <- Hyper.Node.Vmlinux.test_system(), :ok <- Hyper.Node.Users.test_system(), :ok <- Hyper.Node.Layer.Repo.test_system(), diff --git a/lib/hyper/node/vmlinux.ex b/lib/hyper/node/vmlinux.ex index d632845b..92f7f688 100644 --- a/lib/hyper/node/vmlinux.ex +++ b/lib/hyper/node/vmlinux.ex @@ -1,35 +1,63 @@ defmodule Hyper.Node.Vmlinux do @moduledoc """ - Resolves the guest kernel (vmlinux) image for this node. Unlike - `Hyper.Node.FireVMM.Provider` (which downloads firecracker), kernels are - pre-provisioned by the operator; their per-architecture paths are configured - via `config :hyper, vmlinux: %{ => }` (see `Hyper.Config.vmlinux/0`). - - `test_system/0` verifies the image for this node's architecture is actually - present on disk, so a misconfigured node aborts at boot instead of failing the - first VM launch. + Resolves the guest kernel (vmlinux) image for this node. + + Two sources, in priority order: + + 1. An operator-configured path for the node's architecture, via + `config :hyper, vmlinux: %{ => }` (see `Hyper.Config.vmlinux/0`). + If set, it wins - the operator can pin a custom kernel. + 2. Otherwise, the default kernel downloaded by + `Hyper.Node.FireVMM.VmLinux.Provider` (highest version for the arch). + + `test_system/0` verifies that whichever source applies actually yields a file + on disk for this node's architecture, so a misconfigured node aborts at boot + instead of failing the first VM launch. """ - @doc "Absolute path to the configured vmlinux image for `arch`." + alias Hyper.Node.FireVMM.VmLinux.Provider + + @doc """ + Absolute path to the kernel image for `arch`: the operator-configured path if + set, otherwise the Provider's default kernel. Raises if neither resolves + (boot's `test_system/0` is expected to have caught that first). + """ @spec path(Sys.Arch.t()) :: Path.t() - def path(arch), do: Map.fetch!(Hyper.Config.vmlinux(), arch) + def path(arch) do + case Map.fetch(Hyper.Config.vmlinux(), arch) do + {:ok, path} -> + path + + :error -> + {:ok, path} = Provider.default_path(arch) + path + end + end @doc """ - Ensure this node's vmlinux image is configured and present. Returns - `{:error, {:vmlinux_unconfigured, arch}}` if no path is set for the node's - architecture, or `{:error, {:vmlinux_missing, path}}` if the configured file - is absent. + Ensure this node's vmlinux image is present. With an operator-configured path, + returns `{:error, {:vmlinux_missing, path}}` if that file is absent. Otherwise + falls back to the Provider's default kernel and returns + `{:error, {:vmlinux_missing, path}}` if it is absent, or `{:error, {:no_kernel, arch}}` + if the manifest has no kernel for this architecture. """ @spec test_system() :: :ok | {:error, term()} def test_system do with {:ok, arch} <- Sys.Arch.current() do case Map.fetch(Hyper.Config.vmlinux(), arch) do {:ok, path} -> - if File.regular?(path), do: :ok, else: {:error, {:vmlinux_missing, path}} + present(path) :error -> - {:error, {:vmlinux_unconfigured, arch}} + case Provider.default_path(arch) do + {:ok, path} -> present(path) + {:error, _} = err -> err + end end end end + + defp present(path) do + if File.regular?(path), do: :ok, else: {:error, {:vmlinux_missing, path}} + end end From 049bccc09fb063d37e98b7b9bcf0dd1482724633 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 24 Jun 2026 01:30:03 +0000 Subject: [PATCH 7/8] docs(vmlinux): note per-arch path guarantee; harden bad-install test --- lib/hyper/node/fire_vmm/vm_linux/provider.ex | 6 ++++-- test/hyper/node/fire_vmm/vm_linux/provider_test.exs | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/hyper/node/fire_vmm/vm_linux/provider.ex b/lib/hyper/node/fire_vmm/vm_linux/provider.ex index 217693c2..110dec77 100644 --- a/lib/hyper/node/fire_vmm/vm_linux/provider.ex +++ b/lib/hyper/node/fire_vmm/vm_linux/provider.ex @@ -32,7 +32,7 @@ defmodule Hyper.Node.FireVMM.VmLinux.Provider do end end - @doc "Absolute path to the installed kernel for build `name` (e.g. \"x86_64-6.1\")." + @doc "Absolute path to the installed kernel for build `name` (e.g. \"x86_64-6.1\"). The returned path is only guaranteed to exist for the node's current architecture (the one `ensure_installed/0` installs)." @spec path(String.t()) :: {:ok, Path.t()} | {:error, {:unknown_build, String.t()}} def path(name) do case Manifest.fetch(name) do @@ -41,7 +41,7 @@ defmodule Hyper.Node.FireVMM.VmLinux.Provider do end end - @doc "Absolute path to the default (highest-version) kernel for `arch`." + @doc "Absolute path to the default (highest-version) kernel for `arch`. The returned path is only guaranteed to exist for the node's current architecture (the one `ensure_installed/0` installs)." @spec default_path(Sys.Arch.t()) :: {:ok, Path.t()} | {:error, {:no_kernel, Sys.Arch.t()}} def default_path(arch) do case Manifest.default_for(arch) do @@ -81,6 +81,8 @@ defmodule Hyper.Node.FireVMM.VmLinux.Provider do end) end + # Safe to wipe wholesale: install_dir/0 is Hyper's own redist cache + # (/redist/vmlinux), never operator data. defp reinstall(builds) do _ = File.rm_rf!(install_dir()) install_all(builds) diff --git a/test/hyper/node/fire_vmm/vm_linux/provider_test.exs b/test/hyper/node/fire_vmm/vm_linux/provider_test.exs index af15c8fa..991a7cb8 100644 --- a/test/hyper/node/fire_vmm/vm_linux/provider_test.exs +++ b/test/hyper/node/fire_vmm/vm_linux/provider_test.exs @@ -27,6 +27,7 @@ defmodule Hyper.Node.FireVMM.VmLinux.ProviderTest do dir: dir, builds: builds } do + assert length(builds) > 1 [first | _] = builds File.write!(Path.join(dir, first.asset), "kernel") assert Provider.install_state(dir, builds) == {:error, :bad_install} From f6fcd335751014278f157e1aef5f8d647df44636 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 24 Jun 2026 03:11:10 +0000 Subject: [PATCH 8/8] test(vmlinux): sha256 property tests + Provider.path success branch --- .../node/fire_vmm/vm_linux/provider_test.exs | 5 ++ test/hyper/redist/sha256_properties_test.exs | 68 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 test/hyper/redist/sha256_properties_test.exs diff --git a/test/hyper/node/fire_vmm/vm_linux/provider_test.exs b/test/hyper/node/fire_vmm/vm_linux/provider_test.exs index 991a7cb8..4bd99330 100644 --- a/test/hyper/node/fire_vmm/vm_linux/provider_test.exs +++ b/test/hyper/node/fire_vmm/vm_linux/provider_test.exs @@ -38,6 +38,11 @@ defmodule Hyper.Node.FireVMM.VmLinux.ProviderTest do assert path == Path.join(Hyper.Config.vmlinux_install_dir(), "vmlinux-x86_64-6.1") end + test "path/1 resolves a known build under the install dir" do + assert Provider.path("x86_64-6.1") == + {:ok, Path.join(Hyper.Config.vmlinux_install_dir(), "vmlinux-x86_64-6.1")} + end + test "path/1 rejects an unknown build name" do assert Provider.path("nope") == {:error, {:unknown_build, "nope"}} end diff --git a/test/hyper/redist/sha256_properties_test.exs b/test/hyper/redist/sha256_properties_test.exs new file mode 100644 index 00000000..60e850c8 --- /dev/null +++ b/test/hyper/redist/sha256_properties_test.exs @@ -0,0 +1,68 @@ +defmodule Hyper.Redist.Sha256PropertiesTest do + @moduledoc """ + `Sha256.file/1` folds a file through `:crypto` in 2 MiB chunks. The contract it + must uphold for EVERY input: the streamed digest equals the one-shot + `:crypto.hash(:sha256, _)` of the same bytes, rendered as 64 lowercase hex + characters. The example test spot-checks a single string; these laws cover + arbitrary content, and the explicit cases pin the two boundaries the streaming + loop can get wrong on its own - the empty (`:eof`-first) file and the + multi-chunk read across the 2 MiB boundary. + """ + use ExUnit.Case, async: true + use ExUnitProperties + + alias Hyper.Redist.Sha256 + + # The streaming digest must equal a hash computed in one shot over the same + # bytes - this is the only thing `file/1` promises, and it is exactly what a + # chunking bug (dropped/duplicated/misordered chunk) would break. + property "streamed file digest equals the one-shot :crypto digest" do + check all(data <- binary()) do + with_temp_file(data, fn path -> + assert Sha256.file(path) == oneshot(data) + end) + end + end + + # The encoding half of the contract: lowercase hex, fixed 32-byte (64-char) + # width, regardless of input. + property "digest is always 64 lowercase hex characters" do + check all(data <- binary()) do + with_temp_file(data, fn path -> + assert Sha256.file(path) =~ ~r/\A[0-9a-f]{64}\z/ + end) + end + end + + # `:file.read/2` returns `:eof` immediately for an empty file; the fold must + # still finalize the (empty-input) digest rather than crash or skip. + test "empty file hashes to the empty-input digest" do + with_temp_file(<<>>, fn path -> + assert Sha256.file(path) == oneshot(<<>>) + end) + end + + # Cross the 2 MiB chunk boundary deterministically: property inputs stay small + # for fast shrinking, so a single large input exercises the multi-read path + # (and the non-multiple-of-chunk-size tail). + test "matches the one-shot digest across the 2 MiB chunk boundary" do + data = :binary.copy(<<0xAB>>, 5 * 1024 * 1024 + 7) + + with_temp_file(data, fn path -> + assert Sha256.file(path) == oneshot(data) + end) + end + + defp oneshot(data), do: :crypto.hash(:sha256, data) |> Base.encode16(case: :lower) + + defp with_temp_file(data, fun) do + path = Path.join(System.tmp_dir!(), "sha256-prop-#{System.unique_integer([:positive])}.bin") + File.write!(path, data) + + try do + fun.(path) + after + File.rm(path) + end + end +end