Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/cibuildgem.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 20 additions & 9 deletions ext/rubydex/extconf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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?
""
Expand All @@ -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 $@
Expand Down
144 changes: 144 additions & 0 deletions lib/rubydex/mcp_executable_build.rb
Original file line number Diff line number Diff line change
@@ -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
171 changes: 171 additions & 0 deletions test/mcp_executable_build_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading