require 'rbconfig' require 'net/http' require 'net/https' require 'fileutils' require 'tempfile' require 'digest' require 'open-uri' require 'cgi' require 'rbconfig' require 'shellwords' # Monkey patch for Net::HTTP by ruby open-uri fix: # https://github.com/ruby/ruby/commit/58835a9 class Net::HTTP private remove_method(:edit_path) def edit_path(path) if proxy? if path.start_with?("ftp://") || use_ssl? path else "http://#{addr_port}#{path}" end else path end end end class MiniPortile attr_reader :name, :version, :original_host attr_writer :configure_options attr_accessor :host, :files, :patch_files, :target, :logger, :source_directory def self.windows? RbConfig::CONFIG['target_os'] =~ /mswin|mingw/ end # GNU MinGW compiled Ruby? def self.mingw? RbConfig::CONFIG['target_os'] =~ /mingw/ end # MS Visual-C compiled Ruby? def self.mswin? RbConfig::CONFIG['target_os'] =~ /mswin/ end def initialize(name, version) @name = name @version = version @target = 'ports' @files = [] @patch_files = [] @log_files = {} @logger = STDOUT @source_directory = nil @original_host = @host = detect_host end def source_directory=(path) @source_directory = File.expand_path(path) end def prepare_build_directory raise "source_directory is not set" if source_directory.nil? output "Building #{@name} #{@version} from source at '#{source_directory}'" FileUtils.mkdir_p(File.join(tmp_path, [name, version].join("-"))) FileUtils.rm_rf(port_path) # make sure we always re-install end def download files_hashs.each do |file| download_file(file[:url], file[:local_path]) verify_file(file) end end def extract files_hashs.each do |file| verify_file(file) extract_file(file[:local_path], tmp_path) end end def apply_patch(patch_file) ( # Not a class variable because closures will capture self. @apply_patch ||= case when which('git') lambda { |file| message "Running git apply with #{file}... " # By --work-tree=. git-apply uses the current directory as # the project root and will not search upwards for .git. execute('patch', ["git", "--git-dir=.", "--work-tree=.", "apply", "--whitespace=warn", file], :initial_message => false) } when which('patch') lambda { |file| message "Running patch with #{file}... " execute('patch', ["patch", "-p1", "-i", file], :initial_message => false) } else raise "Failed to complete patch task; patch(1) or git(1) is required." end ).call(patch_file) end def patch @patch_files.each do |full_path| next unless File.exist?(full_path) apply_patch(full_path) end end def configure_options @configure_options ||= configure_defaults end def configure return if configured? FileUtils.mkdir_p(tmp_path) cache_file = File.join(tmp_path, 'configure.options_cache') File.open(cache_file, "w") { |f| f.write computed_options.to_s } command = Array(File.join((source_directory || "."), "configure")) if RUBY_PLATFORM=~/mingw|mswin/ # Windows doesn't recognize the shebang. command.unshift("sh") end execute('configure', command + computed_options) end def compile execute('compile', make_cmd) end def install return if installed? execute('install', %Q(#{make_cmd} install)) end def downloaded? missing = files_hashs.detect do |file| !File.exist?(file[:local_path]) end missing ? false : true end def configured? configure = File.join((source_directory || work_path), 'configure') makefile = File.join(work_path, 'Makefile') cache_file = File.join(tmp_path, 'configure.options_cache') stored_options = File.exist?(cache_file) ? File.read(cache_file) : "" current_options = computed_options.to_s (current_options == stored_options) && newer?(makefile, configure) end def installed? makefile = File.join(work_path, 'Makefile') target_dir = Dir.glob("#{port_path}/*").find { |d| File.directory?(d) } newer?(target_dir, makefile) end def cook if source_directory prepare_build_directory else download unless downloaded? extract patch end configure unless configured? compile install unless installed? return true end def activate lib_path = File.join(port_path, "lib") vars = { 'PATH' => File.join(port_path, 'bin'), 'CPATH' => File.join(port_path, 'include'), 'LIBRARY_PATH' => lib_path }.reject { |env, path| !File.directory?(path) } output "Activating #{@name} #{@version} (from #{port_path})..." vars.each do |var, path| full_path = File.expand_path(path) # turn into a valid Windows path (if required) full_path.gsub!(File::SEPARATOR, File::ALT_SEPARATOR) if File::ALT_SEPARATOR # save current variable value old_value = ENV[var] || '' unless old_value.include?(full_path) ENV[var] = "#{full_path}#{File::PATH_SEPARATOR}#{old_value}" end end # rely on LDFLAGS when cross-compiling if File.exist?(lib_path) && (@host != @original_host) full_path = File.expand_path(lib_path) old_value = ENV.fetch("LDFLAGS", "") unless old_value.include?(full_path) ENV["LDFLAGS"] = "-L#{full_path} #{old_value}".strip end end end def path File.expand_path(port_path) end private def tmp_path "tmp/#{@host}/ports/#{@name}/#{@version}" end def port_path "#{@target}/#{@host}/#{@name}/#{@version}" end def archives_path "#{@target}/archives" end def work_path Dir.glob("#{tmp_path}/*").find { |d| File.directory?(d) } end def configure_defaults [ "--host=#{@host}", # build for specific target (host) "--enable-static", # build static library "--disable-shared" # disable generation of shared object ] end def configure_prefix "--prefix=#{File.expand_path(port_path)}" end def computed_options [ configure_options, # customized or default options configure_prefix, # installation target ].flatten end def files_hashs @files.map do |file| hash = case file when String { :url => file } when Hash file.dup else raise ArgumentError, "files must be an Array of Stings or Hashs" end url = hash.fetch(:url){ raise ArgumentError, "no url given" } filename = File.basename(url) hash[:local_path] = File.join(archives_path, filename) hash end end KEYRING_NAME = "mini_portile_keyring.gpg" def verify_file(file) if file.has_key?(:gpg) gpg = file[:gpg] signature_url = gpg[:signature_url] || "#{file[:url]}.asc" signature_file = file[:local_path] + ".asc" # download the signature file download_file(signature_url, signature_file) gpg_exe = which('gpg2') || which('gpg') || raise("Neither GPG nor GPG2 is installed") # import the key into our own keyring gpg_status = IO.popen([gpg_exe, "--status-fd", "1", "--no-default-keyring", "--keyring", KEYRING_NAME, "--import"], "w+") do |io| io.write gpg[:key] io.close_write io.read end key_ids = gpg_status.scan(/\[GNUPG:\] IMPORT_OK \d+ (?[0-9a-f]+)/i).map(&:first) raise "invalid gpg key provided" if key_ids.empty? # verify the signature against our keyring gpg_status = IO.popen([gpg_exe, "--status-fd", "1", "--no-default-keyring", "--keyring", KEYRING_NAME, "--verify", signature_file, file[:local_path]], &:read) # remove the key from our keyring key_ids.each do |key_id| IO.popen([gpg_exe, "--batch", "--yes", "--no-default-keyring", "--keyring", KEYRING_NAME, "--delete-keys", key_id], &:read) raise "unable to delete the imported key" unless $?.exitstatus==0 end raise "signature mismatch" unless gpg_status.match(/^\[GNUPG:\] VALIDSIG/) else digest = case when exp=file[:sha256] then Digest::SHA256 when exp=file[:sha1] then Digest::SHA1 when exp=file[:md5] then Digest::MD5 end if digest is = digest.file(file[:local_path]).hexdigest unless is == exp.downcase raise "Downloaded file '#{file[:local_path]}' has wrong hash: expected: #{exp} is: #{is}" end end end end def log_file(action) @log_files[action] ||= File.expand_path("#{action}.log", tmp_path).tap { |file| File.unlink(file) if File.exist?(file) } end TAR_EXECUTABLES = %w[gtar bsdtar tar basic-bsdtar] def tar_exe @@tar_exe ||= begin TAR_EXECUTABLES.find { |c| which(c) } or raise("tar not found - please make sure that one of the following commands is in the PATH: #{TAR_EXECUTABLES.join(", ")}") end end def tar_compression_switch(filename) case File.extname(filename) when '.gz', '.tgz' 'z' when '.bz2', '.tbz2' 'j' when '.Z' 'Z' else '' end end # From: http://stackoverflow.com/a/5471032/7672 # Thanks, Mislav! # # Cross-platform way of finding an executable in the $PATH. # # which('ruby') #=> /usr/bin/ruby def which(cmd) exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : [''] ENV['PATH'].split(File::PATH_SEPARATOR).each do |path| exts.each { |ext| exe = File.join(path, "#{cmd}#{ext}") return exe if File.executable? exe } end return nil end def detect_host return @detect_host if defined?(@detect_host) begin ENV["LC_ALL"], old_lc_all = "C", ENV["LC_ALL"] output = `#{gcc_cmd} -v 2>&1` if m = output.match(/^Target\: (.*)$/) @detect_host = m[1] end @detect_host ensure ENV["LC_ALL"] = old_lc_all end end def extract_file(file, target) filename = File.basename(file) FileUtils.mkdir_p target message "Extracting #{filename} into #{target}... " execute('extract', [tar_exe, "#{tar_compression_switch(filename)}xf", file, "-C", target], {:cd => Dir.pwd, :initial_message => false}) end # command could be an array of args, or one string containing a command passed to the shell. See # Process.spawn for more information. def execute(action, command, command_opts={}) opt_message = command_opts.fetch(:initial_message, true) opt_debug = command_opts.fetch(:debug, false) opt_cd = command_opts.fetch(:cd) { work_path } opt_env = command_opts.fetch(:env) { Hash.new } log_out = log_file(action) Dir.chdir(opt_cd) do output "DEBUG: env is #{opt_env.inspect}" if opt_debug output "DEBUG: command is #{command.inspect}" if opt_debug message "Running '#{action}' for #{@name} #{@version}... " if opt_message if Process.respond_to?(:spawn) && ! RbConfig.respond_to?(:java) options = {[:out, :err]=>[log_out, "a"]} output "DEBUG: options are #{options.inspect}" if opt_debug args = [opt_env, command, options].flatten pid = spawn(*args) Process.wait(pid) else env_args = opt_env.map { |k,v| "#{k}=#{v}".shellescape }.join(" ") c = if command.kind_of?(Array) command.map(&:shellescape).join(" ") else command end redirected = %Q{env #{env_args} #{c} > #{log_out.shellescape} 2>&1} output "DEBUG: final command is #{redirected.inspect}" if opt_debug system redirected end if $?.success? output "OK" return true else if File.exist? log_out output "ERROR, review '#{log_out}' to see what happened. Last lines are:" output("=" * 72) log_lines = File.readlines(log_out) output(log_lines[-[log_lines.length, 20].min .. -1]) output("=" * 72) end raise "Failed to complete #{action} task" end end end def newer?(target, checkpoint) if (target && File.exist?(target)) && (checkpoint && File.exist?(checkpoint)) File.mtime(target) > File.mtime(checkpoint) else false end end # print out a message with the logger def message(text) @logger.print text @logger.flush end # print out a message using the logger but return to a new line def output(text = "") @logger.puts text @logger.flush end # Slighly modified from RubyInstaller uri_ext, Rubinius configure # and adaptations of Wayne's RailsInstaller def download_file(url, full_path, count = 3) return if File.exist?(full_path) uri = URI.parse(url) case uri.scheme.downcase when /ftp/ download_file_ftp(uri, full_path) when /http|https/ download_file_http(url, full_path, count) when /file/ download_file_file(uri, full_path) else raise ArgumentError.new("Unsupported protocol for #{url}") end rescue Exception => e File.unlink full_path if File.exist?(full_path) raise e end def download_file_http(url, full_path, count = 3) filename = File.basename(full_path) with_tempfile(filename, full_path) do |temp_file| total = 0 params = { "Accept-Encoding" => 'identity', :content_length_proc => lambda{|length| total = length }, :progress_proc => lambda{|bytes| if total new_progress = (bytes * 100) / total message "\rDownloading %s (%3d%%) " % [filename, new_progress] else # Content-Length is unavailable because Transfer-Encoding is chunked message "\rDownloading %s " % [filename] end } } proxy_uri = URI.parse(url).scheme.downcase == 'https' ? ENV["https_proxy"] : ENV["http_proxy"] if proxy_uri _, userinfo, _p_host, _p_port = URI.split(proxy_uri) if userinfo proxy_user, proxy_pass = userinfo.split(/:/).map{|s| CGI.unescape(s) } params[:proxy_http_basic_authentication] = [proxy_uri, proxy_user, proxy_pass] end end begin OpenURI.open_uri(url, 'rb', params) do |io| temp_file << io.read end output rescue OpenURI::HTTPRedirect => redirect raise "Too many redirections for the original URL, halting." if count <= 0 count = count - 1 return download_file(redirect.url, full_path, count-1) rescue => e count = count - 1 puts "#{count} retrie(s) left for #{filename}" if count > 0 sleep 1 return download_file_http(url, full_path, count) end output e.message return false end end end def download_file_file(uri, full_path) FileUtils.mkdir_p File.dirname(full_path) FileUtils.cp uri.path, full_path end def download_file_ftp(uri, full_path) require "net/ftp" filename = File.basename(uri.path) with_tempfile(filename, full_path) do |temp_file| total = 0 params = { :content_length_proc => lambda{|length| total = length }, :progress_proc => lambda{|bytes| new_progress = (bytes * 100) / total message "\rDownloading %s (%3d%%) " % [filename, new_progress] } } if ENV["ftp_proxy"] _, userinfo, _p_host, _p_port = URI.split(ENV['ftp_proxy']) if userinfo proxy_user, proxy_pass = userinfo.split(/:/).map{|s| CGI.unescape(s) } params[:proxy_http_basic_authentication] = [ENV['ftp_proxy'], proxy_user, proxy_pass] end end OpenURI.open_uri(uri, 'rb', params) do |io| temp_file << io.read end output end rescue LoadError raise LoadError, "Ruby #{RUBY_VERSION} does not provide the net-ftp gem, please add it as a dependency if you need to use FTP" rescue Net::FTPError return false end def with_tempfile(filename, full_path) temp_file = Tempfile.new("download-#{filename}") temp_file.binmode yield temp_file temp_file.close File.unlink full_path if File.exist?(full_path) FileUtils.mkdir_p File.dirname(full_path) FileUtils.mv temp_file.path, full_path, :force => true end def gcc_cmd cc = ENV["CC"] || RbConfig::CONFIG["CC"] || "gcc" return cc.dup end def make_cmd m = ENV['MAKE'] || ENV['make'] || 'make' return m.dup end end