require "rubygems/ext" require "rubygems/ext/builder" require_relative "cargo_builder/link_flag_converter" module RbSys # A class to build a Ruby gem Cargo. Extracted from `rubygems` gem, with some modifications. # @api private class CargoBuilder < Gem::Ext::Builder WELL_KNOWN_WRAPPERS = %w[sccache cachepot].freeze attr_accessor :spec, :runner, :env, :features, :target, :extra_rustc_args, :dry_run, :ext_dir, :extra_rustflags, :extra_cargo_args, :config attr_writer :profile def initialize(spec) require "rubygems/command" require_relative "cargo_builder/link_flag_converter" @spec = spec @runner = self.class.method(:run) @profile = ENV.fetch("RB_SYS_CARGO_PROFILE", :release).to_sym @env = {} @features = [] @target = ENV["CARGO_BUILD_TARGET"] || ENV["RUST_TARGET"] @extra_rustc_args = [] @extra_cargo_args = [] @dry_run = true @ext_dir = "" @extra_rustflags = [] end def profile return :release if rubygems_invoked? @profile end def build(_extension, dest_path, results, args = [], lib_dir = nil, cargo_dir = Dir.pwd) require "fileutils" require "shellwords" build_crate(dest_path, results, args, cargo_dir) validate_cargo_build!(dest_path) rename_cdylib_for_ruby_compatibility(dest_path) finalize_directory(dest_path, lib_dir, cargo_dir) results end def build_crate(dest_path, results, args, cargo_dir) env = build_env cmd = cargo_command(dest_path, args) runner.call cmd, results, "cargo", cargo_dir, env results end def build_env build_env = rb_config_env build_env["RUBY_STATIC"] = "true" if ruby_static? && ENV.key?("RUBY_STATIC") build_env.merge(env) end def manifest_dir ext_dir end def cargo_command(dest_path, args = []) cmd = [] cmd += if config.use_cargo_build ["cargo", "build"] else ["cargo", "rustc"] end cmd += ["--target", target] if target cmd += ["--target-dir", dest_path] cmd += ["--features", features.join(",")] unless features.empty? cmd += ["--lib"] cmd += ["--profile", profile.to_s] cmd += Gem::Command.build_args cmd += args if !config.use_cargo_build cmd += ["--"] cmd += [*rustc_args(dest_path)] cmd += extra_rustc_args end cmd end def cargo_dylib_path(dest_path) prefix = so_ext == "dll" ? "" : "lib" path_parts = [dest_path] path_parts << target if target path_parts += [profile_target_directory, "#{prefix}#{cargo_crate_name}.#{so_ext}"] File.join(*path_parts) end # We have to basically reimplement RbConfig::CONFIG['SOEXT'] here to support # Ruby < 2.5 # # @see https://github.com/ruby/ruby/blob/c87c027f18c005460746a74c07cd80ee355b16e4/configure.ac#L3185 def so_ext return RbConfig::CONFIG["SOEXT"] if RbConfig::CONFIG.key?("SOEXT") if win_target? "dll" elsif darwin_target? "dylib" else "so" end end private def rb_config_env result = {} RbConfig::CONFIG.each { |k, v| result["RBCONFIG_#{k}"] = v } result end def rustc_args(dest_dir) [ *linker_args, *mkmf_libpath, *rustc_dynamic_linker_flags(dest_dir), *rustc_lib_flags(dest_dir), *platform_specific_rustc_args(dest_dir) ] end def platform_specific_rustc_args(dest_dir, flags = []) if mingw_target? # On mingw platforms, mkmf adds libruby to the linker flags flags += libruby_args(dest_dir) # Make sure ALSR is used on mingw # see https://github.com/rust-lang/rust/pull/75406/files flags += ["-C", "link-arg=-Wl,--dynamicbase"] flags += ["-C", "link-arg=-Wl,--disable-auto-image-base"] # If the gem is installed on a host with build tools installed, but is # run on one that isn't the missing libraries will cause the extension # to fail on start. flags += ["-C", "link-arg=-static-libgcc"] elsif darwin_target? # See https://github.com/oxidize-rb/rb-sys/issues/88 dl_flag = "-Wl,-undefined,dynamic_lookup" flags += ["-C", "link-arg=#{dl_flag}"] unless makefile_config("DLDFLAGS")&.include?(dl_flag) end flags end # We want to use the same linker that Ruby uses, so that the linker flags from # mkmf work properly. def linker_args cc_flag = Shellwords.split(makefile_config("CC")) linker = cc_flag.shift if WELL_KNOWN_WRAPPERS.any? { |w| linker.include?(w) } linker = cc_flag.shift end link_args = cc_flag.flat_map { |a| ["-C", "link-arg=#{a}"] } return mswin_link_args if linker == "cl" ["-C", "linker=#{linker}", *link_args] end def mswin_link_args args = [] args += ["-l", makefile_config("LIBRUBYARG_SHARED").chomp(".lib")] args += split_flags("LIBS").flat_map { |lib| ["-l", lib.chomp(".lib")] } args += split_flags("LOCAL_LIBS").flat_map { |lib| ["-l", lib.chomp(".lib")] } args end def libruby_args(dest_dir) libs = makefile_config(ruby_static? ? "LIBRUBYARG_STATIC" : "LIBRUBYARG_SHARED") raw_libs = Shellwords.split(libs) raw_libs.flat_map { |l| ldflag_to_link_modifier(l) } end def ruby_static? return true if %w[1 true].include?(ENV["RUBY_STATIC"]) makefile_config("ENABLE_SHARED") == "no" end # Ruby expects the dylib to follow a file name convention for loading def rename_cdylib_for_ruby_compatibility(dest_path) new_path = final_extension_path(dest_path) FileUtils.cp(cargo_dylib_path(dest_path), new_path) new_path end def validate_cargo_build!(dir) dylib_path = cargo_dylib_path(dir) raise DylibNotFoundError, dir unless File.exist?(dylib_path) dylib_path end def final_extension_path(dest_path) dylib_path = cargo_dylib_path(dest_path) dlext_name = "#{spec.name}.#{makefile_config("DLEXT")}" dylib_path.gsub(File.basename(dylib_path), dlext_name) end def cargo_crate_name spec.metadata.fetch("cargo_crate_name", spec.name).tr("-", "_") end def rustc_dynamic_linker_flags(dest_dir) split_flags("DLDFLAGS") .map { |arg| maybe_resolve_ldflag_variable(arg, dest_dir) } .compact .flat_map { |arg| ldflag_to_link_modifier(arg) } end def rustc_lib_flags(dest_dir) split_flags("LIBS").flat_map { |arg| ldflag_to_link_modifier(arg) } end def split_flags(var) Shellwords.split(RbConfig::CONFIG.fetch(var, "")) end def ldflag_to_link_modifier(arg) LinkFlagConverter.convert(arg) end def msvc_target? makefile_config("target_os").include?("msvc") end def darwin_target? makefile_config("target_os").include?("darwin") end def mingw_target? makefile_config("target_os").include?("mingw") end def win_target? target_platform = RbConfig::CONFIG["target_os"] !!Gem::WIN_PATTERNS.find { |r| target_platform =~ r } end # Interpolate substition vars in the arg def maybe_resolve_ldflag_variable(input_arg, dest_dir) var_matches = input_arg.match(/\$\((\w+)\)/) return input_arg unless var_matches var_name = var_matches[1] return input_arg if var_name.nil? || var_name.chomp.empty? case var_name when "DEFFILE" # DEFFILE already generated by cargo else RbConfig::CONFIG[var_name] end end # Corresponds to $(LIBPATH) in mkmf def mkmf_libpath ["-L", "native=#{makefile_config("libdir")}"] end def makefile_config(var_name) val = RbConfig::MAKEFILE_CONFIG[var_name] return unless val RbConfig.expand(val.dup) end # Copied from ExtConfBuilder def finalize_directory(dest_path, lib_dir, extension_dir) require "fileutils" require "tempfile" ext_path = final_extension_path(dest_path) begin tmp_dest = Dir.mktmpdir(".gem.", extension_dir) # Some versions of `mktmpdir` return absolute paths, which will break make # if the paths contain spaces. However, on Ruby 1.9.x on Windows, relative # paths cause all C extension builds to fail. # # As such, we convert to a relative path unless we are using Ruby 1.9.x on # Windows. This means that when using Ruby 1.9.x on Windows, paths with # spaces do not work. # # Details: https://github.com/rubygems/rubygems/issues/977#issuecomment-171544940 tmp_dest_relative = get_relative_path(tmp_dest.clone, extension_dir) if tmp_dest_relative full_tmp_dest = File.join(extension_dir, tmp_dest_relative) # TODO: remove in RubyGems 3 if Gem.install_extension_in_lib && lib_dir FileUtils.mkdir_p lib_dir FileUtils.cp_r ext_path, lib_dir, remove_destination: true end FileUtils::Entry_.new(full_tmp_dest).traverse do |ent| destent = ent.class.new(dest_path, ent.rel) destent.exist? || FileUtils.mv(ent.path, destent.path) end end ensure FileUtils.rm_rf tmp_dest if tmp_dest end end def get_relative_path(path, base) path[0..base.length - 1] = "." if path.start_with?(base) path end def profile_target_directory case profile.to_sym when :release then "release" when :dev then "debug" else raise "unknown target directory for profile: #{profile}" end end def rubygems_invoked? ENV.key?("SOURCE_DATE_EPOCH") && ENV["RB_SYS_TEST"] != "1" end # Error raised when no cdylib artifact was created class DylibNotFoundError < StandardError def initialize(dir) files = Dir.glob(File.join(dir, "**", "*")).map { |f| "- #{f}" }.join "\n" super <<~MSG Dynamic library not found for Rust extension (in #{dir}) Make sure you set "crate-type" in Cargo.toml to "cdylib" Found files: #{files} MSG end end end end