diff options
Diffstat (limited to 'lib/bundler/source')
| -rw-r--r-- | lib/bundler/source/gemspec.rb | 5 | ||||
| -rw-r--r-- | lib/bundler/source/git.rb | 288 | ||||
| -rw-r--r-- | lib/bundler/source/git/git_proxy.rb | 473 | ||||
| -rw-r--r-- | lib/bundler/source/metadata.rb | 40 | ||||
| -rw-r--r-- | lib/bundler/source/path.rb | 68 | ||||
| -rw-r--r-- | lib/bundler/source/path/installer.rb | 43 | ||||
| -rw-r--r-- | lib/bundler/source/rubygems.rb | 542 | ||||
| -rw-r--r-- | lib/bundler/source/rubygems/remote.rb | 18 | ||||
| -rw-r--r-- | lib/bundler/source/rubygems_aggregate.rb | 71 |
9 files changed, 990 insertions, 558 deletions
diff --git a/lib/bundler/source/gemspec.rb b/lib/bundler/source/gemspec.rb index 7e3447e776..ed766dbe74 100644 --- a/lib/bundler/source/gemspec.rb +++ b/lib/bundler/source/gemspec.rb @@ -4,14 +4,15 @@ module Bundler class Source class Gemspec < Path attr_reader :gemspec + attr_writer :checksum_store def initialize(options) super @gemspec = options["gemspec"] end - def as_path_source - Path.new(options) + def to_s + "gemspec at `#{@path}`" end end end diff --git a/lib/bundler/source/git.rb b/lib/bundler/source/git.rb index 7c1533ad90..a002a2570a 100644 --- a/lib/bundler/source/git.rb +++ b/lib/bundler/source/git.rb @@ -11,6 +11,7 @@ module Bundler def initialize(options) @options = options + @checksum_store = Checksum::Store.new @glob = options["glob"] || DEFAULT_GLOB @allow_cached = false @@ -19,10 +20,10 @@ module Bundler # Stringify options that could be set as symbols %w[ref branch tag revision].each {|k| options[k] = options[k].to_s if options[k] } - @uri = options["uri"] || "" + @uri = URINormalizer.normalize_suffix(options["uri"] || "", trailing_slash: false) @safe_uri = URICredentialsFilter.credential_filtered_uri(@uri) @branch = options["branch"] - @ref = options["ref"] || options["branch"] || options["tag"] || "master" + @ref = options["ref"] || options["branch"] || options["tag"] @submodules = options["submodules"] @name = options["name"] @version = options["version"].to_s.strip.gsub("-", ".pre.") @@ -31,6 +32,20 @@ module Bundler @local = false end + def remote! + return if @allow_remote + + @local_specs = nil + @allow_remote = true + end + + def cached! + return if @allow_cached + + @local_specs = nil + @allow_cached = true + end + def self.from_lock(options) new(options.merge("uri" => options.delete("remote"))) end @@ -42,43 +57,65 @@ module Bundler %w[ref branch tag submodules].each do |opt| out << " #{opt}: #{options[opt]}\n" if options[opt] end - out << " glob: #{@glob}\n" unless @glob == DEFAULT_GLOB + out << " glob: #{@glob}\n" unless default_glob? out << " specs:\n" end + def to_gemfile + specifiers = %w[ref branch tag submodules glob].map do |opt| + "#{opt}: #{options[opt]}" if options[opt] + end + + uri_with_specifiers(specifiers) + end + def hash - [self.class, uri, ref, branch, name, version, glob, submodules].hash + [self.class, uri, ref, branch, name, glob, submodules].hash end def eql?(other) other.is_a?(Git) && uri == other.uri && ref == other.ref && branch == other.branch && name == other.name && - version == other.version && glob == other.glob && + glob == other.glob && submodules == other.submodules end alias_method :==, :eql? + def include?(other) + other.is_a?(Git) && uri == other.uri && + name == other.name && + glob == other.glob && + submodules == other.submodules + end + def to_s - at = if local? - path - elsif user_ref = options["ref"] - if ref =~ /\A[a-z0-9]{4,}\z/i - shortref_for_display(user_ref) - else - user_ref - end - else - ref + begin + at = humanized_ref || current_branch + + rev = "at #{at}@#{shortref_for_display(revision)}" + rescue GitError + "" end - rev = begin - "@#{shortref_for_display(revision)}" - rescue GitError - nil - end + uri_with_specifiers([rev, glob_for_display]) + end - "#{@safe_uri} (at #{at}#{rev})" + def identifier + uri_with_specifiers([humanized_ref, locked_revision, glob_for_display]) + end + + def uri_with_specifiers(specifiers) + specifiers.compact! + + suffix = + if specifiers.any? + " (#{specifiers.join(", ")})" + else + "" + end + + "#{@safe_uri}#{suffix}" end def name @@ -92,13 +129,7 @@ module Bundler @install_path ||= begin git_scope = "#{base_name}-#{shortref_for_path(revision)}" - path = Bundler.install_path.join(git_scope) - - if !path.exist? && Bundler.requires_sudo? - Bundler.user_bundle_path.join(Bundler.ruby_scope).join(git_scope) - else - path - end + Bundler.install_path.join(git_scope) end end @@ -122,7 +153,7 @@ module Bundler path = Pathname.new(path) path = path.expand_path(Bundler.root) unless path.relative? - unless options["branch"] || Bundler.settings[:disable_local_branch_check] + unless branch || Bundler.settings[:disable_local_branch_check] raise GitError, "Cannot use local override for #{name} at #{path} because " \ ":branch is not specified in Gemfile. Specify a branch or run " \ "`bundle config unset local.#{override_for(original_path)}` to remove the local override" @@ -133,21 +164,22 @@ module Bundler "does not exist. Run `bundle config unset local.#{override_for(original_path)}` to remove the local override" end - set_local!(path) + @local = true + set_paths!(path) # Create a new git proxy without the cached revision # so the Gemfile.lock always picks up the new revision. - @git_proxy = GitProxy.new(path, uri, ref) + @git_proxy = GitProxy.new(path, uri, options) - if git_proxy.branch != options["branch"] && !Bundler.settings[:disable_local_branch_check] + if current_branch != branch && !Bundler.settings[:disable_local_branch_check] raise GitError, "Local override for #{name} at #{path} is using branch " \ - "#{git_proxy.branch} but Gemfile specifies #{options["branch"]}" + "#{current_branch} but Gemfile specifies #{branch}" end - changed = cached_revision && cached_revision != git_proxy.revision + changed = locked_revision && locked_revision != revision - if changed && !@unlocked && !git_proxy.contains?(cached_revision) - raise GitError, "The Gemfile lock is pointing to revision #{shortref_for_display(cached_revision)} " \ + if !Bundler.settings[:disable_local_revision_check] && changed && !@unlocked && !git_proxy.contains?(locked_revision) + raise GitError, "The Gemfile lock is pointing to revision #{shortref_for_display(locked_revision)} " \ "but the current branch in your local override for #{name} does not contain such commit. " \ "Please make sure your branch is up to date." end @@ -156,45 +188,47 @@ module Bundler end def specs(*) - set_local!(app_cache_path) if has_app_cache? && !local? + set_cache_path!(app_cache_path) if use_app_cache? if requires_checkout? && !@copied - fetch - git_proxy.copy_to(install_path, submodules) - serialize_gemspecs_in(install_path) - @copied = true + Plugin.hook(Plugin::Events::GIT_BEFORE_FETCH, self) + begin + fetch unless use_app_cache? + checkout + ensure + Plugin.hook(Plugin::Events::GIT_AFTER_FETCH, self) + end end local_specs end def install(spec, options = {}) + return if Bundler.settings[:no_install] force = options[:force] - print_using_message "Using #{version_message(spec)} from #{self}" + print_using_message "Using #{version_message(spec, options[:previous_spec])} from #{self}" if (requires_checkout? && !@copied) || force - Bundler.ui.debug " * Checking out revision: #{ref}" - git_proxy.copy_to(install_path, submodules) - serialize_gemspecs_in(install_path) - @copied = true + checkout end - generate_bin_options = { :disable_extensions => !Bundler.rubygems.spec_missing_extensions?(spec), :build_args => options[:build_args] } + generate_bin_options = { disable_extensions: !spec.missing_extensions?, build_args: options[:build_args] } generate_bin(spec, generate_bin_options) requires_checkout? ? spec.post_install_message : nil end + def migrate_cache(custom_path = nil, local: false) + if local + cache_to(custom_path, try_migrate: false) + else + cache_to(custom_path, try_migrate: true) + end + end + def cache(spec, custom_path = nil) - app_cache_path = app_cache_path(custom_path) - return unless Bundler.feature_flag.cache_all? - return if path == app_cache_path - cached! - FileUtils.rm_rf(app_cache_path) - git_proxy.checkout if requires_checkout? - git_proxy.copy_to(app_cache_path, @submodules) - serialize_gemspecs_in(app_cache_path) + cache_to(custom_path, try_migrate: false) end def load_spec_files @@ -209,28 +243,85 @@ module Bundler # across different projects, this cache will be shared. # When using local git repos, this is set to the local repo. def cache_path - @cache_path ||= begin - if Bundler.requires_sudo? || Bundler.feature_flag.global_gem_cache? - Bundler.user_cache - else - Bundler.bundle_path.join("cache", "bundler") - end.join("git", git_scope) - end + @cache_path ||= if Bundler.settings[:global_gem_cache] + Bundler.user_cache + else + Bundler.bundle_path.join("cache", "bundler") + end.join("git", git_scope) end def app_cache_dirname - "#{base_name}-#{shortref_for_path(cached_revision || revision)}" + "#{base_name}-#{shortref_for_path(locked_revision || revision)}" end def revision git_proxy.revision end + def current_branch + git_proxy.current_branch + end + def allow_git_ops? @allow_remote || @allow_cached end - private + def local? + @local + end + + private + + def cache_to(custom_path, try_migrate: false) + return unless Bundler.settings[:cache_all] + + app_cache_path = app_cache_path(custom_path) + + migrate = try_migrate ? bare_repo?(app_cache_path) : false + + set_cache_path!(nil) if migrate + + return if cache_path == app_cache_path + + cached! + FileUtils.rm_rf(app_cache_path) + git_proxy.checkout if migrate || requires_checkout? + git_proxy.copy_to(app_cache_path, @submodules) + serialize_gemspecs_in(app_cache_path) + end + + def checkout + Bundler.ui.debug " * Checking out revision: #{ref}" + if use_app_cache? && !bare_repo?(app_cache_path) + SharedHelpers.filesystem_access(install_path.dirname) do |p| + FileUtils.mkdir_p(p) + end + FileUtils.cp_r("#{app_cache_path}/.", install_path) + else + if use_app_cache? && bare_repo?(app_cache_path) + Bundler.ui.warn "Installing from cache in old \"bare repository\" format for compatibility. " \ + "Please run `bundle cache` and commit the updated cache to migrate to the new format and get rid of this warning." + end + + git_proxy.copy_to(install_path, submodules) + end + serialize_gemspecs_in(install_path) + @copied = true + end + + def humanized_ref + if local? + path + elsif user_ref = options["ref"] + if /\A[a-z0-9]{4,}\z/i.match?(ref) + shortref_for_display(user_ref) + else + user_ref + end + elsif ref + ref + end + end def serialize_gemspecs_in(destination) destination = destination.expand_path(Bundler.root) if destination.relative? @@ -240,32 +331,45 @@ module Bundler # The gemspecs we cache should already be evaluated. spec = Bundler.load_gemspec(spec_path) next unless spec - Bundler.rubygems.set_installed_by_version(spec) + spec.installed_by_version = Gem::VERSION Bundler.rubygems.validate(spec) File.open(spec_path, "wb") {|file| file.write(spec.to_ruby) } end end - def set_local!(path) - @local = true - @local_specs = @git_proxy = nil - @cache_path = @install_path = path + def set_paths!(path) + set_cache_path!(path) + set_install_path!(path) + end + + def set_cache_path!(path) + @git_proxy = nil + @cache_path = path + end + + def set_install_path!(path) + @local_specs = nil + @install_path = path end def has_app_cache? - cached_revision && super + locked_revision && super end - def local? - @local + def use_app_cache? + has_app_cache? && !local? end def requires_checkout? - allow_git_ops? && !local? && !cached_revision_checked_out? + allow_git_ops? && !local? && !locked_revision_checked_out? end - def cached_revision_checked_out? - cached_revision && cached_revision == revision && install_path.exist? + def locked_revision_checked_out? + locked_revision && locked_revision == revision && installed? + end + + def installed? + git_proxy.installed_to?(install_path) end def base_name @@ -280,19 +384,29 @@ module Bundler ref[0..11] end + def glob_for_display + default_glob? ? nil : "glob: #{@glob}" + end + + def default_glob? + @glob == DEFAULT_GLOB + end + def uri_hash - if uri =~ %r{^\w+://(\w+@)?} + if %r{^\w+://(\w+@)?}.match?(uri) # Downcase the domain component of the URI # and strip off a trailing slash, if one is present - input = Bundler::URI.parse(uri).normalize.to_s.sub(%r{/$}, "") + input = Gem::URI.parse(uri).normalize.to_s.sub(%r{/$}, "") else # If there is no URI scheme, assume it is an ssh/git URI input = uri end - SharedHelpers.digest(:SHA1).hexdigest(input) + # We use SHA1 here for historical reason and to preserve backward compatibility. + # But a transition to a simpler mangling algorithm would be welcome. + Bundler::Digest.sha1(input) end - def cached_revision + def locked_revision options["revision"] end @@ -301,13 +415,12 @@ module Bundler end def git_proxy - @git_proxy ||= GitProxy.new(cache_path, uri, ref, cached_revision, self) + @git_proxy ||= GitProxy.new(cache_path, uri, options, locked_revision, self) end def fetch git_proxy.checkout rescue GitError => e - raise unless Bundler.feature_flag.allow_offline_install? Bundler.ui.warn "Using cached git data because of network errors:\n#{e}" end @@ -315,9 +428,12 @@ module Bundler def validate_spec(_spec); end def load_gemspec(file) - stub = Gem::StubSpecification.gemspec_stub(file, install_path.parent, install_path.parent) - stub.full_gem_path = Pathname.new(file).dirname.expand_path(root).to_s.tap{|x| x.untaint if RUBY_VERSION < "2.7" } - StubSpecification.from_stub(stub) + dirname = Pathname.new(file).dirname + SharedHelpers.chdir(dirname.to_s) do + stub = Gem::StubSpecification.gemspec_stub(file, install_path.parent, install_path.parent) + stub.full_gem_path = dirname.expand_path(root).to_s + StubSpecification.from_stub(stub) + end end def git_scope @@ -331,6 +447,10 @@ module Bundler def override_for(path) Bundler.settings.local_overrides.key(path) end + + def bare_repo?(path) + File.exist?(path.join("objects")) && File.exist?(path.join("HEAD")) + end end end end diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index 7612eb16c6..72f7dc7710 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "shellwords" - module Bundler class Source class Git @@ -17,8 +15,8 @@ module Bundler class GitNotAllowedError < GitError def initialize(command) msg = String.new - msg << "Bundler is trying to run a `git #{command}` at runtime. You probably need to run `bundle install`. However, " - msg << "this error message could probably be more useful. Please submit a ticket at https://github.com/bundler/bundler/issues " + msg << "Bundler is trying to run `#{command}` at runtime. You probably need to run `bundle install`. However, " + msg << "this error message could probably be more useful. Please submit a ticket at https://github.com/ruby/rubygems/issues/new?labels=Bundler&template=bundler-related-issue.md " msg << "with steps to reproduce as well as the following\n\nCALLER: #{caller.join("\n")}" super msg end @@ -27,21 +25,28 @@ module Bundler class GitCommandError < GitError attr_reader :command - def initialize(command, path = nil, extra_info = nil) + def initialize(command, path, extra_info = nil) @command = command - msg = String.new - msg << "Git error: command `git #{command}` in directory #{SharedHelpers.pwd} has failed." + msg = String.new("Git error: command `#{command}`") + msg << " in directory #{path}" if path + msg << " has failed." msg << "\n#{extra_info}" if extra_info - msg << "\nIf this error persists you could try removing the cache directory '#{path}'" if path && path.exist? super msg end end class MissingGitRevisionError < GitCommandError - def initialize(command, path, ref, repo) + def initialize(command, destination_path, ref, repo) msg = "Revision #{ref} does not exist in the repository #{repo}. Maybe you misspelled it?" - super command, path, msg + super command, destination_path, msg + end + end + + class AmbiguousGitReference < GitError + def initialize(options) + msg = "Specification of branch or ref with tag is ambiguous. You specified #{options.inspect}" + super msg end end @@ -49,72 +54,87 @@ module Bundler # All actions required by the Git source is encapsulated in this # object. class GitProxy - attr_accessor :path, :uri, :ref + attr_accessor :path, :uri, :branch, :tag, :ref, :explicit_ref attr_writer :revision - def initialize(path, uri, ref, revision = nil, git = nil) + def self.version + @version ||= full_version[/((\.?\d+)+).*/, 1] + end + + def self.full_version + @full_version ||= begin + raise GitNotInstalledError.new unless Bundler.git_present? + + require "open3" + out, err, status = Open3.capture3("git", "--version") + + raise GitCommandError.new("--version", SharedHelpers.pwd, err) unless status.success? + Bundler.ui.warn err unless err.empty? + + out.sub(/git version\s*/, "").strip + end + end + + def self.reset + @version = nil + @full_version = nil + end + + def initialize(path, uri, options = {}, revision = nil, git = nil) @path = path @uri = uri - @ref = ref + @tag = options["tag"] + @branch = options["branch"] + @ref = options["ref"] + if @tag + raise AmbiguousGitReference.new(options) if @branch || @ref + @explicit_ref = @tag + else + @explicit_ref = @ref || @branch + end @revision = revision @git = git - raise GitNotInstalledError.new if allow? && !Bundler.git_present? + @commit_ref = nil end def revision - return @revision if @revision - - begin - @revision ||= find_local_revision - rescue GitCommandError => e - raise MissingGitRevisionError.new(e.command, path, ref, URICredentialsFilter.credential_filtered_uri(uri)) - end - - @revision + @revision ||= allowed_with_path { find_local_revision } end - def branch - @branch ||= allowed_in_path do - git("rev-parse --abbrev-ref HEAD").strip + def current_branch + @current_branch ||= with_path do + git_local("rev-parse", "--abbrev-ref", "HEAD", dir: path).strip end end def contains?(commit) - allowed_in_path do - result, status = git_null("branch --contains #{commit}") - status.success? && result =~ /^\* (.*)$/ + allowed_with_path do + result, status = git_null("branch", "--contains", commit, dir: path) + status.success? && result.match?(/^\* (.*)$/) end end def version - git("--version").match(/(git version\s*)?((\.?\d+)+).*/)[2] + self.class.version end def full_version - git("--version").sub("git version", "").strip + self.class.full_version end def checkout - return if path.exist? && has_revision_cached? - extra_ref = "#{Shellwords.shellescape(ref)}:#{Shellwords.shellescape(ref)}" if ref && ref.start_with?("refs/") + return if has_revision_cached? - Bundler.ui.info "Fetching #{URICredentialsFilter.credential_filtered_uri(uri)}" + Bundler.ui.info "Fetching #{credential_filtered_uri}" - unless path.exist? - SharedHelpers.filesystem_access(path.dirname) do |p| - FileUtils.mkdir_p(p) - end - git_retry %(clone #{uri_escaped_with_configured_credentials} "#{path}" --bare --no-hardlinks --quiet) - return unless extra_ref - end + extra_fetch_needed = clone_needs_extra_fetch? + unshallow_needed = clone_needs_unshallow? + return unless extra_fetch_needed || unshallow_needed - in_path do - git_retry %(fetch --force --quiet --tags #{uri_escaped_with_configured_credentials} "refs/heads/*:refs/heads/*" #{extra_ref}) - end + git_remote_fetch(unshallow_needed ? ["--unshallow"] : depth_args) end def copy_to(destination, submodules = false) - # method 1 unless File.exist?(destination.join(".git")) begin SharedHelpers.filesystem_access(destination.dirname) do |p| @@ -123,135 +143,354 @@ module Bundler SharedHelpers.filesystem_access(destination) do |p| FileUtils.rm_rf(p) end - git_retry %(clone --no-checkout --quiet "#{path}" "#{destination}") - File.chmod(((File.stat(destination).mode | 0o777) & ~File.umask), destination) + git "clone", "--no-checkout", "--quiet", path.to_s, destination.to_s + File.chmod((File.stat(destination).mode | 0o777) & ~File.umask, destination) rescue Errno::EEXIST => e - file_path = e.message[%r{.*?(/.*)}, 1] + file_path = e.message[%r{.*?((?:[a-zA-Z]:)?/.*)}, 1] raise GitError, "Bundler could not install a gem because it needs to " \ "create a directory, but a file exists - #{file_path}. Please delete " \ "this file and try again." end end - # method 2 - SharedHelpers.chdir(destination) do - git_retry %(fetch --force --quiet --tags "#{path}") - begin - git "reset --hard #{@revision}" - rescue GitCommandError => e - raise MissingGitRevisionError.new(e.command, path, @revision, URICredentialsFilter.credential_filtered_uri(uri)) + ref = @commit_ref || (locked_to_full_sha? && @revision) + if ref + git "config", "uploadpack.allowAnySHA1InWant", "true", dir: path.to_s if @commit_ref.nil? && needs_allow_any_sha1_in_want? + + git "fetch", "--force", "--quiet", *extra_fetch_args(ref), dir: destination + end + + git "reset", "--hard", revision, dir: destination + + if submodules + git_retry "submodule", "update", "--init", "--recursive", dir: destination + elsif Gem::Version.create(version) >= Gem::Version.create("2.9.0") + inner_command = "git -C $toplevel submodule deinit --force $sm_path" + git_retry "submodule", "foreach", "--quiet", inner_command, dir: destination + end + end + + def installed_to?(destination) + # if copy_to is interrupted, it may leave a partially installed directory that + # contains .git but no other files -- consider this not to be installed + Dir.exist?(destination) && (Dir.children(destination) - [".git"]).any? + end + + private + + def git_remote_fetch(args) + command = fetch_command(args) + command_with_no_credentials = check_allowed(command) + + Bundler::Retry.new("`#{command_with_no_credentials}` at #{path}", [MissingGitRevisionError]).attempts do + out, err, status = capture(command, path) + return out if status.success? + + if err.include?("couldn't find remote ref") || err.include?("not our ref") + raise MissingGitRevisionError.new(command_with_no_credentials, path, commit || explicit_ref, credential_filtered_uri) + else + if shallow? + args -= depth_args + command = fetch_command(args) + command_with_no_credentials = check_allowed(command) + end + raise GitCommandError.new(command_with_no_credentials, path, err) end + end + end - if submodules - git_retry "submodule update --init --recursive" - elsif Gem::Version.create(version) >= Gem::Version.create("2.9.0") - git_retry "submodule deinit --all --force" + def clone_needs_extra_fetch? + return true if path.exist? + + SharedHelpers.filesystem_access(path.dirname) do |p| + FileUtils.mkdir_p(p) + end + + clone_args = extra_clone_args + command = clone_command(clone_args) + command_with_no_credentials = check_allowed(command) + + Bundler::Retry.new("`#{command_with_no_credentials}`", [MissingGitRevisionError]).attempts do + _, err, status = capture(command, nil) + return extra_ref if status.success? + + if err.include?("Could not find remote branch") || # git up to 2.49 + err.include?("Remote branch #{branch_option} not found") # git 2.49 or higher + raise MissingGitRevisionError.new(command_with_no_credentials, nil, explicit_ref, credential_filtered_uri) + else + if shallow? + clone_args -= depth_args + command = clone_command(clone_args) + command_with_no_credentials = check_allowed(command) + end + raise GitCommandError.new(command_with_no_credentials, path, err) end end end - private + def clone_needs_unshallow? + return false unless path.join("shallow").exist? + return true unless shallow? - def git_null(command) - command_with_no_credentials = URICredentialsFilter.credential_filtered_string(command, uri) - raise GitNotAllowedError.new(command_with_no_credentials) unless allow? + @revision && @revision != head_revision + end + + def extra_ref + return false if not_pinned? + return true if shallow? + + ref.start_with?("refs/") + end + + def depth + return @depth if defined?(@depth) + + @depth = if !supports_fetching_unreachable_refs? + nil + elsif not_pinned? || pinned_to_full_sha? + 1 + elsif ref.include?("~") + parsed_depth = ref.split("~").last + parsed_depth.to_i + 1 + end + end + + def refspec + if commit + @commit_ref = "refs/#{commit}-sha" + return "#{commit}:#{@commit_ref}" + end + + reference = fully_qualified_ref - out, status = SharedHelpers.with_clean_git_env do - capture_and_ignore_stderr("git #{command}") + reference ||= if ref.include?("~") + ref.split("~").first + elsif ref.start_with?("refs/") + ref + else + "refs/*" end - [URICredentialsFilter.credential_filtered_string(out, uri), status] + "#{reference}:#{reference}" + end + + def commit + @commit ||= pinned_to_full_sha? ? ref : @revision end - def git_retry(command) - Bundler::Retry.new("`git #{URICredentialsFilter.credential_filtered_string(command, uri)}`", GitNotAllowedError).attempts do - git(command) + def fully_qualified_ref + if branch + "refs/heads/#{branch}" + elsif tag + "refs/tags/#{tag}" + elsif ref.nil? + "refs/heads/#{current_branch}" end end - def git(command, check_errors = true, error_msg = nil) - command_with_no_credentials = URICredentialsFilter.credential_filtered_string(command, uri) - raise GitNotAllowedError.new(command_with_no_credentials) unless allow? + def not_pinned? + branch_option || ref.nil? + end + + def pinned_to_full_sha? + full_sha_revision?(ref) + end + + def locked_to_full_sha? + full_sha_revision?(@revision) + end + + def full_sha_revision?(ref) + ref&.match?(/\A\h{40}\z/) + end + + def git_null(*command, dir: nil) + check_allowed(command) + + capture(command, dir, ignore_err: true) + end + + def git_retry(*command, dir: nil) + command_with_no_credentials = check_allowed(command) + + Bundler::Retry.new("`#{command_with_no_credentials}` at #{dir || SharedHelpers.pwd}").attempts do + git(*command, dir: dir) + end + end - out, status = SharedHelpers.with_clean_git_env do - capture_and_filter_stderr(uri, "git #{command}") + def git(*command, dir: nil) + run_command(*command, dir: dir) do |unredacted_command| + check_allowed(unredacted_command) end + end - stdout_with_no_credentials = URICredentialsFilter.credential_filtered_string(out, uri) - raise GitCommandError.new(command_with_no_credentials, path, error_msg) if check_errors && !status.success? - stdout_with_no_credentials + def git_local(*command, dir: nil) + run_command(*command, dir: dir) do |unredacted_command| + redact_and_check_presence(unredacted_command) + end end def has_revision_cached? - return unless @revision - in_path { git("cat-file -e #{@revision}") } + return unless commit && path.exist? + git("cat-file", "-e", commit, dir: path) true rescue GitError false end - def remove_cache - FileUtils.rm_rf(path) + def find_local_revision + return head_revision if explicit_ref.nil? + + find_revision_for(explicit_ref) end - def find_local_revision - allowed_in_path do - git("rev-parse --verify #{Shellwords.shellescape(ref)}", true).strip - end + def head_revision + verify("HEAD") end - # Escape the URI for git commands - def uri_escaped_with_configured_credentials - remote = configured_uri_for(uri) - if Bundler::WINDOWS - # Windows quoting requires double quotes only, with double quotes - # inside the string escaped by being doubled. - '"' + remote.gsub('"') { '""' } + '"' - else - # Bash requires single quoted strings, with the single quotes escaped - # by ending the string, escaping the quote, and restarting the string. - "'" + remote.gsub("'") { "'\\''" } + "'" - end + def find_revision_for(reference) + verify(reference) + rescue GitCommandError => e + raise MissingGitRevisionError.new(e.command, path, reference, credential_filtered_uri) + end + + def verify(reference) + git("rev-parse", "--verify", reference, dir: path).strip end - # Adds credentials to the URI as Fetcher#configured_uri_for does - def configured_uri_for(uri) - if /https?:/ =~ uri - remote = Bundler::URI(uri) + # Adds credentials to the URI + def configured_uri + if /https?:/.match?(uri) + remote = Gem::URI(uri) config_auth = Bundler.settings[remote.to_s] || Bundler.settings[remote.host] remote.userinfo ||= config_auth remote.to_s else - uri + uri.to_s end end + # Removes credentials from the URI + def credential_filtered_uri + URICredentialsFilter.credential_filtered_uri(uri) + end + def allow? - @git ? @git.allow_git_ops? : true + allowed = @git ? @git.allow_git_ops? : true + + raise GitNotInstalledError.new if allowed && !Bundler.git_present? + + allowed end - def in_path(&blk) + def with_path(&blk) checkout unless path.exist? - _ = URICredentialsFilter # load it before we chdir - SharedHelpers.chdir(path, &blk) + blk.call end - def allowed_in_path - return in_path { yield } if allow? + def allowed_with_path + return with_path { yield } if allow? raise GitError, "The git source #{uri} is not yet checked out. Please run `bundle install` before trying to start your application" end - def capture_and_filter_stderr(uri, cmd) - require "open3" - return_value, captured_err, status = Open3.capture3(cmd) - Bundler.ui.warn URICredentialsFilter.credential_filtered_string(captured_err, uri) if uri && !captured_err.empty? - [return_value, status] + def check_allowed(command) + command_with_no_credentials = redact_and_check_presence(command) + raise GitNotAllowedError.new(command_with_no_credentials) unless allow? + command_with_no_credentials + end + + def redact_and_check_presence(command) + raise GitNotInstalledError.new unless Bundler.git_present? + + require "shellwords" + URICredentialsFilter.credential_filtered_string("git #{command.shelljoin}", uri) + end + + def run_command(*command, dir: nil) + command_with_no_credentials = yield(command) + + out, err, status = capture(command, dir) + + raise GitCommandError.new(command_with_no_credentials, dir || SharedHelpers.pwd, err) unless status.success? + + Bundler.ui.warn err unless err.empty? + + out + end + + def capture(cmd, dir, ignore_err: false) + SharedHelpers.with_clean_git_env do + require "open3" + out, err, status = Open3.capture3(*capture3_args_for(cmd, dir)) + + filtered_out = URICredentialsFilter.credential_filtered_string(out, uri) + return [filtered_out, status] if ignore_err + + filtered_err = URICredentialsFilter.credential_filtered_string(err, uri) + [filtered_out, filtered_err, status] + end + end + + def capture3_args_for(cmd, dir) + return ["git", *cmd] unless dir + + ["git", "-C", dir.to_s, *cmd] + end + + def extra_clone_args + args = depth_args + return [] if args.empty? + + args += ["--single-branch"] + args.unshift("--no-tags") if supports_cloning_with_no_tags? + + # If there's a locked revision, no need to clone any specific branch + # or tag, since we will end up checking out that locked revision + # anyways. + return args if @revision + + args += ["--branch", branch_option] if branch_option + args + end + + def fetch_command(args) + ["fetch", "--force", "--quiet", "--no-tags", *args, "--", configured_uri, refspec].compact + end + + def clone_command(args) + ["clone", "--bare", "--no-hardlinks", "--quiet", *args, "--", configured_uri, path.to_s] + end + + def depth_args + return [] unless shallow? + + ["--depth", depth.to_s] + end + + def extra_fetch_args(ref) + extra_args = [path.to_s, *depth_args] + extra_args.push(ref) + extra_args + end + + def branch_option + branch || tag + end + + def shallow? + !depth.nil? + end + + def needs_allow_any_sha1_in_want? + @needs_allow_any_sha1_in_want ||= Gem::Version.new(version) <= Gem::Version.new("2.13.7") + end + + def supports_fetching_unreachable_refs? + @supports_fetching_unreachable_refs ||= Gem::Version.new(version) >= Gem::Version.new("2.5.0") end - def capture_and_ignore_stderr(cmd) - require "open3" - return_value, _, status = Open3.capture3(cmd) - [return_value, status] + def supports_cloning_with_no_tags? + @supports_cloning_with_no_tags ||= Gem::Version.new(version) >= Gem::Version.new("2.14.0-rc0") end end end diff --git a/lib/bundler/source/metadata.rb b/lib/bundler/source/metadata.rb index 0867879861..ecf8895187 100644 --- a/lib/bundler/source/metadata.rb +++ b/lib/bundler/source/metadata.rb @@ -5,38 +5,34 @@ module Bundler class Metadata < Source def specs @specs ||= Index.build do |idx| - idx << Gem::Specification.new("Ruby\0", RubyVersion.system.to_gem_version_with_patchlevel) + idx << Gem::Specification.new("Ruby\0", Bundler::RubyVersion.system.gem_version) idx << Gem::Specification.new("RubyGems\0", Gem::VERSION) do |s| s.required_rubygems_version = Gem::Requirement.default end - idx << Gem::Specification.new do |s| - s.name = "bundler" - s.version = VERSION - s.license = "MIT" - s.platform = Gem::Platform::RUBY - s.source = self - s.authors = ["bundler team"] - s.bindir = "exe" - s.homepage = "https://bundler.io" - s.summary = "The best way to manage your application's dependencies" - s.executables = %w[bundle] - # can't point to the actual gemspec or else the require paths will be wrong - s.loaded_from = File.expand_path("..", __FILE__) - end + if local_spec = Gem.loaded_specs["bundler"] + raise CorruptBundlerInstallError.new(local_spec) if local_spec.version.to_s != Bundler::VERSION - if local_spec = Bundler.rubygems.find_name("bundler").find {|s| s.version.to_s == VERSION } idx << local_spec + else + idx << Gem::Specification.new do |s| + s.name = "bundler" + s.version = VERSION + s.license = "MIT" + s.platform = Gem::Platform::RUBY + s.authors = ["bundler team"] + s.bindir = "exe" + s.homepage = "https://bundler.io" + s.summary = "The best way to manage your application's dependencies" + s.executables = %w[bundle bundler] + s.loaded_from = SharedHelpers.gemspec_path + end end idx.each {|s| s.source = self } end end - def cached!; end - - def remote!; end - def options {} end @@ -62,6 +58,10 @@ module Bundler def version_message(spec) "#{spec.name} #{spec.version}" end + + def checksum_store + @checksum_store ||= Checksum::Store.new + end end end end diff --git a/lib/bundler/source/path.rb b/lib/bundler/source/path.rb index f98f5155fb..366a23aea7 100644 --- a/lib/bundler/source/path.rb +++ b/lib/bundler/source/path.rb @@ -11,22 +11,20 @@ module Bundler protected :original_path - DEFAULT_GLOB = "{,*,*/*}.gemspec".freeze + DEFAULT_GLOB = "{,*,*/*}.gemspec" def initialize(options) + @checksum_store = Checksum::Store.new @options = options.dup @glob = options["glob"] || DEFAULT_GLOB - @allow_cached = false - @allow_remote = false - @root_path = options["root_path"] || root if options["path"] @path = Pathname.new(options["path"]) expanded_path = expand(@path) @path = if @path.relative? - expanded_path.relative_path_from(root_path.expand_path) + expanded_path.relative_path_from(File.expand_path(root_path)) else expanded_path end @@ -40,16 +38,6 @@ module Bundler @original_path = @path end - def remote! - @local_specs = nil - @allow_remote = true - end - - def cached! - @local_specs = nil - @allow_cached = true - end - def self.from_lock(options) new(options.merge("path" => options.delete("remote"))) end @@ -65,13 +53,17 @@ module Bundler "source at `#{@path}`" end + alias_method :identifier, :to_s + + alias_method :to_gemfile, :path + def hash [self.class, expanded_path, version].hash end def eql?(other) - return unless other.class == self.class - expanded_original_path == other.expanded_original_path && + [Gemspec, Path].include?(other.class) && + expanded_original_path == other.expanded_original_path && version == other.version end @@ -82,14 +74,16 @@ module Bundler end def install(spec, options = {}) - print_using_message "Using #{version_message(spec)} from #{self}" - generate_bin(spec, :disable_extensions => true) + using_message = "Using #{version_message(spec, options[:previous_spec])} from #{self}" + using_message += " and installing its executables" unless spec.executables.empty? + print_using_message using_message + generate_bin(spec, disable_extensions: true) nil # no post-install message end def cache(spec, custom_path = nil) app_cache_path = app_cache_path(custom_path) - return unless Bundler.feature_flag.cache_all? + return unless Bundler.settings[:cache_all] return if expand(@original_path).to_s.index(root_path.to_s + "/") == 0 unless @original_path.exist? @@ -125,7 +119,7 @@ module Bundler @expanded_original_path ||= expand(original_path) end - private + private def expanded_path @expanded_path ||= expand(path) @@ -154,7 +148,7 @@ module Bundler def load_gemspec(file) return unless spec = Bundler.load_gemspec(file) - Bundler.rubygems.set_installed_by_version(spec) + spec.installed_by_version = Gem::VERSION spec end @@ -167,10 +161,17 @@ module Bundler if File.directory?(expanded_path) # We sort depth-first since `<<` will override the earlier-found specs - Dir["#{expanded_path}/#{@glob}"].sort_by {|p| -p.split(File::SEPARATOR).size }.each do |file| + Gem::Util.glob_files_in_dir(@glob, expanded_path).sort_by {|p| -p.split(File::SEPARATOR).size }.each do |file| next unless spec = load_gemspec(file) spec.source = self + # The ignore attribute is for ignoring installed gems that don't + # have extensions correctly compiled for activation. In the case of + # path sources, there's a single version of each gem in the path + # source available to Bundler, so we always certainly want to + # consider that for activation and never makes sense to ignore it. + spec.ignored = false + # Validation causes extension_dir to be calculated, which depends # on #source, so we validate here instead of load_gemspec validate_spec(spec) @@ -218,22 +219,23 @@ module Bundler # Some gem authors put absolute paths in their gemspec # and we have to save them from themselves - spec.files = spec.files.map do |p| - next p unless p =~ /\A#{Pathname::SEPARATOR_PAT}/ - next if File.directory?(p) + spec.files = spec.files.filter_map do |path| + pathname = Pathname.new(path) + next path unless pathname.absolute? + next if File.directory?(path) begin - Pathname.new(p).relative_path_from(gem_dir).to_s + pathname.relative_path_from(gem_dir).to_s rescue ArgumentError - p + path end - end.compact + end installer = Path::Installer.new( spec, - :env_shebang => false, - :disable_extensions => options[:disable_extensions], - :build_args => options[:build_args], - :bundler_extension_cache_path => extension_cache_path(spec) + env_shebang: false, + disable_extensions: options[:disable_extensions], + build_args: options[:build_args], + bundler_extension_cache_path: extension_cache_path(spec) ) installer.post_install rescue Gem::InvalidSpecificationException => e diff --git a/lib/bundler/source/path/installer.rb b/lib/bundler/source/path/installer.rb index a0357ffa39..39765e5da2 100644 --- a/lib/bundler/source/path/installer.rb +++ b/lib/bundler/source/path/installer.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "../../rubygems_gem_installer" + module Bundler class Source class Path @@ -16,47 +18,24 @@ module Bundler @build_args = options[:build_args] || Bundler.rubygems.build_args @gem_bin_dir = "#{Bundler.rubygems.gem_dir}/bin" @disable_extensions = options[:disable_extensions] - - if Bundler.requires_sudo? - @tmp_dir = Bundler.tmp(spec.full_name).to_s - @bin_dir = "#{@tmp_dir}/bin" - else - @bin_dir = @gem_bin_dir - end + @bin_dir = @gem_bin_dir end def post_install - SharedHelpers.chdir(@gem_dir) do - run_hooks(:pre_install) - - unless @disable_extensions - build_extensions - run_hooks(:post_build) - end - - generate_bin unless spec.executables.nil? || spec.executables.empty? + run_hooks(:pre_install) - run_hooks(:post_install) + unless @disable_extensions || Bundler.settings[:no_build_extension] + build_extensions + run_hooks(:post_build) end - ensure - Bundler.rm_rf(@tmp_dir) if Bundler.requires_sudo? - end - - private - def generate_bin - super + generate_bin unless spec.executables.empty? - if Bundler.requires_sudo? - SharedHelpers.filesystem_access(@gem_bin_dir) do |p| - Bundler.mkdir_p(p) - end - spec.executables.each do |exe| - Bundler.sudo "cp -R #{@bin_dir}/#{exe} #{@gem_bin_dir}" - end - end + run_hooks(:post_install) end + private + def run_hooks(type) hooks_meth = "#{type}_hooks" return unless Gem.respond_to?(hooks_meth) diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb index 6c0de204e7..d610ce3fdf 100644 --- a/lib/bundler/source/rubygems.rb +++ b/lib/bundler/source/rubygems.rb @@ -7,12 +7,11 @@ module Bundler class Rubygems < Source autoload :Remote, File.expand_path("rubygems/remote", __dir__) - # Use the API when installing less than X gems - API_REQUEST_LIMIT = 500 # Ask for X gems per API request - API_REQUEST_SIZE = 50 + API_REQUEST_SIZE = 100 + REQUIRE_MUTEX = Mutex.new - attr_reader :remotes, :caches + attr_accessor :remotes def initialize(options = {}) @options = options @@ -20,17 +19,55 @@ module Bundler @dependency_names = [] @allow_remote = false @allow_cached = false - @caches = [cache_path, *Bundler.rubygems.gem_cache] + @allow_local = options["allow_local"] || false + @prefer_local = false + @checksum_store = Checksum::Store.new + @gem_installers = {} + @gem_installers_mutex = Mutex.new - Array(options["remotes"] || []).reverse_each {|r| add_remote(r) } + Array(options["remotes"]).reverse_each {|r| add_remote(r) } + + @lockfile_remotes = @remotes if options["from_lockfile"] + end + + def caches + @caches ||= [cache_path, *Bundler.rubygems.gem_cache] + end + + def prefer_local! + @prefer_local = true + end + + def local_only! + @specs = nil + @allow_local = true + @allow_cached = false + @allow_remote = false + end + + def local_only? + @allow_local && !@allow_remote + end + + def local! + return if @allow_local + + @specs = nil + @allow_local = true end def remote! + return if @allow_remote + @specs = nil @allow_remote = true end def cached! + return unless File.exist?(cache_path) + + return if @allow_cached + @specs = nil @allow_cached = true end @@ -49,9 +86,17 @@ module Bundler o.is_a?(Rubygems) && (o.credless_remotes - credless_remotes).empty? end + def multiple_remotes? + @remotes.size > 1 + end + + def no_remotes? + @remotes.size == 0 + end + def can_lock?(spec) - return super if Bundler.feature_flag.disable_multisource? - spec.source.is_a?(Rubygems) + return super unless multiple_remotes? + include?(spec.source) end def options @@ -59,13 +104,14 @@ module Bundler end def self.from_lock(options) - new(options) + options["remotes"] = Array(options.delete("remote")).reverse + new(options.merge("from_lockfile" => true)) end def to_lock out = String.new("GEM\n") - remotes.reverse_each do |remote| - out << " remote: #{suppress_configured_credentials remote}\n" + lockfile_remotes.reverse_each do |remote| + out << " remote: #{remote}\n" end out << " specs:\n" end @@ -73,134 +119,108 @@ module Bundler def to_s if remotes.empty? "locally installed gems" - else - remote_names = remotes.map(&:to_s).join(", ") + elsif @allow_remote && @allow_cached && @allow_local + "rubygems repository #{remote_names}, cached gems or installed locally" + elsif @allow_remote && @allow_local "rubygems repository #{remote_names} or installed locally" + elsif @allow_remote + "rubygems repository #{remote_names}" + elsif @allow_cached && @allow_local + "cached gems or installed locally" + else + "locally installed gems" + end + end + + def identifier + if remotes.empty? + "locally installed gems" + else + "rubygems repository #{remote_names}" end end - alias_method :name, :to_s + alias_method :name, :identifier + alias_method :to_gemfile, :identifier def specs @specs ||= begin # remote_specs usually generates a way larger Index than the other - # sources, and large_idx.use small_idx is way faster than - # small_idx.use large_idx. - idx = @allow_remote ? remote_specs.dup : Index.new - idx.use(cached_specs, :override_dupes) if @allow_cached || @allow_remote - idx.use(installed_specs, :override_dupes) - idx + # sources, and large_idx.merge! small_idx is way faster than + # small_idx.merge! large_idx. + index = @allow_remote ? remote_specs.dup : Index.new + index.merge!(cached_specs) if @allow_cached + index.merge!(installed_specs) if @allow_local + + if @allow_local + if @prefer_local + index.merge!(default_specs) + else + # complete with default specs, only if not already available in the + # index through remote, cached, or installed specs + index.use(default_specs) + end + end + + index end end - def install(spec, opts = {}) - force = opts[:force] - ensure_builtin_gems_cached = opts[:ensure_builtin_gems_cached] - - if ensure_builtin_gems_cached && builtin_gem?(spec) - if !cached_path(spec) - cached_built_in_gem(spec) unless spec.remote - force = true - else - spec.loaded_from = loaded_from(spec) - end + def download(spec, options = {}) + if (spec.default_gem? && !cached_built_in_gem(spec, local: options[:local])) || (installed?(spec) && !options[:force]) + return true end - if (installed?(spec) || Plugin.installed?(spec.name)) && !force - print_using_message "Using #{version_message(spec)}" - return nil # no post-install message - end + installer = rubygems_gem_installer(spec, options) - # Download the gem to get the spec, because some specs that are returned - # by rubygems.org are broken and wrong. if spec.remote - # Check for this spec from other sources - uris = [spec.remote.anonymized_uri] - uris += remotes_for_spec(spec).map(&:anonymized_uri) - uris.uniq! - Installer.ambiguous_gems << [spec.name, *uris] if uris.length > 1 - - path = fetch_gem(spec) - begin - s = Bundler.rubygems.spec_from_gem(path, Bundler.settings["trust-policy"]) - spec.__swap__(s) - rescue StandardError - Bundler.rm_rf(path) + s = begin + installer.spec + rescue Gem::Package::FormatError + Bundler.rm_rf(installer.gem) raise + rescue Gem::Security::Exception => e + raise SecurityError, + "The gem #{installer.gem} can't be installed because " \ + "the security policy didn't allow it, with the message: #{e.message}" end + + spec.__swap__(s) end - unless Bundler.settings[:no_install] - message = "Installing #{version_message(spec)}" - message += " with native extensions" if spec.extensions.any? - Bundler.ui.confirm message + spec + end - path = cached_gem(spec) - if requires_sudo? - install_path = Bundler.tmp(spec.full_name) - bin_path = install_path.join("bin") - else - install_path = rubygems_dir - bin_path = Bundler.system_bindir - end + def install(spec, options = {}) + if (spec.default_gem? && !cached_built_in_gem(spec, local: options[:local])) || (installed?(spec) && !options[:force]) + print_using_message "Using #{version_message(spec, options[:previous_spec])}" + return nil # no post-install message + end - Bundler.mkdir_p bin_path, :no_sudo => true unless spec.executables.empty? || Bundler.rubygems.provides?(">= 2.7.5") - - installed_spec = nil - Bundler.rubygems.preserve_paths do - installed_spec = Bundler::RubyGemsGemInstaller.at( - path, - :install_dir => install_path.to_s, - :bin_dir => bin_path.to_s, - :ignore_dependencies => true, - :wrappers => true, - :env_shebang => true, - :build_args => opts[:build_args], - :bundler_expected_checksum => spec.respond_to?(:checksum) && spec.checksum, - :bundler_extension_cache_path => extension_cache_path(spec) - ).install - end - spec.full_gem_path = installed_spec.full_gem_path - - # SUDO HAX - if requires_sudo? - Bundler.rubygems.repository_subdirectories.each do |name| - src = File.join(install_path, name, "*") - dst = File.join(rubygems_dir, name) - if name == "extensions" && Dir.glob(src).any? - src = File.join(src, "*/*") - ext_src = Dir.glob(src).first - ext_src.gsub!(src[0..-6], "") - dst = File.dirname(File.join(dst, ext_src)) - end - SharedHelpers.filesystem_access(dst) do |p| - Bundler.mkdir_p(p) - end - Bundler.sudo "cp -R #{src} #{dst}" if Dir[src].any? - end + return if Bundler.settings[:no_install] - spec.executables.each do |exe| - SharedHelpers.filesystem_access(Bundler.system_bindir) do |p| - Bundler.mkdir_p(p) - end - Bundler.sudo "cp -R #{install_path}/bin/#{exe} #{Bundler.system_bindir}/" - end - end - installed_spec.loaded_from = loaded_from(spec) + installer = rubygems_gem_installer(spec, options) + spec.source.checksum_store.register(spec, installer.gem_checksum) + + message = "Installing #{version_message(spec, options[:previous_spec])}" + message += " with native extensions" if spec.extensions.any? + Bundler.ui.confirm message + + installed_spec = nil + + Gem.time("Installed #{spec.name} in", 0, true) do + installed_spec = installer.install end - spec.loaded_from = loaded_from(spec) + + spec.full_gem_path = installed_spec.full_gem_path + spec.loaded_from = installed_spec.loaded_from + spec.base_dir = installed_spec.base_dir spec.post_install_message - ensure - Bundler.rm_rf(install_path) if requires_sudo? end def cache(spec, custom_path = nil) - if builtin_gem?(spec) - cached_path = cached_built_in_gem(spec) - else - cached_path = cached_gem(spec) - end - raise GemNotFound, "Missing gem file '#{spec.full_name}.gem'." unless cached_path + cached_path = Bundler.settings[:cache_all_platforms] ? fetch_gem_if_possible(spec) : cached_gem(spec) + raise GemNotFound, "Missing gem file '#{spec.file_name}'." unless cached_path return if File.dirname(cached_path) == Bundler.app_cache.to_s Bundler.ui.info " * #{File.basename(cached_path)}" FileUtils.cp(cached_path, Bundler.app_cache(custom_path)) @@ -209,12 +229,13 @@ module Bundler raise InstallError, e.message end - def cached_built_in_gem(spec) - cached_path = cached_path(spec) - if cached_path.nil? + def cached_built_in_gem(spec, local: false) + cached_path = cached_gem(spec) + if cached_path.nil? && !local remote_spec = remote_specs.search(spec).first if remote_spec cached_path = fetch_gem(remote_spec) + spec.remote = remote_spec.remote else Bundler.ui.warn "#{spec.full_name} is built in to Ruby, and can't be cached because your Gemfile doesn't have any sources that contain it." end @@ -227,41 +248,35 @@ module Bundler @remotes.unshift(uri) unless @remotes.include?(uri) end - def equivalent_remotes?(other_remotes) - other_remotes.map(&method(:remove_auth)) == @remotes.map(&method(:remove_auth)) - end - - def replace_remotes(other_remotes, allow_equivalent = false) - return false if other_remotes == @remotes - - equivalent = allow_equivalent && equivalent_remotes?(other_remotes) - - @remotes = [] - other_remotes.reverse_each do |r| - add_remote r.to_s + def spec_names + if dependency_api_available? + remote_specs.spec_names + else + [] end - - !equivalent end def unmet_deps - if @allow_remote && api_fetchers.any? + if dependency_api_available? remote_specs.unmet_dependency_names else [] end end - def fetchers - @fetchers ||= remotes.map do |uri| + def remote_fetchers + @remote_fetchers ||= remotes.to_h do |uri| remote = Source::Rubygems::Remote.new(uri) - Bundler::Fetcher.new(remote) - end + [remote, Bundler::Fetcher.new(remote)] + end.freeze + end + + def fetchers + @fetchers ||= remote_fetchers.values.freeze end def double_check_for(unmet_dependency_names) - return unless @allow_remote - return unless api_fetchers.any? + return unless dependency_api_available? unmet_dependency_names = unmet_dependency_names.call unless unmet_dependency_names.nil? @@ -275,7 +290,9 @@ module Bundler Bundler.ui.debug "Double checking for #{unmet_dependency_names || "all specs (due to the size of the request)"} in #{self}" - fetch_names(api_fetchers, unmet_dependency_names, specs, false) + fetch_names(api_fetchers, unmet_dependency_names, remote_specs) + + specs.use remote_specs end def dependency_names_to_double_check @@ -283,66 +300,58 @@ module Bundler remote_specs.each do |spec| case spec when EndpointSpecification, Gem::Specification, StubSpecification, LazySpecification - names.concat(spec.runtime_dependencies) + names.concat(spec.runtime_dependencies.map(&:name)) when RemoteSpecification # from the full index return nil else raise "unhandled spec type (#{spec.inspect})" end end - names.map!(&:name) if names names end - protected + def dependency_api_available? + @allow_remote && api_fetchers.any? + end - def credless_remotes - remotes.map(&method(:suppress_configured_credentials)) + def clear_cache + @specs = nil + @installed_specs = nil + @default_specs = nil + @cached_specs = nil end - def remotes_for_spec(spec) - specs.search_all(spec.name).inject([]) do |uris, s| - uris << s.remote if s.remote - uris - end + protected + + def remote_names + remotes.map(&:to_s).join(", ") end - def loaded_from(spec) - "#{rubygems_dir}/specifications/#{spec.full_name}.gemspec" + def credless_remotes + remotes.map(&method(:remove_auth)) end def cached_gem(spec) - cached_gem = cached_path(spec) - unless cached_gem - raise Bundler::GemNotFound, "Could not find #{spec.file_name} for installation" - end - cached_gem - end + global_cache_path = download_cache_path(spec) + caches << global_cache_path if global_cache_path - def cached_path(spec) - possibilities = @caches.map {|p| "#{p}/#{spec.file_name}" } + possibilities = caches.map {|p| package_path(p, spec) } possibilities.find {|p| File.exist?(p) } end + def package_path(cache_path, spec) + "#{cache_path}/#{spec.file_name}" + end + def normalize_uri(uri) - uri = uri.to_s - uri = "#{uri}/" unless uri =~ %r{/$} + uri = URINormalizer.normalize_suffix(uri.to_s) require_relative "../vendored_uri" - uri = Bundler::URI(uri) + uri = Gem::URI(uri) raise ArgumentError, "The source must be an absolute URI. For example:\n" \ - "source 'https://rubygems.org'" if !uri.absolute? || (uri.is_a?(Bundler::URI::HTTP) && uri.host.nil?) + "source 'https://rubygems.org'" if !uri.absolute? || (uri.is_a?(Gem::URI::HTTP) && uri.host.nil?) uri end - def suppress_configured_credentials(remote) - remote_nouser = remove_auth(remote) - if remote.userinfo && remote.userinfo == Bundler.settings[remote_nouser] - remote_nouser - else - remote - end - end - def remove_auth(remote) if remote.user || remote.password remote.dup.tap {|uri| uri.user = uri.password = nil }.to_s @@ -353,13 +362,18 @@ module Bundler def installed_specs @installed_specs ||= Index.build do |idx| - Bundler.rubygems.all_specs.reverse_each do |spec| - next if spec.name == "bundler" + Bundler.rubygems.installed_specs.reverse_each do |spec| + spec.source = self + next if spec.ignored? + idx << spec + end + end + end + + def default_specs + @default_specs ||= Index.build do |idx| + Bundler.rubygems.default_specs.each do |spec| spec.source = self - if Bundler.rubygems.spec_missing_extensions?(spec, false) - Bundler.ui.debug "Source #{self} is ignoring #{spec} because it is missing extensions" - next - end idx << spec end end @@ -367,16 +381,11 @@ module Bundler def cached_specs @cached_specs ||= begin - idx = installed_specs.dup + idx = Index.new Dir["#{cache_path}/*.gem"].each do |gemfile| - next if gemfile =~ /^bundler\-[\d\.]+?\.gem/ s ||= Bundler.rubygems.spec_from_gem(gemfile) s.source = self - if Bundler.rubygems.spec_missing_extensions?(s, false) - Bundler.ui.debug "Source #{self} is ignoring #{s} because it is missing extensions" - next - end idx << s end @@ -385,90 +394,78 @@ module Bundler end def api_fetchers - fetchers.select {|f| f.use_api && f.fetchers.first.api_fetcher? } + fetchers.select(&:api_fetcher?) end def remote_specs @remote_specs ||= Index.build do |idx| index_fetchers = fetchers - api_fetchers - # gather lists from non-api sites - fetch_names(index_fetchers, nil, idx, false) - - # because ensuring we have all the gems we need involves downloading - # the gemspecs of those gems, if the non-api sites contain more than - # about 500 gems, we treat all sites as non-api for speed. - allow_api = idx.size < API_REQUEST_LIMIT && dependency_names.size < API_REQUEST_LIMIT - Bundler.ui.debug "Need to query more than #{API_REQUEST_LIMIT} gems." \ - " Downloading full index instead..." unless allow_api - - fetch_names(api_fetchers, allow_api && dependency_names, idx, false) + if index_fetchers.empty? + fetch_names(api_fetchers, dependency_names, idx) + else + fetch_names(fetchers, nil, idx) + end end end - def fetch_names(fetchers, dependency_names, index, override_dupes) + def fetch_names(fetchers, dependency_names, index) fetchers.each do |f| if dependency_names - Bundler.ui.info "Fetching gem metadata from #{f.uri}", Bundler.ui.debug? - index.use f.specs_with_retry(dependency_names, self), override_dupes + Bundler.ui.info "Fetching gem metadata from #{URICredentialsFilter.credential_filtered_uri(f.uri)}", Bundler.ui.debug? + index.use f.specs_with_retry(dependency_names, self) Bundler.ui.info "" unless Bundler.ui.debug? # new line now that the dots are over else - Bundler.ui.info "Fetching source index from #{f.uri}" - index.use f.specs_with_retry(nil, self), override_dupes + Bundler.ui.info "Fetching source index from #{URICredentialsFilter.credential_filtered_uri(f.uri)}" + index.use f.specs_with_retry(nil, self) end end end - def fetch_gem(spec) - return false unless spec.remote + def fetch_gem_if_possible(spec, previous_spec = nil) + if spec.remote + fetch_gem(spec, previous_spec) + else + cached_gem(spec) + end + end + def fetch_gem(spec, previous_spec = nil) spec.fetch_platform - download_path = requires_sudo? ? Bundler.tmp(spec.full_name) : rubygems_dir - gem_path = "#{rubygems_dir}/cache/#{spec.full_name}.gem" + cache_path = download_cache_path(spec) || default_cache_path_for(rubygems_dir) + gem_path = package_path(cache_path, spec) + return gem_path if File.exist?(gem_path) - SharedHelpers.filesystem_access("#{download_path}/cache") do |p| + SharedHelpers.filesystem_access(cache_path) do |p| FileUtils.mkdir_p(p) end - download_gem(spec, download_path) - - if requires_sudo? - SharedHelpers.filesystem_access("#{rubygems_dir}/cache") do |p| - Bundler.mkdir_p(p) - end - Bundler.sudo "mv #{download_path}/cache/#{spec.full_name}.gem #{gem_path}" - end + download_gem(spec, cache_path, previous_spec) gem_path - ensure - Bundler.rm_rf(download_path) if requires_sudo? - end - - def builtin_gem?(spec) - # Ruby 2.1, where all included gems have this summary - return true if spec.summary =~ /is bundled with Ruby/ - - # Ruby 2.0, where gemspecs are stored in specifications/default/ - spec.loaded_from && spec.loaded_from.include?("specifications/default/") end def installed?(spec) - installed_specs[spec].any? + installed_specs[spec].any? && !spec.installation_missing? end - def requires_sudo? - Bundler.requires_sudo? + def rubygems_dir + Bundler.bundle_path end - def rubygems_dir - Bundler.rubygems.gem_dir + def default_cache_path_for(dir) + "#{dir}/cache" end def cache_path Bundler.app_cache end - private + private + + def lockfile_remotes + @lockfile_remotes || credless_remotes + end # Checks if the requested spec exists in the global cache. If it does, # we copy it to the download path, and if it does not, we download it. @@ -476,44 +473,24 @@ module Bundler # @param [Specification] spec # the spec we want to download or retrieve from the cache. # - # @param [String] download_path + # @param [String] download_cache_path # the local directory the .gem will end up in. # - def download_gem(spec, download_path) - local_path = File.join(download_path, "cache/#{spec.full_name}.gem") - - if (cache_path = download_cache_path(spec)) && cache_path.file? - SharedHelpers.filesystem_access(local_path) do - FileUtils.cp(cache_path, local_path) - end - else - uri = spec.remote.uri - Bundler.ui.confirm("Fetching #{version_message(spec)}") - rubygems_local_path = Bundler.rubygems.download_gem(spec, uri, download_path) - if rubygems_local_path != local_path - FileUtils.mv(rubygems_local_path, local_path) - end - cache_globally(spec, local_path) - end - end - - # Checks if the requested spec exists in the global cache. If it does - # not, we create the relevant global cache subdirectory if it does not - # exist and copy the spec from the local cache to the global cache. - # - # @param [Specification] spec - # the spec we want to copy to the global cache. - # - # @param [String] local_cache_path - # the local directory from which we want to copy the .gem. + # @param [Specification] previous_spec + # the spec previously locked # - def cache_globally(spec, local_cache_path) - return unless cache_path = download_cache_path(spec) - return if cache_path.exist? - - SharedHelpers.filesystem_access(cache_path.dirname, &:mkpath) - SharedHelpers.filesystem_access(cache_path) do - FileUtils.cp(local_cache_path, cache_path) + def download_gem(spec, download_cache_path, previous_spec = nil) + uri = spec.remote.uri + Bundler.ui.confirm("Fetching #{version_message(spec, previous_spec)}") + gem_remote_fetcher = remote_fetchers.fetch(spec.remote).gem_remote_fetcher + + Plugin.hook(Plugin::Events::GEM_BEFORE_FETCH, spec) + begin + Gem.time("Downloaded #{spec.name} in", 0, true) do + Bundler.rubygems.download_gem(spec, uri, download_cache_path, gem_remote_fetcher) + end + ensure + Plugin.hook(Plugin::Events::GEM_AFTER_FETCH, spec) end end @@ -529,17 +506,52 @@ module Bundler # @return [Pathname] The global cache path. # def download_cache_path(spec) - return unless Bundler.feature_flag.global_gem_cache? + return unless Bundler.settings[:global_gem_cache] return unless remote = spec.remote return unless cache_slug = remote.cache_slug - Bundler.user_cache.join("gems", cache_slug, spec.file_name) + if Gem.respond_to?(:global_gem_cache_path) + Pathname.new(Gem.global_gem_cache_path).join(cache_slug) + else + # Fall back to old location for older RubyGems versions + Bundler.user_cache.join("gems", cache_slug) + end end def extension_cache_slug(spec) return unless remote = spec.remote remote.cache_slug end + + # We are using a mutex to read and write from/to the hash. + # The reason this double synchronization was added is for performance + # and to lock the mutex for the shortest possible amount of time. Otherwise, + # all threads are fighting over this mutex and when it gets acquired it gets locked + # until a thread finishes downloading a gem, leaving the other threads waiting + # doing nothing. + def rubygems_gem_installer(spec, options) + @gem_installers_mutex.synchronize { @gem_installers[spec.name] } || begin + path = fetch_gem_if_possible(spec, options[:previous_spec]) + raise GemNotFound, "Could not find #{spec.file_name} for installation" unless path + + REQUIRE_MUTEX.synchronize { require_relative "../rubygems_gem_installer" } + + installer = Bundler::RubyGemsGemInstaller.at( + path, + security_policy: Bundler.rubygems.security_policies[Bundler.settings["trust-policy"]], + install_dir: rubygems_dir.to_s, + bin_dir: Bundler.system_bindir.to_s, + ignore_dependencies: true, + wrappers: true, + env_shebang: true, + build_args: options[:build_args], + bundler_extension_cache_path: extension_cache_path(spec), + build_extension: Bundler.settings[:no_build_extension] ? false : nil, + install_plugin: Bundler.settings[:no_install_plugin] ? false : nil + ) + @gem_installers_mutex.synchronize { @gem_installers[spec.name] ||= installer } + end + end end end end diff --git a/lib/bundler/source/rubygems/remote.rb b/lib/bundler/source/rubygems/remote.rb index 45ea61acb2..ed55912a99 100644 --- a/lib/bundler/source/rubygems/remote.rb +++ b/lib/bundler/source/rubygems/remote.rb @@ -16,6 +16,9 @@ module Bundler @anonymized_uri = remove_auth(@uri).freeze end + MAX_CACHE_SLUG_HOST_SIZE = 255 - 1 - 32 # 255 minus dot minus MD5 length + private_constant :MAX_CACHE_SLUG_HOST_SIZE + # @return [String] A slug suitable for use as a cache key for this # remote. # @@ -28,10 +31,15 @@ module Bundler host = cache_uri.to_s.start_with?("file://") ? nil : cache_uri.host uri_parts = [host, cache_uri.user, cache_uri.port, cache_uri.path] - uri_digest = SharedHelpers.digest(:MD5).hexdigest(uri_parts.compact.join(".")) + uri_parts.compact! + uri_digest = SharedHelpers.digest(:MD5).hexdigest(uri_parts.join(".")) + + uri_parts.pop + host_parts = uri_parts.join(".") + return uri_digest if host_parts.empty? - uri_parts[-1] = uri_digest - uri_parts.compact.join(".") + shortened_host_parts = host_parts[0...MAX_CACHE_SLUG_HOST_SIZE] + [shortened_host_parts, uri_digest].join(".") end end @@ -39,7 +47,7 @@ module Bundler "rubygems remote at #{anonymized_uri}" end - private + private def apply_auth(uri, auth) if auth && uri.userinfo.nil? @@ -48,7 +56,7 @@ module Bundler end uri - rescue Bundler::URI::InvalidComponentError + rescue Gem::URI::InvalidComponentError error_message = "Please CGI escape your usernames and passwords before " \ "setting them for authentication." raise HTTPError.new(error_message) diff --git a/lib/bundler/source/rubygems_aggregate.rb b/lib/bundler/source/rubygems_aggregate.rb new file mode 100644 index 0000000000..8aeaa375fa --- /dev/null +++ b/lib/bundler/source/rubygems_aggregate.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Bundler + class Source + class RubygemsAggregate + attr_reader :source_map, :sources + + def initialize(sources, source_map, excluded_sources = []) + @sources = sources + @source_map = source_map + @excluded_sources = excluded_sources + + @index = build_index + end + + def specs + @index + end + + def identifier + to_s + end + + def to_s + "any of the sources" + end + + private + + def build_index + Index.build do |idx| + dependency_names = source_map.pinned_spec_names + + sources.all_sources.each do |source| + next if @excluded_sources.include?(source) + + source.dependency_names = dependency_names - source_map.pinned_spec_names(source) + idx.add_source source.specs + dependency_names.concat(source.unmet_deps).uniq! + end + + double_check_for_index(idx, dependency_names) + end + end + + # Suppose the gem Foo depends on the gem Bar. Foo exists in Source A. Bar has some versions that exist in both + # sources A and B. At this point, the API request will have found all the versions of Bar in source A, + # but will not have found any versions of Bar from source B, which is a problem if the requested version + # of Foo specifically depends on a version of Bar that is only found in source B. This ensures that for + # each spec we found, we add all possible versions from all sources to the index. + def double_check_for_index(idx, dependency_names) + pinned_names = source_map.pinned_spec_names + + names = :names # do this so we only have to traverse to get dependency_names from the index once + unmet_dependency_names = lambda do + return names unless names == :names + new_names = sources.all_sources.map(&:dependency_names_to_double_check) + return names = nil if new_names.compact! + names = new_names.flatten(1).concat(dependency_names) + names.uniq! + names -= pinned_names + names + end + + sources.all_sources.each do |source| + source.double_check_for(unmet_dependency_names) + end + end + end + end +end |
