diff options
Diffstat (limited to 'lib/bundler/source')
-rw-r--r-- | lib/bundler/source/git.rb | 128 | ||||
-rw-r--r-- | lib/bundler/source/git/git_proxy.rb | 368 | ||||
-rw-r--r-- | lib/bundler/source/metadata.rb | 33 | ||||
-rw-r--r-- | lib/bundler/source/path.rb | 25 | ||||
-rw-r--r-- | lib/bundler/source/path/installer.rb | 23 | ||||
-rw-r--r-- | lib/bundler/source/rubygems.rb | 424 | ||||
-rw-r--r-- | lib/bundler/source/rubygems/remote.rb | 2 | ||||
-rw-r--r-- | lib/bundler/source/rubygems_aggregate.rb | 68 |
8 files changed, 651 insertions, 420 deletions
diff --git a/lib/bundler/source/git.rb b/lib/bundler/source/git.rb index fb13ca0578..198e335bb6 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,7 +20,7 @@ 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"] @@ -42,10 +43,18 @@ 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 end @@ -59,28 +68,40 @@ module Bundler 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 begin - 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 - elsif ref - ref - else - git_proxy.branch - end + at = humanized_ref || current_branch - rev = " (at #{at}@#{shortref_for_display(revision)})" + rev = "at #{at}@#{shortref_for_display(revision)}" rescue GitError "" end - "#{@safe_uri}#{rev}" + uri_with_specifiers([rev, glob_for_display]) + end + + def identifier + uri_with_specifiers([humanized_ref, cached_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 @@ -94,13 +115,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 @@ -124,7 +139,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" @@ -139,14 +154,14 @@ module Bundler # 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 = cached_revision && cached_revision != revision if !Bundler.settings[:disable_local_revision_check] && changed && !@unlocked && !git_proxy.contains?(cached_revision) raise GitError, "The Gemfile lock is pointing to revision #{shortref_for_display(cached_revision)} " \ @@ -171,9 +186,10 @@ module Bundler 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}" @@ -182,7 +198,7 @@ module Bundler @copied = true end - generate_bin_options = { :disable_extensions => !Bundler.rubygems.spec_missing_extensions?(spec), :build_args => options[:build_args] } + generate_bin_options = { disable_extensions: !Bundler.rubygems.spec_missing_extensions?(spec), build_args: options[:build_args] } generate_bin(spec, generate_bin_options) requires_checkout? ? spec.post_install_message : nil @@ -211,13 +227,11 @@ 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.feature_flag.global_gem_cache? + Bundler.user_cache + else + Bundler.bundle_path.join("cache", "bundler") + end.join("git", git_scope) end def app_cache_dirname @@ -228,6 +242,10 @@ module Bundler git_proxy.revision end + def current_branch + git_proxy.current_branch + end + def allow_git_ops? @allow_remote || @allow_cached end @@ -238,6 +256,20 @@ module Bundler private + 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? Dir["#{destination}/#{@glob}"].each do |spec_path| @@ -282,16 +314,26 @@ 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 @@ -303,7 +345,7 @@ 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, cached_revision, self) end def fetch @@ -318,7 +360,7 @@ module Bundler 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" } + stub.full_gem_path = Pathname.new(file).dirname.expand_path(root).to_s StubSpecification.from_stub(stub) end diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index ae21770306..2fc9c6535f 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 @@ -30,10 +28,10 @@ module Bundler def initialize(command, path, extra_info = nil) @command = command - msg = String.new - msg << "Git error: command `#{command}` in directory #{path} 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.exist? super msg end end @@ -45,70 +43,75 @@ module Bundler 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 + # The GitProxy is responsible to interact with git repositories. # 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 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 - @revision ||= find_local_revision + @revision ||= allowed_with_path { find_local_revision } end - def branch - @branch ||= allowed_with_path do - git("rev-parse", "--abbrev-ref", "HEAD", :dir => path).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_with_path do - result, status = git_null("branch", "--contains", commit, :dir => path) - status.success? && result =~ /^\* (.*)$/ + 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] + @version ||= full_version.match(/((\.?\d+)+).*/)[1] end def full_version - git("--version").sub("git version", "").strip + @full_version ||= git_local("--version").sub(/git version\s*/, "").strip end def checkout - return if path.exist? && has_revision_cached? - extra_ref = "#{ref}:#{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}" - configured_uri = configured_uri_for(uri).to_s + extra_fetch_needed = clone_needs_extra_fetch? + unshallow_needed = clone_needs_unshallow? + return unless extra_fetch_needed || unshallow_needed - unless path.exist? - SharedHelpers.filesystem_access(path.dirname) do |p| - FileUtils.mkdir_p(p) - end - git_retry "clone", configured_uri, path.to_s, "--bare", "--no-hardlinks", "--quiet" - return unless extra_ref - end - - with_path do - git_retry(*["fetch", "--force", "--quiet", "--tags", configured_uri, "refs/heads/*:refs/heads/*", extra_ref].compact, :dir => path) - 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| @@ -117,7 +120,7 @@ module Bundler SharedHelpers.filesystem_access(destination) do |p| FileUtils.rm_rf(p) end - git_retry "clone", "--no-checkout", "--quiet", path.to_s, destination.to_s + 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{.*?((?:[a-zA-Z]:)?/.*)}, 1] @@ -126,91 +129,227 @@ module Bundler "this file and try again." end end - # method 2 - git_retry "fetch", "--force", "--quiet", "--tags", path.to_s, :dir => destination - begin - git "reset", "--hard", @revision, :dir => destination - rescue GitCommandError => e - raise MissingGitRevisionError.new(e.command, destination, @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 + 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 + git_retry "submodule", "foreach", "--quiet", inner_command, dir: destination end end private - def git_null(*command, dir: nil) - check_allowed(command) + def git_remote_fetch(args) + command = ["fetch", "--force", "--quiet", "--no-tags", *args, "--", configured_uri, refspec].compact + 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 + raise GitCommandError.new(command_with_no_credentials, path, err) + end + end + end - out, status = SharedHelpers.with_clean_git_env do - capture_and_ignore_stderr(*capture3_args_for(command, dir)) + def clone_needs_extra_fetch? + return true if path.exist? + + SharedHelpers.filesystem_access(path.dirname) do |p| + FileUtils.mkdir_p(p) end - [URICredentialsFilter.credential_filtered_string(out, uri), status] + command = ["clone", "--bare", "--no-hardlinks", "--quiet", *extra_clone_args, "--", configured_uri, path.to_s] + 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") + raise MissingGitRevisionError.new(command_with_no_credentials, nil, explicit_ref, credential_filtered_uri) + else + idx = command.index("--depth") + if idx + command.delete_at(idx) + command.delete_at(idx) + command_with_no_credentials = check_allowed(command) + + err += "Retrying without --depth argument." + end + raise GitCommandError.new(command_with_no_credentials, path, err) + end + end + end + + def clone_needs_unshallow? + return false unless path.join("shallow").exist? + return true if full_clone? + + @revision && @revision != head_revision + end + + def extra_ref + return false if not_pinned? + return true unless full_clone? + + 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 + + reference ||= if ref.include?("~") + ref.split("~").first + elsif ref.start_with?("refs/") + ref + else + "refs/*" + end + + "#{reference}:#{reference}" + end + + def commit + @commit ||= pinned_to_full_sha? ? ref : @revision + end + + def fully_qualified_ref + if branch + "refs/heads/#{branch}" + elsif tag + "refs/tags/#{tag}" + elsif ref.nil? + "refs/heads/#{current_branch}" + end + end + + def not_pinned? + branch || tag || 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) + git(*command, dir: dir) end end def git(*command, dir: nil) - command_with_no_credentials = check_allowed(command) - - out, status = SharedHelpers.with_clean_git_env do - capture_and_filter_stderr(*capture3_args_for(command, dir)) + run_command(*command, dir: dir) do |unredacted_command| + check_allowed(unredacted_command) end + end - filtered_out = URICredentialsFilter.credential_filtered_string(out, uri) - - raise GitCommandError.new(command_with_no_credentials, dir || SharedHelpers.pwd, filtered_out) unless status.success? - - filtered_out + 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 - with_path { git("cat-file", "-e", @revision, :dir => path) } + return unless @revision && path.exist? + git("cat-file", "-e", @revision, 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_with_path do - git("rev-parse", "--verify", ref || "HEAD", :dir => path).strip - end + def head_revision + verify("HEAD") + end + + def find_revision_for(reference) + verify(reference) rescue GitCommandError => e - raise MissingGitRevisionError.new(e.command, path, ref, URICredentialsFilter.credential_filtered_uri(uri)) + 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 + elsif File.exist?(uri) + "file://#{uri}" 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 with_path(&blk) @@ -224,22 +363,41 @@ module Bundler end def check_allowed(command) - command_with_no_credentials = URICredentialsFilter.credential_filtered_string("git #{command.shelljoin}", uri) + command_with_no_credentials = redact_and_check_presence(command) raise GitNotAllowedError.new(command_with_no_credentials) unless allow? command_with_no_credentials end - def capture_and_filter_stderr(*cmd) - require "open3" - return_value, captured_err, status = Open3.capture3(*cmd) - Bundler.ui.warn URICredentialsFilter.credential_filtered_string(captured_err, uri) unless captured_err.empty? - [return_value, status] + 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_and_ignore_stderr(*cmd) - require "open3" - return_value, _, status = Open3.capture3(*cmd) - [return_value, status] + 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) @@ -248,13 +406,57 @@ module Bundler if Bundler.feature_flag.bundler_3_mode? || supports_minus_c? ["git", "-C", dir.to_s, *cmd] else - ["git", *cmd, { :chdir => dir.to_s }] + ["git", *cmd, { chdir: dir.to_s }] end 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 || tag] if branch || tag + args + end + + def depth_args + return [] if full_clone? + + ["--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 full_clone? + depth.nil? + end + def supports_minus_c? @supports_minus_c ||= Gem::Version.new(version) >= Gem::Version.new("1.8.5") 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 supports_cloning_with_no_tags? + @supports_cloning_with_no_tags ||= Gem::Version.new(version) >= Gem::Version.new("2.14.0-rc0") + end end end end diff --git a/lib/bundler/source/metadata.rb b/lib/bundler/source/metadata.rb index 50b65ce0ea..6b05e17727 100644 --- a/lib/bundler/source/metadata.rb +++ b/lib/bundler/source/metadata.rb @@ -5,28 +5,29 @@ 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] + # can't point to the actual gemspec or else the require paths will be wrong + s.loaded_from = __dir__ + end end idx.each {|s| s.source = self } diff --git a/lib/bundler/source/path.rb b/lib/bundler/source/path.rb index 01f89b204d..978b0b2c9f 100644 --- a/lib/bundler/source/path.rb +++ b/lib/bundler/source/path.rb @@ -11,9 +11,10 @@ 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 @@ -82,10 +83,10 @@ module Bundler end def install(spec, options = {}) - using_message = "Using #{version_message(spec)} from #{self}" + 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) + generate_bin(spec, disable_extensions: true) nil # no post-install message end @@ -224,22 +225,22 @@ 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.map do |path| + next path unless /\A#{Pathname::SEPARATOR_PAT}/o.match?(path) + next if File.directory?(path) begin - Pathname.new(p).relative_path_from(gem_dir).to_s + Pathname.new(path).relative_path_from(gem_dir).to_s rescue ArgumentError - p + path end end.compact 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 a70973bde7..0af28fe770 100644 --- a/lib/bundler/source/path/installer.rb +++ b/lib/bundler/source/path/installer.rb @@ -18,13 +18,7 @@ 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 @@ -38,25 +32,10 @@ module Bundler generate_bin unless spec.executables.empty? run_hooks(:post_install) - ensure - Bundler.rm_rf(@tmp_dir) if Bundler.requires_sudo? end private - def generate_bin - super - - 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 - end - 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 2b7cfd53b9..3ac1bd4ff8 100644 --- a/lib/bundler/source/rubygems.rb +++ b/lib/bundler/source/rubygems.rb @@ -7,12 +7,10 @@ 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 - attr_reader :remotes, :caches + attr_accessor :remotes def initialize(options = {}) @options = options @@ -21,11 +19,22 @@ module Bundler @allow_remote = false @allow_cached = false @allow_local = options["allow_local"] || false - @caches = [cache_path, *Bundler.rubygems.gem_cache] + @checksum_store = Checksum::Store.new Array(options["remotes"]).reverse_each {|r| add_remote(r) } end + def caches + @caches ||= [cache_path, *Bundler.rubygems.gem_cache] + end + + def local_only! + @specs = nil + @allow_local = true + @allow_cached = false + @allow_remote = false + end + def local! return if @allow_local @@ -41,6 +50,8 @@ module Bundler end def cached! + return unless File.exist?(cache_path) + return if @allow_cached @specs = nil @@ -61,13 +72,17 @@ module Bundler o.is_a?(Rubygems) && (o.credless_remotes - credless_remotes).empty? end - def disable_multisource? - @remotes.size <= 1 + def multiple_remotes? + @remotes.size > 1 + end + + def no_remotes? + @remotes.size == 0 end def can_lock?(spec) - return super if disable_multisource? - spec.source.is_a?(Rubygems) + return super unless multiple_remotes? + include?(spec.source) end def options @@ -75,13 +90,14 @@ module Bundler end def self.from_lock(options) + options["remotes"] = Array(options.delete("remote")).reverse new(options) end def to_lock out = String.new("GEM\n") remotes.reverse_each do |remote| - out << " remote: #{suppress_configured_credentials remote}\n" + out << " remote: #{remove_auth remote}\n" end out << " specs:\n" end @@ -89,133 +105,115 @@ 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) if @allow_local - 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 + index end end - def install(spec, opts = {}) - force = opts[:force] - ensure_builtin_gems_cached = opts[:ensure_builtin_gems_cached] + def install(spec, options = {}) + force = options[:force] + ensure_builtin_gems_cached = options[: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 + if ensure_builtin_gems_cached && spec.default_gem? && !cached_path(spec) + cached_built_in_gem(spec) unless spec.remote + force = true end - if (installed?(spec) || Plugin.installed?(spec.name)) && !force - print_using_message "Using #{version_message(spec)}" + if installed?(spec) && !force + print_using_message "Using #{version_message(spec, options[:previous_spec])}" return nil # no post-install message end - # 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! + uris = [spec.remote, *remotes_for_spec(spec)].map(&:anonymized_uri).uniq Installer.ambiguous_gems << [spec.name, *uris] if uris.length > 1 + end + + path = fetch_gem_if_possible(spec, options[:previous_spec]) + raise GemNotFound, "Could not find #{spec.file_name} for installation" unless path - path = fetch_gem(spec) - begin - s = Bundler.rubygems.spec_from_gem(path, Bundler.settings["trust-policy"]) - spec.__swap__(s) - rescue StandardError + return if Bundler.settings[:no_install] + + install_path = rubygems_dir + bin_path = Bundler.system_bindir + + require_relative "../rubygems_gem_installer" + + installer = Bundler::RubyGemsGemInstaller.at( + path, + security_policy: Bundler.rubygems.security_policies[Bundler.settings["trust-policy"]], + install_dir: install_path.to_s, + bin_dir: bin_path.to_s, + ignore_dependencies: true, + wrappers: true, + env_shebang: true, + build_args: options[:build_args], + bundler_extension_cache_path: extension_cache_path(spec) + ) + + if spec.remote + s = begin + installer.spec + rescue Gem::Package::FormatError Bundler.rm_rf(path) raise + rescue Gem::Security::Exception => e + raise SecurityError, + "The gem #{File.basename(path, ".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.source.checksum_store.register(spec, installer.gem_checksum) - 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 + message = "Installing #{version_message(spec, options[:previous_spec])}" + message += " with native extensions" if spec.extensions.any? + Bundler.ui.confirm message - Bundler.mkdir_p bin_path, :no_sudo => true unless spec.executables.empty? || Bundler.rubygems.provides?(">= 2.7.5") - - require_relative "../rubygems_gem_installer" - - 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 - 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 + installed_spec = installer.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) - end - spec.loaded_from = loaded_from(spec) + spec.full_gem_path = installed_spec.full_gem_path + spec.loaded_from = installed_spec.loaded_from 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)) @@ -242,41 +240,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? @@ -290,7 +282,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 @@ -298,21 +292,28 @@ 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 + def dependency_api_available? + @allow_remote && api_fetchers.any? + end + protected + def remote_names + remotes.map(&:to_s).join(", ") + end + def credless_remotes - remotes.map(&method(:suppress_configured_credentials)) + remotes.map(&method(:remove_auth)) end def remotes_for_spec(spec) @@ -322,42 +323,35 @@ module Bundler end end - def loaded_from(spec) - "#{rubygems_dir}/specifications/#{spec.full_name}.gemspec" - end - def cached_gem(spec) - cached_gem = cached_path(spec) - unless cached_gem - raise Bundler::GemNotFound, "Could not find #{spec.file_name} for installation" + if spec.default_gem? + cached_built_in_gem(spec) + else + cached_path(spec) end - cached_gem end def cached_path(spec) - possibilities = @caches.map {|p| "#{p}/#{spec.file_name}" } + global_cache_path = download_cache_path(spec) + caches << global_cache_path if global_cache_path + + 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 @@ -381,16 +375,11 @@ module Bundler def cached_specs @cached_specs ||= begin - idx = @allow_local ? installed_specs.dup : Index.new + 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 @@ -399,83 +388,67 @@ 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 #{URICredentialsFilter.credential_filtered_uri(f.uri)}", Bundler.ui.debug? - index.use f.specs_with_retry(dependency_names, self), override_dupes + 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 #{URICredentialsFilter.credential_filtered_uri(f.uri)}" - index.use f.specs_with_retry(nil, self), override_dupes + 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.deleted_gem? 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 @@ -490,52 +463,17 @@ 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) - - # older rubygems return varying file:// variants depending on version - rubygems_local_path = rubygems_local_path.gsub(/\Afile:/, "") unless Bundler.rubygems.provides?(">= 3.2.0.rc.2") - rubygems_local_path = rubygems_local_path.gsub(%r{\A//}, "") if Bundler.rubygems.provides?("< 3.1.0") - - if rubygems_local_path != local_path - SharedHelpers.filesystem_access(local_path) do - FileUtils.mv(rubygems_local_path, local_path) - end - 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 [Specification] previous_spec + # the spec previously locked # - # @param [String] local_cache_path - # the local directory from which we want to copy the .gem. - # - 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) - end + 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 + Bundler.rubygems.download_gem(spec, uri, download_cache_path, gem_remote_fetcher) end # Returns the global cache path of the calling Rubygems::Source object. @@ -554,7 +492,7 @@ module Bundler return unless remote = spec.remote return unless cache_slug = remote.cache_slug - Bundler.user_cache.join("gems", cache_slug, spec.file_name) + Bundler.user_cache.join("gems", cache_slug) end def extension_cache_slug(spec) diff --git a/lib/bundler/source/rubygems/remote.rb b/lib/bundler/source/rubygems/remote.rb index 82c850ffbb..9c5c06de24 100644 --- a/lib/bundler/source/rubygems/remote.rb +++ b/lib/bundler/source/rubygems/remote.rb @@ -48,7 +48,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..99ef81ad54 --- /dev/null +++ b/lib/bundler/source/rubygems_aggregate.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Bundler + class Source + class RubygemsAggregate + attr_reader :source_map, :sources + + def initialize(sources, source_map) + @sources = sources + @source_map = source_map + + @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| + 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 |