@@ -14,7 +14,7 @@ defmodule Hyper.MixProject do
1414 # A Mix compiler (not a `compile` alias) is used because Mix honors a
1515 # dependency's `:compilers` but NOT its aliases or `config/` -- so this is
1616 # the only hook that also fires when hyper is compiled AS A DEPENDENCY.
17- compilers: [ :firecracker_gen , :grpc_gen | Mix . compilers ( ) ] ,
17+ compilers: [ :suidhelper_stamp , : firecracker_gen, :grpc_gen | Mix . compilers ( ) ] ,
1818 deps: deps ( ) ,
1919 test_coverage: [ tool: ExCoveralls ] ,
2020 docs: docs ( ) ,
@@ -195,7 +195,9 @@ defmodule Hyper.MixProject do
195195 # Force a regeneration of the Firecracker bindings (ignores staleness).
196196 "firecracker.gen": [ "compile.firecracker_gen --force" ] ,
197197 # Force a regeneration of the gRPC bindings (ignores staleness).
198- "grpc.gen": [ "compile.grpc_gen --force" ]
198+ "grpc.gen": [ "compile.grpc_gen --force" ] ,
199+ # Rebuild + stamp the suidhelper and re-capture its expected identity.
200+ "suidhelper.stamp": [ "compile.suidhelper_stamp --force" ]
199201 ]
200202 end
201203end
@@ -325,3 +327,104 @@ defmodule Mix.Tasks.Compile.FirecrackerGen do
325327 not File . exists? ( @ out ) or File . stat! ( @ spec_path ) . mtime > File . stat! ( @ out ) . mtime
326328 end
327329end
330+
331+ defmodule Mix.Tasks.Compile.SuidhelperStamp do
332+ @ moduledoc """
333+ Mix compiler that builds and stamps the Rust setuid helper, then captures the
334+ build identity it will report at runtime into a generated module,
335+ `Hyper.SuidHelper.Expected` (gitignored, like the other generated bindings).
336+
337+ Steps, run before the Elixir compiler so the generated module compiles:
338+
339+ 1. `cargo xtask stamp` (in `native/suidhelper`) builds the release binary and
340+ writes its BLAKE3 self-checksum into the ELF `.note.sum` section.
341+ 2. The stamped binary's `version` subcommand is invoked; its JSON
342+ (`{"version":..,"checksum_blake3":..}`) is the helper's self-reported
343+ build identity.
344+ 3. Its version + checksum are baked into `lib/hyper/suid_helper/expected.ex`
345+ so the BEAM can compare a deployed helper against the one this build made.
346+
347+ Always runs (no staleness gate): `cargo` is incremental, so a no-op rebuild is
348+ cheap, and this keeps the embedded identity in lockstep with the binary. Like
349+ the protoc compiler, a missing toolchain is a hard failure -- `cargo` and the
350+ helper's nightly toolchain must be present wherever hyper is compiled.
351+
352+ Note: the checksum is *self-reported* by the binary, so the generated module is
353+ a build-provenance / version-skew check, not an adversarial tamper proof (a
354+ malicious binary could print any value). Real tamper detection would re-hash
355+ the on-disk ELF with `.note.sum` zeroed and compare -- the embedded checksum is
356+ the reference value that check would use.
357+ """
358+
359+ use Mix.Task.Compiler
360+
361+ @ helper_dir "native/suidhelper"
362+ @ binary "native/suidhelper/target/release/hyper-suidhelper"
363+ @ out "lib/hyper/suid_helper/expected.ex"
364+
365+ @ impl Mix.Task.Compiler
366+ def run ( _argv ) do
367+ stamp! ( )
368+ json = capture_version! ( )
369+ generate ( json )
370+ { :ok , [ ] }
371+ end
372+
373+ defp stamp! do
374+ case System . cmd ( "cargo" , [ "xtask" , "stamp" ] , cd: @ helper_dir , stderr_to_stdout: true ) do
375+ { _ , 0 } ->
376+ :ok
377+
378+ { output , code } ->
379+ Mix . raise ( """
380+ `cargo xtask stamp` failed (exit #{ code } ) building the suidhelper:
381+
382+ #{ output }
383+ Ensure `cargo` and the helper's toolchain (see #{ @ helper_dir } /rust-toolchain.toml)
384+ are installed.
385+ """ )
386+ end
387+ end
388+
389+ defp capture_version! do
390+ case System . cmd ( Path . expand ( @ binary ) , [ "version" ] , stderr_to_stdout: true ) do
391+ { out , 0 } ->
392+ String . trim ( out )
393+
394+ { out , code } ->
395+ Mix . raise ( "`hyper-suidhelper version` failed (exit #{ code } ): #{ out } " )
396+ end
397+ end
398+
399+ defp generate ( json ) do
400+ # Jason and the app's deps are available once loadpaths runs.
401+ Mix.Task . run ( "loadpaths" )
402+ % { "version" => version , "checksum_blake3" => checksum } = Jason . decode! ( json )
403+
404+ File . mkdir_p! ( Path . dirname ( @ out ) )
405+
406+ source = """
407+ defmodule Hyper.SuidHelper.Expected do
408+ @moduledoc false
409+ # GENERATED by Mix.Tasks.Compile.SuidhelperStamp from the stamped
410+ # `hyper-suidhelper version` output. Do not edit; gitignored.
411+
412+ @version #{ inspect ( version ) }
413+ @checksum_blake3 #{ inspect ( checksum ) }
414+
415+ @doc "Expected helper version."
416+ @spec version() :: String.t()
417+ def version, do: @version
418+
419+ @doc "Expected BLAKE3 checksum (hex) of the stamped helper."
420+ @spec checksum_blake3() :: String.t()
421+ def checksum_blake3, do: @checksum_blake3
422+ end
423+ """
424+
425+ # Format the generated source directly rather than via `Mix.Task.run("format",
426+ # ...)`: a Mix task runs once per session, so invoking it here would consume
427+ # the single run and leave the later `:grpc_gen` compiler's format a no-op.
428+ File . write! ( @ out , [ Code . format_string! ( source ) , "\n " ] )
429+ end
430+ end
0 commit comments