diff options
Diffstat (limited to 'lib/bundler/source')
| -rw-r--r-- | lib/bundler/source/gemspec.rb | 19 | ||||
| -rw-r--r-- | lib/bundler/source/git.rb | 451 | ||||
| -rw-r--r-- | lib/bundler/source/git/git_proxy.rb | 464 | ||||
| -rw-r--r-- | lib/bundler/source/metadata.rb | 63 | ||||
| -rw-r--r-- | lib/bundler/source/path.rb | 255 | ||||
| -rw-r--r-- | lib/bundler/source/path/installer.rb | 53 | ||||
| -rw-r--r-- | lib/bundler/source/rubygems.rb | 516 | ||||
| -rw-r--r-- | lib/bundler/source/rubygems/remote.rb | 76 | ||||
| -rw-r--r-- | lib/bundler/source/rubygems_aggregate.rb | 68 |
9 files changed, 1965 insertions, 0 deletions
diff --git a/lib/bundler/source/gemspec.rb b/lib/bundler/source/gemspec.rb new file mode 100644 index 0000000000..ed766dbe74 --- /dev/null +++ b/lib/bundler/source/gemspec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Bundler + class Source + class Gemspec < Path + attr_reader :gemspec + attr_writer :checksum_store + + def initialize(options) + super + @gemspec = options["gemspec"] + end + + def to_s + "gemspec at `#{@path}`" + end + end + end +end diff --git a/lib/bundler/source/git.rb b/lib/bundler/source/git.rb new file mode 100644 index 0000000000..bb669ebba3 --- /dev/null +++ b/lib/bundler/source/git.rb @@ -0,0 +1,451 @@ +# frozen_string_literal: true + +require_relative "../vendored_fileutils" + +module Bundler + class Source + class Git < Path + autoload :GitProxy, File.expand_path("git/git_proxy", __dir__) + + attr_reader :uri, :ref, :branch, :options, :glob, :submodules + + def initialize(options) + @options = options + @checksum_store = Checksum::Store.new + @glob = options["glob"] || DEFAULT_GLOB + + @allow_cached = false + @allow_remote = false + + # 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 = 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"] + @submodules = options["submodules"] + @name = options["name"] + @version = options["version"].to_s.strip.gsub("-", ".pre.") + + @copied = false + @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 + + def to_lock + out = String.new("GIT\n") + out << " remote: #{@uri}\n" + out << " revision: #{revision}\n" + %w[ref branch tag submodules].each do |opt| + out << " #{opt}: #{options[opt]}\n" if options[opt] + end + 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, glob, submodules].hash + end + + def eql?(other) + other.is_a?(Git) && uri == other.uri && ref == other.ref && + branch == other.branch && name == other.name && + 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 + begin + at = humanized_ref || current_branch + + rev = "at #{at}@#{shortref_for_display(revision)}" + rescue GitError + "" + end + + uri_with_specifiers([rev, glob_for_display]) + end + + 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 + File.basename(@uri, ".git") + end + + # This is the path which is going to contain a specific + # checkout of the git repository. When using local git + # repos, this is set to the local repo. + def install_path + @install_path ||= begin + git_scope = "#{base_name}-#{shortref_for_path(revision)}" + + Bundler.install_path.join(git_scope) + end + end + + alias_method :path, :install_path + + def extension_dir_name + "#{base_name}-#{shortref_for_path(revision)}" + end + + def unlock! + git_proxy.revision = nil + options["revision"] = nil + + @unlocked = true + end + + def local_override!(path) + return false if local? + + original_path = path + path = Pathname.new(path) + path = path.expand_path(Bundler.root) unless path.relative? + + 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" + end + + unless path.exist? + raise GitError, "Cannot use local override for #{name} because #{path} " \ + "does not exist. Run `bundle config unset local.#{override_for(original_path)}` to remove the local override" + end + + @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, options) + + if current_branch != branch && !Bundler.settings[:disable_local_branch_check] + raise GitError, "Local override for #{name} at #{path} is using branch " \ + "#{current_branch} but Gemfile specifies #{branch}" + end + + changed = locked_revision && locked_revision != 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 + + changed + end + + def specs(*) + set_cache_path!(app_cache_path) if use_app_cache? + + if requires_checkout? && !@copied + fetch unless use_app_cache? + checkout + end + + local_specs + end + + def install(spec, options = {}) + return if Bundler.settings[:no_install] + force = options[:force] + + print_using_message "Using #{version_message(spec, options[:previous_spec])} from #{self}" + + if (requires_checkout? && !@copied) || force + checkout + end + + 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) + cache_to(custom_path, try_migrate: false) + end + + def load_spec_files + super + rescue PathError => e + Bundler.ui.trace e + raise GitError, "#{self} is not yet checked out. Run `bundle install` first." + end + + # This is the path which is going to contain a cache + # of the git repository. When using the same git repository + # 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 ||= 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(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 + + 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? + Dir["#{destination}/#{@glob}"].each do |spec_path| + # Evaluate gemspecs and cache the result. Gemspecs + # in git might require git or other dependencies. + # The gemspecs we cache should already be evaluated. + spec = Bundler.load_gemspec(spec_path) + next unless 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_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? + locked_revision && super + end + + def use_app_cache? + has_app_cache? && !local? + end + + def requires_checkout? + allow_git_ops? && !local? && !locked_revision_checked_out? + end + + def locked_revision_checked_out? + locked_revision && locked_revision == revision && installed? + end + + def installed? + git_proxy.installed_to?(install_path) + end + + def base_name + File.basename(uri.sub(%r{^(\w+://)?([^/:]+:)?(//\w*/)?(\w*/)*}, ""), ".git") + end + + def shortref_for_display(ref) + ref[0..6] + end + + def shortref_for_path(ref) + 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 %r{^\w+://(\w+@)?}.match?(uri) + # Downcase the domain component of the URI + # and strip off a trailing slash, if one is present + 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 + # 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 locked_revision + options["revision"] + end + + def cached? + cache_path.exist? + end + + def git_proxy + @git_proxy ||= GitProxy.new(cache_path, uri, options, locked_revision, self) + end + + def fetch + git_proxy.checkout + rescue GitError => e + Bundler.ui.warn "Using cached git data because of network errors:\n#{e}" + end + + # no-op, since we validate when re-serializing the gemspec + def validate_spec(_spec); end + + def load_gemspec(file) + 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 + "#{base_name}-#{uri_hash}" + end + + def extension_cache_slug(_) + extension_dir_name + end + + 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 new file mode 100644 index 0000000000..cd352c22a7 --- /dev/null +++ b/lib/bundler/source/git/git_proxy.rb @@ -0,0 +1,464 @@ +# frozen_string_literal: true + +module Bundler + class Source + class Git + class GitNotInstalledError < GitError + def initialize + msg = String.new + msg << "You need to install git to be able to use gems from git repositories. " + msg << "For help installing git, please refer to GitHub's tutorial at https://help.github.com/articles/set-up-git" + super msg + end + end + + class GitNotAllowedError < GitError + def initialize(command) + msg = String.new + 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 + end + + class GitCommandError < GitError + attr_reader :command + + def initialize(command, path, extra_info = nil) + @command = command + + msg = String.new("Git error: command `#{command}`") + msg << " in directory #{path}" if path + msg << " has failed." + msg << "\n#{extra_info}" if extra_info + super msg + end + end + + class MissingGitRevisionError < GitCommandError + def initialize(command, destination_path, ref, repo) + msg = "Revision #{ref} does not exist in the repository #{repo}. Maybe you misspelled it?" + 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 + + # 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, :branch, :tag, :ref, :explicit_ref + attr_writer :revision + + def initialize(path, uri, options = {}, revision = nil, git = nil) + @path = path + @uri = uri + @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 + @commit_ref = nil + end + + def revision + @revision ||= allowed_with_path { find_local_revision } + end + + 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.match?(/^\* (.*)$/) + end + end + + def version + @version ||= full_version.match(/((\.?\d+)+).*/)[1] + end + + def full_version + @full_version ||= git_local("--version").sub(/git version\s*/, "").strip + end + + def checkout + return if has_revision_cached? + + Bundler.ui.info "Fetching #{credential_filtered_uri}" + + extra_fetch_needed = clone_needs_extra_fetch? + unshallow_needed = clone_needs_unshallow? + return unless extra_fetch_needed || unshallow_needed + + git_remote_fetch(unshallow_needed ? ["--unshallow"] : depth_args) + end + + def copy_to(destination, submodules = false) + unless File.exist?(destination.join(".git")) + begin + SharedHelpers.filesystem_access(destination.dirname) do |p| + FileUtils.mkdir_p(p) + end + SharedHelpers.filesystem_access(destination) do |p| + FileUtils.rm_rf(p) + end + 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] + 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 + + 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", "--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 + + def clone_needs_extra_fetch? + return true if path.exist? + + SharedHelpers.filesystem_access(path.dirname) do |p| + FileUtils.mkdir_p(p) + end + + 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") || # 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 + 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_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 + + def git(*command, dir: nil) + run_command(*command, dir: dir) do |unredacted_command| + check_allowed(unredacted_command) + end + end + + 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 commit && path.exist? + git("cat-file", "-e", commit, dir: path) + true + rescue GitError + false + end + + def find_local_revision + return head_revision if explicit_ref.nil? + + find_revision_for(explicit_ref) + end + + def head_revision + verify("HEAD") + 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 + 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.to_s + end + end + + # Removes credentials from the URI + def credential_filtered_uri + URICredentialsFilter.credential_filtered_uri(uri) + end + + def allow? + allowed = @git ? @git.allow_git_ops? : true + + raise GitNotInstalledError.new if allowed && !Bundler.git_present? + + allowed + end + + def with_path(&blk) + checkout unless path.exist? + blk.call + end + + 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 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 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 branch_option + branch || tag + end + + def full_clone? + 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 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 +end diff --git a/lib/bundler/source/metadata.rb b/lib/bundler/source/metadata.rb new file mode 100644 index 0000000000..fd959cd64e --- /dev/null +++ b/lib/bundler/source/metadata.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Bundler + class Source + class Metadata < Source + def specs + @specs ||= Index.build do |idx| + 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 + + if local_spec = Gem.loaded_specs["bundler"] + raise CorruptBundlerInstallError.new(local_spec) if local_spec.version.to_s != Bundler::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 options + {} + end + + def install(spec, _opts = {}) + print_using_message "Using #{version_message(spec)}" + nil + end + + def to_s + "the local ruby installation" + end + + def ==(other) + self.class == other.class + end + alias_method :eql?, :== + + def hash + self.class.hash + end + + def version_message(spec) + "#{spec.name} #{spec.version}" + end + end + end +end diff --git a/lib/bundler/source/path.rb b/lib/bundler/source/path.rb new file mode 100644 index 0000000000..82e782ba25 --- /dev/null +++ b/lib/bundler/source/path.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +module Bundler + class Source + class Path < Source + autoload :Installer, File.expand_path("path/installer", __dir__) + + attr_reader :path, :options, :root_path, :original_path + attr_writer :name + attr_accessor :version + + protected :original_path + + DEFAULT_GLOB = "{,*,*/*}.gemspec" + + def initialize(options) + @checksum_store = Checksum::Store.new + @options = options.dup + @glob = options["glob"] || DEFAULT_GLOB + + @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(File.expand_path(root_path)) + else + expanded_path + end + end + + @name = options["name"] + @version = options["version"] + + # Stores the original path. If at any point we move to the + # cached directory, we still have the original path to copy from. + @original_path = @path + end + + def self.from_lock(options) + new(options.merge("path" => options.delete("remote"))) + end + + def to_lock + out = String.new("PATH\n") + out << " remote: #{lockfile_path}\n" + out << " glob: #{@glob}\n" unless @glob == DEFAULT_GLOB + out << " specs:\n" + end + + def to_s + "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) + [Gemspec, Path].include?(other.class) && + expanded_original_path == other.expanded_original_path && + version == other.version + end + + alias_method :==, :eql? + + def name + File.basename(expanded_path.to_s) + end + + def install(spec, options = {}) + 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.settings[:cache_all] + return if expand(@original_path).to_s.index(root_path.to_s + "/") == 0 + + unless @original_path.exist? + raise GemNotFound, "Can't cache gem #{version_message(spec)} because #{self} is missing!" + end + + FileUtils.rm_rf(app_cache_path) + FileUtils.cp_r("#{@original_path}/.", app_cache_path) + FileUtils.touch(app_cache_path.join(".bundlecache")) + end + + def local_specs(*) + @local_specs ||= load_spec_files + end + + def specs + if has_app_cache? + @path = app_cache_path + @expanded_path = nil # Invalidate + end + local_specs + end + + def app_cache_dirname + name + end + + def root + Bundler.root + end + + def expanded_original_path + @expanded_original_path ||= expand(original_path) + end + + private + + def expanded_path + @expanded_path ||= expand(path) + end + + def expand(somepath) + somepath.expand_path(root_path) + rescue ArgumentError => e + Bundler.ui.debug(e) + raise PathError, "There was an error while trying to use the path " \ + "`#{somepath}`.\nThe error message was: #{e.message}." + end + + def lockfile_path + return relative_path(original_path) if original_path.absolute? + expand(original_path).relative_path_from(root) + end + + def app_cache_path(custom_path = nil) + @app_cache_path ||= Bundler.app_cache(custom_path).join(app_cache_dirname) + end + + def has_app_cache? + SharedHelpers.in_bundle? && app_cache_path.exist? + end + + def load_gemspec(file) + return unless spec = Bundler.load_gemspec(file) + spec.installed_by_version = Gem::VERSION + spec + end + + def validate_spec(spec) + Bundler.rubygems.validate(spec) + end + + def load_spec_files + index = Index.new + + if File.directory?(expanded_path) + # We sort depth-first since `<<` will override the earlier-found specs + 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) + index << spec + end + + if index.empty? && @name && @version + index << Gem::Specification.new do |s| + s.name = @name + s.source = self + s.version = Gem::Version.new(@version) + s.platform = Gem::Platform::RUBY + s.summary = "Fake gemspec for #{@name}" + s.relative_loaded_from = "#{@name}.gemspec" + s.authors = ["no one"] + if expanded_path.join("bin").exist? + executables = expanded_path.join("bin").children + executables.reject! {|p| File.directory?(p) } + s.executables = executables.map {|c| c.basename.to_s } + end + end + end + else + message = String.new("The path `#{expanded_path}` ") + message << if File.exist?(expanded_path) + "is not a directory." + else + "does not exist." + end + raise PathError, message + end + + index + end + + def relative_path(path = self.path) + if path.to_s.start_with?(root_path.to_s) + return path.relative_path_from(root_path) + end + path + end + + def generate_bin(spec, options = {}) + gem_dir = Pathname.new(spec.full_gem_path) + + # Some gem authors put absolute paths in their gemspec + # and we have to save them from themselves + spec.files = spec.files.filter_map do |path| + next path unless /\A#{Pathname::SEPARATOR_PAT}/o.match?(path) + next if File.directory?(path) + begin + Pathname.new(path).relative_path_from(gem_dir).to_s + rescue ArgumentError + path + end + 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) + ) + installer.post_install + rescue Gem::InvalidSpecificationException => e + Bundler.ui.warn "\n#{spec.name} at #{spec.full_gem_path} did not have a valid gemspec.\n" \ + "This prevents bundler from installing bins or native extensions, but " \ + "that may not affect its functionality." + + if !spec.extensions.empty? && !spec.email.empty? + Bundler.ui.warn "If you need to use this package without installing it from a gem " \ + "repository, please contact #{spec.email} and ask them " \ + "to modify their .gemspec so it can work with `gem build`." + end + + Bundler.ui.warn "The validation message from RubyGems was:\n #{e.message}" + end + end + end +end diff --git a/lib/bundler/source/path/installer.rb b/lib/bundler/source/path/installer.rb new file mode 100644 index 0000000000..0af28fe770 --- /dev/null +++ b/lib/bundler/source/path/installer.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative "../../rubygems_gem_installer" + +module Bundler + class Source + class Path + class Installer < Bundler::RubyGemsGemInstaller + attr_reader :spec + + def initialize(spec, options = {}) + @options = options + @spec = spec + @gem_dir = Bundler.rubygems.path(spec.full_gem_path) + @wrappers = true + @env_shebang = true + @format_executable = options[:format_executable] || false + @build_args = options[:build_args] || Bundler.rubygems.build_args + @gem_bin_dir = "#{Bundler.rubygems.gem_dir}/bin" + @disable_extensions = options[:disable_extensions] + @bin_dir = @gem_bin_dir + end + + def post_install + run_hooks(:pre_install) + + unless @disable_extensions + build_extensions + run_hooks(:post_build) + end + + generate_bin unless spec.executables.empty? + + run_hooks(:post_install) + end + + private + + def run_hooks(type) + hooks_meth = "#{type}_hooks" + return unless Gem.respond_to?(hooks_meth) + Gem.send(hooks_meth).each do |hook| + result = hook.call(self) + next unless result == false + location = " at #{$1}" if hook.inspect =~ /@(.*:\d+)/ + message = "#{type} hook#{location} failed for #{spec.full_name}" + raise InstallHookError, message + end + end + end + end + end +end diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb new file mode 100644 index 0000000000..e1e030ffc8 --- /dev/null +++ b/lib/bundler/source/rubygems.rb @@ -0,0 +1,516 @@ +# frozen_string_literal: true + +require "rubygems/user_interaction" + +module Bundler + class Source + class Rubygems < Source + autoload :Remote, File.expand_path("rubygems/remote", __dir__) + + # Ask for X gems per API request + API_REQUEST_SIZE = 100 + + attr_accessor :remotes + + def initialize(options = {}) + @options = options + @remotes = [] + @dependency_names = [] + @allow_remote = false + @allow_cached = false + @allow_local = options["allow_local"] || false + @prefer_local = false + @checksum_store = Checksum::Store.new + + 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 + + def hash + @remotes.hash + end + + def eql?(other) + other.is_a?(Rubygems) && other.credless_remotes == credless_remotes + end + + alias_method :==, :eql? + + def include?(o) + 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 unless multiple_remotes? + include?(spec.source) + end + + def options + { "remotes" => @remotes.map(&:to_s) } + end + + def self.from_lock(options) + options["remotes"] = Array(options.delete("remote")).reverse + new(options.merge("from_lockfile" => true)) + end + + def to_lock + out = String.new("GEM\n") + lockfile_remotes.reverse_each do |remote| + out << " remote: #{remote}\n" + end + out << " specs:\n" + end + + def to_s + if remotes.empty? + "locally installed gems" + 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, :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.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, 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 + + path = fetch_gem_if_possible(spec, options[:previous_spec]) + raise GemNotFound, "Could not find #{spec.file_name} for installation" unless path + + 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 + + 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.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 + end + + def cache(spec, custom_path = nil) + 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)) + rescue Errno::EACCES => e + Bundler.ui.debug(e) + raise InstallError, e.message + end + + 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 + end + cached_path + end + + def add_remote(source) + uri = normalize_uri(source) + @remotes.unshift(uri) unless @remotes.include?(uri) + end + + def spec_names + if dependency_api_available? + remote_specs.spec_names + else + [] + end + end + + def unmet_deps + if dependency_api_available? + remote_specs.unmet_dependency_names + else + [] + end + end + + def remote_fetchers + @remote_fetchers ||= remotes.to_h do |uri| + remote = Source::Rubygems::Remote.new(uri) + [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 dependency_api_available? + + unmet_dependency_names = unmet_dependency_names.call + unless unmet_dependency_names.nil? + if api_fetchers.size <= 1 + # can't do this when there are multiple fetchers because then we might not fetch from _all_ + # of them + unmet_dependency_names -= remote_specs.spec_names # avoid re-fetching things we've already gotten + end + return if unmet_dependency_names.empty? + end + + 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, remote_specs) + + specs.use remote_specs + end + + def dependency_names_to_double_check + names = [] + remote_specs.each do |spec| + case spec + when EndpointSpecification, Gem::Specification, StubSpecification, LazySpecification + 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 + 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(:remove_auth)) + end + + def cached_gem(spec) + 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 = URINormalizer.normalize_suffix(uri.to_s) + require_relative "../vendored_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?(Gem::URI::HTTP) && uri.host.nil?) + uri + end + + def remove_auth(remote) + if remote.user || remote.password + remote.dup.tap {|uri| uri.user = uri.password = nil }.to_s + else + remote.to_s + end + end + + def installed_specs + @installed_specs ||= Index.build do |idx| + 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 + idx << spec + end + end + end + + def cached_specs + @cached_specs ||= begin + idx = Index.new + + Dir["#{cache_path}/*.gem"].each do |gemfile| + s ||= Bundler.rubygems.spec_from_gem(gemfile) + s.source = self + idx << s + end + + idx + end + end + + def api_fetchers + fetchers.select(&:api_fetcher?) + end + + def remote_specs + @remote_specs ||= Index.build do |idx| + index_fetchers = fetchers - api_fetchers + + 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) + 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) + 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) + end + end + end + + 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 + + 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(cache_path) do |p| + FileUtils.mkdir_p(p) + end + download_gem(spec, cache_path, previous_spec) + + gem_path + end + + def installed?(spec) + installed_specs[spec].any? && !spec.installation_missing? + end + + def rubygems_dir + Bundler.bundle_path + end + + def default_cache_path_for(dir) + "#{dir}/cache" + end + + def cache_path + Bundler.app_cache + end + + 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. + # + # @param [Specification] spec + # the spec we want to download or retrieve from the cache. + # + # @param [String] download_cache_path + # the local directory the .gem will end up in. + # + # @param [Specification] previous_spec + # the spec previously locked + # + 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 + + Gem.time("Downloaded #{spec.name} in", 0, true) do + Bundler.rubygems.download_gem(spec, uri, download_cache_path, gem_remote_fetcher) + end + end + + # Returns the global cache path of the calling Rubygems::Source object. + # + # Note that the Source determines the path's subdirectory. We use this + # subdirectory in the global cache path so that gems with the same name + # -- and possibly different versions -- from different sources are saved + # to their respective subdirectories and do not override one another. + # + # @param [Gem::Specification] specification + # + # @return [Pathname] The global cache path. + # + def download_cache_path(spec) + 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) + end + + def extension_cache_slug(spec) + return unless remote = spec.remote + remote.cache_slug + end + end + end +end diff --git a/lib/bundler/source/rubygems/remote.rb b/lib/bundler/source/rubygems/remote.rb new file mode 100644 index 0000000000..ed55912a99 --- /dev/null +++ b/lib/bundler/source/rubygems/remote.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Bundler + class Source + class Rubygems + class Remote + attr_reader :uri, :anonymized_uri, :original_uri + + def initialize(uri) + orig_uri = uri + uri = Bundler.settings.mirror_for(uri) + @original_uri = orig_uri if orig_uri != uri + fallback_auth = Bundler.settings.credentials_for(uri) + + @uri = apply_auth(uri, fallback_auth).freeze + @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. + # + def cache_slug + @cache_slug ||= begin + return nil unless SharedHelpers.md5_available? + + cache_uri = original_uri || uri + + 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_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? + + shortened_host_parts = host_parts[0...MAX_CACHE_SLUG_HOST_SIZE] + [shortened_host_parts, uri_digest].join(".") + end + end + + def to_s + "rubygems remote at #{anonymized_uri}" + end + + private + + def apply_auth(uri, auth) + if auth && uri.userinfo.nil? + uri = uri.dup + uri.userinfo = auth + end + + uri + rescue Gem::URI::InvalidComponentError + error_message = "Please CGI escape your usernames and passwords before " \ + "setting them for authentication." + raise HTTPError.new(error_message) + end + + def remove_auth(uri) + if uri.userinfo + uri = uri.dup + uri.user = uri.password = nil + end + + uri + end + end + end + end +end 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 |
