From 31931ef73a252def98ef73cbf61c72a2081f5fde Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Fri, 12 Jun 2026 17:50:47 +0100 Subject: [PATCH] Build Linux MCP executable with static musl --- .github/workflows/cibuildgem.yaml | 6 + ext/rubydex/extconf.rb | 29 +++-- lib/rubydex/mcp_executable_build.rb | 144 +++++++++++++++++++++++ test/mcp_executable_build_test.rb | 171 ++++++++++++++++++++++++++++ 4 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 lib/rubydex/mcp_executable_build.rb create mode 100644 test/mcp_executable_build_test.rb diff --git a/.github/workflows/cibuildgem.yaml b/.github/workflows/cibuildgem.yaml index af7c32bb..6bb61128 100644 --- a/.github/workflows/cibuildgem.yaml +++ b/.github/workflows/cibuildgem.yaml @@ -26,6 +26,12 @@ jobs: with: ruby-version: "3.2.9" bundler-cache: true + - name: "Install musl build tools" + if: ${{ runner.os == 'Linux' }} + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + rustup target add ${{ runner.arch == 'ARM64' && 'aarch64-unknown-linux-musl' || 'x86_64-unknown-linux-musl' }} - name: "Install cargo-about" run: cargo install cargo-about --features cli - name: "Run cibuildgem" diff --git a/ext/rubydex/extconf.rb b/ext/rubydex/extconf.rb index c17f5de1..28848687 100644 --- a/ext/rubydex/extconf.rb +++ b/ext/rubydex/extconf.rb @@ -4,6 +4,8 @@ require "fileutils" require "pathname" +require_relative "../../lib/rubydex/mcp_executable_build" + unless system("cargo", "--version", out: File::NULL, err: File::NULL) abort "Installing Rubydex requires Cargo, the Rust package manager for platforms that we do not precompile binaries." end @@ -19,20 +21,26 @@ bundle_gemfile = ENV["BUNDLE_GEMFILE"] developing_rubydex = bundle_gemfile && Pathname.new(bundle_gemfile).expand_path.dirname == gem_dir -release = ENV["RELEASE"] || !developing_rubydex +precompiling_gem = !!ENV["RELEASE"] +release = precompiling_gem || !developing_rubydex root_dir = gem_dir.join("rust") +bindings_path = root_dir.join("rubydex-sys").join("rustbindings.h") + +extension_target = "x86_64-pc-windows-gnu" if Gem.win_platform? +mcp_build = Rubydex::MCPExecutableBuild.new(release:, static_musl: precompiling_gem) +split_mcp_build = mcp_build.target && mcp_build.target != extension_target + target_dir = root_dir.join("target") -target_dir = target_dir.join("x86_64-pc-windows-gnu") if Gem.win_platform? +target_dir = target_dir.join(extension_target) if extension_target target_dir = target_dir.join(release ? "release" : "debug") -bindings_path = root_dir.join("rubydex-sys").join("rustbindings.h") - cargo_args = ["--manifest-path #{root_dir.join("Cargo.toml")}"] +cargo_args << "--package rubydex-sys" if split_mcp_build cargo_args << "--release" if release -if Gem.win_platform? - cargo_args << "--target x86_64-pc-windows-gnu" +if extension_target + cargo_args << "--target #{extension_target}" ENV["RUSTFLAGS"] = "-C target-feature=+crt-static" end @@ -85,9 +93,10 @@ mcp_bin_dir = lib_dir.join("bin") FileUtils.mkdir_p(mcp_bin_dir) -mcp_executable = Gem.win_platform? ? "rubydex_mcp.exe" : "rubydex_mcp" -mcp_src = target_dir.join(mcp_executable) -mcp_dst = mcp_bin_dir.join(mcp_executable) +mcp_src = split_mcp_build ? root_dir.join(mcp_build.relative_binary_path) : target_dir.join(mcp_build.executable_name) +mcp_dst = mcp_bin_dir.join(mcp_build.executable_name) +mcp_cargo_command = mcp_build.cargo_command(root_dir.join("Cargo.toml")) if split_mcp_build +mcp_verify_static_command = mcp_build.verify_static_command(mcp_src, ruby: "$(RUBY)") copy_dylib_commands = if Gem.win_platform? "" @@ -113,6 +122,8 @@ .rust_built: $(RUST_SRCS) \t#{cargo_command} || (echo "Compiling Rust failed" && exit 1) + #{"\t#{mcp_cargo_command} || (echo \"Compiling rubydex_mcp failed\" && exit 1)" if mcp_cargo_command} + #{"\t#{mcp_verify_static_command} || (echo \"rubydex_mcp is not fully static\" && exit 1)" if mcp_verify_static_command} \t$(COPY) #{bindings_path} #{__dir__} \t$(COPY) #{mcp_src} #{mcp_dst} \ttouch $@ diff --git a/lib/rubydex/mcp_executable_build.rb b/lib/rubydex/mcp_executable_build.rb new file mode 100644 index 00000000..baf45ba7 --- /dev/null +++ b/lib/rubydex/mcp_executable_build.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "rbconfig" +require "shellwords" + +module Rubydex + class MCPExecutableBuild + LINUX_MUSL_TARGETS = { + "x86_64" => "x86_64-unknown-linux-musl", + "aarch64" => "aarch64-unknown-linux-musl", + "arm64" => "aarch64-unknown-linux-musl", + }.freeze + + attr_reader :release, :static_musl, :host_os, :host_cpu + + def initialize( + release:, + static_musl: false, + host_os: RbConfig::CONFIG.fetch("host_os"), + host_cpu: RbConfig::CONFIG.fetch("host_cpu") + ) + @release = release + @static_musl = static_musl + @host_os = host_os + @host_cpu = host_cpu + end + + def target + return windows_target if windows? + return linux_musl_target if static_musl && linux? + + nil + end + + def executable_name + windows? ? "rubydex_mcp.exe" : "rubydex_mcp" + end + + def relative_binary_path + parts = ["target"] + parts << target if target + parts << profile + parts << executable_name + parts.join("/") + end + + def cargo_command(manifest_path) + args = cargo_environment.map { |key, value| "#{key}=#{value}" } + args += ["cargo", "build", "--manifest-path", manifest_path.to_s] + args << "--release" if release + args << "--package" << "rubydex-mcp" + args << "--target" << target if target + shell_command(args) + end + + def verify_static_command(path, ruby: RbConfig.ruby) + return unless target&.end_with?("-linux-musl") + + script = 'path = ARGV.fetch(0); abort("#{path} has a dynamic ELF interpreter") unless Rubydex::MCPExecutableBuild.static_elf?(path)' + + shell_command([ + ruby, + "-I#{File.expand_path("..", __dir__)}", + "-rrubydex/mcp_executable_build", + "-e", + script, + path.to_s, + ]) + end + + class << self + def static_elf?(path) + File.open(path, "rb") do |file| + return false unless file.read(4) == "\x7fELF".b + + elf_class = file.read(1)&.unpack1("C") + return false unless elf_class == 2 + + file.seek(0x20) + program_header_offset = file.read(8).unpack1("Q<") + file.seek(0x36) + program_header_entry_size = file.read(2).unpack1("v") + program_header_count = file.read(2).unpack1("v") + + program_header_count.times do |index| + file.seek(program_header_offset + (index * program_header_entry_size)) + return false if file.read(4).unpack1("V") == 3 + end + + true + end + rescue SystemCallError, EOFError + false + end + end + + private + + def linux? + host_os.include?("linux") + end + + def windows? + host_os.match?(/mswin|mingw|cygwin/) + end + + def windows_target + "x86_64-pc-windows-gnu" + end + + def linux_musl_target + LINUX_MUSL_TARGETS.fetch(host_cpu) do + raise "Unsupported Linux CPU for static rubydex_mcp build: #{host_cpu}" + end + end + + def cargo_environment + return {} unless target&.end_with?("-linux-musl") + + env_target = target.upcase.tr("-", "_") + { + "CC_#{target.tr("-", "_")}" => "musl-gcc", + "CARGO_TARGET_#{env_target}_LINKER" => "musl-gcc", + "CARGO_TARGET_#{env_target}_RUSTFLAGS" => "-C target-feature=+crt-static -C link-arg=-static", + } + end + + def profile + release ? "release" : "debug" + end + + def shell_command(args) + args.map do |arg| + if (match = arg.match(/\A([A-Za-z0-9_]+)=(.*)\z/)) + "#{match[1]}=#{Shellwords.escape(match[2])}" + elsif arg.match?(/\A\$\([A-Za-z0-9_]+\)\z/) + arg + else + Shellwords.escape(arg) + end + end.join(" ") + end + end +end diff --git a/test/mcp_executable_build_test.rb b/test/mcp_executable_build_test.rb new file mode 100644 index 00000000..ee9d2003 --- /dev/null +++ b/test/mcp_executable_build_test.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "rbconfig" +require "shellwords" +require "tmpdir" + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "rubydex/mcp_executable_build" + +class MCPExecutableBuildTest < Minitest::Test + def test_linux_release_uses_static_musl_target_for_x86_64 + build = Rubydex::MCPExecutableBuild.new( + release: true, + static_musl: true, + host_os: "linux-gnu", + host_cpu: "x86_64", + ) + + assert_equal("x86_64-unknown-linux-musl", build.target) + assert_equal("target/x86_64-unknown-linux-musl/release/rubydex_mcp", build.relative_binary_path) + assert_equal( + "CC_x86_64_unknown_linux_musl=musl-gcc CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=-C\\ target-feature\\=+crt-static\\ -C\\ link-arg\\=-static cargo build --manifest-path rust/Cargo.toml --release --package rubydex-mcp --target x86_64-unknown-linux-musl", + build.cargo_command("rust/Cargo.toml"), + ) + end + + def test_linux_release_uses_static_musl_target_for_aarch64 + build = Rubydex::MCPExecutableBuild.new( + release: true, + static_musl: true, + host_os: "linux-gnu", + host_cpu: "aarch64", + ) + + assert_equal("aarch64-unknown-linux-musl", build.target) + assert_equal("target/aarch64-unknown-linux-musl/release/rubydex_mcp", build.relative_binary_path) + end + + def test_non_release_linux_keeps_host_target + build = Rubydex::MCPExecutableBuild.new( + release: false, + host_os: "linux-gnu", + host_cpu: "x86_64", + ) + + assert_nil(build.target) + assert_equal("target/debug/rubydex_mcp", build.relative_binary_path) + assert_equal( + "cargo build --manifest-path rust/Cargo.toml --package rubydex-mcp", + build.cargo_command("rust/Cargo.toml"), + ) + assert_nil(build.verify_static_command("target/debug/rubydex_mcp")) + end + + def test_source_install_linux_release_keeps_host_target + build = Rubydex::MCPExecutableBuild.new( + release: true, + static_musl: false, + host_os: "linux-gnu", + host_cpu: "x86_64", + ) + + assert_nil(build.target) + assert_equal("target/release/rubydex_mcp", build.relative_binary_path) + assert_nil(build.verify_static_command("target/release/rubydex_mcp")) + end + + def test_non_linux_release_keeps_host_target + build = Rubydex::MCPExecutableBuild.new( + release: true, + host_os: "darwin24", + host_cpu: "arm64", + ) + + assert_nil(build.target) + assert_equal("target/release/rubydex_mcp", build.relative_binary_path) + end + + def test_windows_uses_exe_name + build = Rubydex::MCPExecutableBuild.new( + release: true, + host_os: "mingw32", + host_cpu: "x64", + ) + + assert_equal("rubydex_mcp.exe", build.executable_name) + assert_equal("target/x86_64-pc-windows-gnu/release/rubydex_mcp.exe", build.relative_binary_path) + end + + def test_static_musl_build_has_verification_command + build = Rubydex::MCPExecutableBuild.new( + release: true, + static_musl: true, + host_os: "linux-gnu", + host_cpu: "x86_64", + ) + + command = build.verify_static_command("target/x86_64-unknown-linux-musl/release/rubydex_mcp") + argv = Shellwords.split(command) + + assert_equal(RbConfig.ruby, argv[0]) + assert_equal("-I#{File.expand_path("../lib", __dir__)}", argv[1]) + assert_equal("-rrubydex/mcp_executable_build", argv[2]) + assert_equal("-e", argv[3]) + assert_includes(argv[4], "Rubydex::MCPExecutableBuild.static_elf?(path)") + assert_equal("target/x86_64-unknown-linux-musl/release/rubydex_mcp", argv[5]) + end + + def test_static_musl_build_can_verify_with_makefile_ruby + build = Rubydex::MCPExecutableBuild.new( + release: true, + static_musl: true, + host_os: "linux-gnu", + host_cpu: "x86_64", + ) + + command = build.verify_static_command("target/x86_64-unknown-linux-musl/release/rubydex_mcp", ruby: "$(RUBY)") + argv = Shellwords.split(command) + + assert_match(/\A\$\(RUBY\) /, command) + assert_equal("$(RUBY)", argv[0]) + end + + def test_unsupported_linux_cpu_fails_loudly + build = Rubydex::MCPExecutableBuild.new( + release: true, + static_musl: true, + host_os: "linux-gnu", + host_cpu: "powerpc64le", + ) + + error = assert_raises(RuntimeError) { build.target } + assert_match(/Unsupported Linux CPU for static rubydex_mcp build: powerpc64le/, error.message) + end + + def test_static_elf_has_no_interpreter + Dir.mktmpdir do |dir| + elf_path = File.join(dir, "rubydex_mcp") + File.binwrite(elf_path, elf_header_with_program_header(type: 1)) + + assert(Rubydex::MCPExecutableBuild.static_elf?(elf_path)) + end + end + + def test_dynamic_elf_has_interpreter + Dir.mktmpdir do |dir| + elf_path = File.join(dir, "rubydex_mcp") + File.binwrite(elf_path, elf_header_with_program_header(type: 3)) + + refute(Rubydex::MCPExecutableBuild.static_elf?(elf_path)) + end + end + + private + + def elf_header_with_program_header(type:) + header = "\x7fELF".b + header << [2, 1, 1, 0].pack("C*") + header << ("\0".b * 8) + header << [2, 62, 1].pack("v v V") + header << [0, 64].pack("Q< Q<") + header << [0].pack("Q<") + header << [0, 64, 56, 1, 0, 0, 0].pack("V v v v v v v") + + program_header = [type, 0].pack("V V") + program_header << [0, 0, 0, 0, 0, 0].pack("Q< Q< Q< Q< Q< Q<") + + header + program_header + end +end