├── .ruby-version ├── .rspec ├── lib └── bundler │ ├── patch │ ├── version.rb │ ├── gems_to_patch_reconciler.rb │ ├── ruby_version.rb │ ├── cli_options.rb │ ├── updater.rb │ ├── gem_version_patch_promoter.rb │ ├── advisory_consolidator.rb │ ├── gemfile.rb │ ├── target_bundle.rb │ ├── conservative_resolver.rb │ ├── conservative_definition.rb │ └── cli.rb │ └── patch.rb ├── Gemfile ├── bin ├── setup ├── bundler-patch └── console ├── .gitignore ├── spec ├── bundler │ ├── unit │ │ ├── bundler_version_spec.rb │ │ ├── gems_to_patch_reconciler_spec.rb │ │ ├── updater_spec.rb │ │ ├── cli_options_spec.rb │ │ ├── ruby_version_spec.rb │ │ ├── advisory_consolidator_spec.rb │ │ ├── target_bundle_spec.rb │ │ ├── conservative_resolver_spec.rb │ │ ├── gemfile_spec.rb │ │ └── conservative_definition_spec.rb │ └── integration │ │ ├── use_target_ruby_spec.rb │ │ └── integration_spec.rb ├── fixture │ └── gemfile_fixture.rb └── spec_helper.rb ├── Rakefile ├── LICENSE.txt ├── bundler-patch.gemspec ├── .travis.yml └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.5.3 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --backtrace 4 | -------------------------------------------------------------------------------- /lib/bundler/patch/version.rb: -------------------------------------------------------------------------------- 1 | module Bundler 2 | module Patch 3 | VERSION = '1.2.0' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in bundler-patch.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /bin/ 6 | /coverage/ 7 | /doc/ 8 | /pkg/ 9 | /spec/reports/ 10 | /tmp/ 11 | zz_debug_spec.rb 12 | -------------------------------------------------------------------------------- /bin/bundler-patch: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'rubygems' 4 | 5 | lib_dir = File.expand_path(File.join(File.dirname(__FILE__),'..','lib')) 6 | $LOAD_PATH << lib_dir unless $LOAD_PATH.include?(lib_dir) 7 | 8 | require 'bundler/patch' 9 | 10 | Bundler::Patch::CLI.execute 11 | -------------------------------------------------------------------------------- /spec/bundler/unit/bundler_version_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe 'Bundler version installed' do 4 | it 'should be correct on Travis CI' do 5 | if ENV['TRAVIS'] && ENV['BUNDLER_TEST_VERSION'] != 'latest' 6 | Bundler::VERSION.should == ENV['BUNDLER_TEST_VERSION'] 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "bundler/patch" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | require "pry" 11 | Pry.start 12 | -------------------------------------------------------------------------------- /lib/bundler/patch.rb: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | 3 | module Bundler 4 | module Patch 5 | end 6 | end 7 | 8 | require 'bundler/patch/target_bundle' 9 | require 'bundler/patch/updater' 10 | require 'bundler/patch/gemfile' 11 | require 'bundler/patch/ruby_version' 12 | require 'bundler/patch/advisory_consolidator' 13 | require 'bundler/patch/conservative_definition' 14 | require 'bundler/patch/conservative_resolver' 15 | require 'bundler/patch/gems_to_patch_reconciler' 16 | require 'bundler/patch/cli_options' 17 | require 'bundler/patch/cli' 18 | require 'bundler/patch/version' 19 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | 3 | task :default => 'test:unit' 4 | task :test => 'test:unit' 5 | 6 | namespace :test do 7 | desc 'Run all RSpec code examples' 8 | RSpec::Core::RakeTask.new(:all) 9 | 10 | desc 'Run RSpec unit code examples' 11 | RSpec::Core::RakeTask.new(:unit) do |t| 12 | t.pattern = 'spec/bundler/unit/**{,/*/**}/*_spec.rb' 13 | end 14 | 15 | desc 'Run RSpec integration code examples' 16 | RSpec::Core::RakeTask.new(:integration) do |t| 17 | t.pattern = 'spec/bundler/integration/**{,/*/**}/*_spec.rb' 18 | end 19 | end 20 | 21 | require 'bundler/gem_tasks' 22 | 23 | -------------------------------------------------------------------------------- /lib/bundler/patch/gems_to_patch_reconciler.rb: -------------------------------------------------------------------------------- 1 | class GemsToPatchReconciler 2 | attr_reader :reconciled_patches 3 | 4 | def initialize(vulnerable_patches, requested_patches=[]) 5 | @vulnerable_patches = vulnerable_patches 6 | @requested_patches = requested_patches 7 | reconcile 8 | end 9 | 10 | private 11 | 12 | def reconcile 13 | @reconciled_patches = [] 14 | unless @requested_patches.empty? 15 | @vulnerable_patches.reject! { |gp| !@requested_patches.include?(gp) } 16 | @reconciled_patches.push(*((@vulnerable_patches + @requested_patches).uniq)) 17 | end 18 | end 19 | end 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /lib/bundler/patch/ruby_version.rb: -------------------------------------------------------------------------------- 1 | module Bundler::Patch 2 | class RubyVersion < UpdateSpec 3 | RUBY_VERSION_LINE_REGEXPS = [/ruby\s+["'](.*)['"]/] 4 | 5 | def self.files 6 | @files ||= { 7 | '.ruby-version' => [/.*/] 8 | } 9 | end 10 | 11 | def initialize(target_bundle: TargetBundle.new, patched_versions: []) 12 | super(target_file: target_bundle.gemfile, 13 | target_dir: target_bundle.dir, 14 | regexes: regexes, 15 | patched_versions: patched_versions) 16 | end 17 | 18 | def update 19 | hash = self.class.files.dup 20 | hash[@target_file.dup] = RUBY_VERSION_LINE_REGEXPS 21 | hash.each_pair do |file, regexes| 22 | @target_file = file 23 | @regexes = regexes 24 | file_replace 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 LivingSocial 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/bundler/unit/gems_to_patch_reconciler_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe GemsToPatchReconciler do 4 | def names_to_patches(names) 5 | names.map { |n| GemPatch.new(gem_name: n) } 6 | end 7 | 8 | def reconciler(vuln_names, requested_names=[]) 9 | @vulnerable_patches = names_to_patches(Array(vuln_names)) 10 | GemsToPatchReconciler.new(@vulnerable_patches, names_to_patches(Array(requested_names))) 11 | end 12 | 13 | it 'should do nothing if nothing requested' do 14 | r = reconciler('foo') 15 | r.reconciled_patches.length.should == 0 16 | # empty will signal to Bundler to update _all_ 17 | end 18 | 19 | it 'should not include non-requested vulnerable gems' do 20 | r = reconciler('foo', 'bar') 21 | r.reconciled_patches.length.should == 1 22 | r.reconciled_patches.first.gem_name.should == 'bar' 23 | 24 | @vulnerable_patches.length.should == 0 25 | end 26 | 27 | it 'should include requested vulnerable gems' do 28 | r = reconciler('foo', %w(foo bar)) 29 | r.reconciled_patches.length.should == 2 30 | r.reconciled_patches.first.gem_name.should == 'foo' 31 | r.reconciled_patches.last.gem_name.should == 'bar' 32 | 33 | @vulnerable_patches.length.should == 1 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /bundler-patch.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'bundler/patch/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'bundler-patch' 8 | spec.version = Bundler::Patch::VERSION 9 | spec.authors = ['chrismo'] 10 | spec.email = ['chrismo@clabs.org'] 11 | 12 | spec.summary = %q{Conservative bundler updates} 13 | # spec.description = '' 14 | spec.homepage = 'https://github.com/livingsocial/bundler-patch' 15 | spec.license = 'MIT' 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = 'bin' 19 | spec.executables = ['bundler-patch'] 20 | spec.require_paths = ['lib'] 21 | 22 | spec.add_dependency 'bundler-advise', '~> 1.1', '>= 1.1.5' 23 | spec.add_dependency 'slop', '~> 3.0' 24 | spec.add_dependency 'bundler', '>= 1.11.0' 25 | 26 | spec.add_development_dependency 'bundler-fixture', '~> 1.6' 27 | spec.add_development_dependency 'pry' 28 | spec.add_development_dependency 'rake', '~> 10.0' 29 | spec.add_development_dependency 'rspec', '~> 3.5' 30 | spec.add_development_dependency 'rubocop' 31 | end 32 | -------------------------------------------------------------------------------- /spec/fixture/gemfile_fixture.rb: -------------------------------------------------------------------------------- 1 | class GemfileLockFixture 2 | def self.create(dir:, gems: {}, locks: nil, gemfile: 'Gemfile', ruby_version: nil) 3 | glf = self.new(dir: dir, gems: gems, locks: locks, gemfile: gemfile, ruby_version: ruby_version) 4 | glf.create_gemfile 5 | glf.create_lockfile 6 | 7 | if block_given? 8 | Dir.chdir dir do 9 | yield dir 10 | end 11 | end 12 | 13 | glf.bundler_fixture 14 | end 15 | 16 | attr_reader :bundler_fixture 17 | 18 | def initialize(dir:, gems: {}, locks: nil, gemfile: 'Gemfile', ruby_version: nil) 19 | @dir = dir 20 | @gems = gems 21 | @locks = locks 22 | @gemfile = gemfile 23 | @ruby_version = ruby_version 24 | @bundler_fixture = BundlerFixture.new(dir: @dir, gemfile: @gemfile) 25 | end 26 | 27 | def create_gemfile 28 | deps = @gems.map { |name, version| @bundler_fixture.create_dependency(name.to_s, version) } 29 | @bundler_fixture.create_gemfile(gem_dependencies: deps, ruby_version: @ruby_version) 30 | end 31 | 32 | def create_lockfile 33 | locks_or_gems = (@locks || @gems).map { |name, version| @bundler_fixture.create_dependency(name.to_s, version) } 34 | @bundler_fixture.create_lockfile(gem_dependencies: locks_or_gems, ruby_version: @ruby_version) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/bundler/patch/cli_options.rb: -------------------------------------------------------------------------------- 1 | module Bundler::Patch 2 | class CLI 3 | class Options 4 | def normalize_options(options) 5 | map = {:prefer_minimal => :minimal, :strict_updates => :strict, :minor_preferred => :minor} 6 | {}.tap do |target| 7 | options.each_pair do |k, v| 8 | new_key = k.to_s.gsub('-', '_').to_sym 9 | new_key = map[new_key] || new_key 10 | target[new_key] ||= v 11 | end 12 | process_gemfile_option(target) 13 | end 14 | end 15 | 16 | private 17 | 18 | def process_gemfile_option(options) 19 | # copy/pasta from Bundler 20 | custom_gemfile = options[:gemfile] || Bundler.settings[:gemfile] 21 | if custom_gemfile && !custom_gemfile.empty? 22 | custom_gemfile = File.join(custom_gemfile, TargetBundle.default_gemfile) if File.directory?(custom_gemfile) 23 | ENV['BUNDLE_GEMFILE'] = File.expand_path(custom_gemfile) 24 | dir, gemfile = [File.dirname(custom_gemfile), File.basename(custom_gemfile)] 25 | target_bundle = TargetBundle.new(dir: dir, gemfile: gemfile) 26 | options[:target] = target_bundle 27 | else 28 | options[:target] = TargetBundle.new 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | 3 | RSpec.configure do |config| 4 | config.expect_with(:rspec) { |c| c.syntax = :should } 5 | end 6 | 7 | require 'bundler/patch' 8 | 9 | include Bundler::Patch 10 | 11 | require 'bundler/fixture' 12 | require_relative './fixture/gemfile_fixture' 13 | 14 | def bundler_1_13? 15 | Gem::Version.new(Bundler::VERSION) >= Gem::Version.new('1.13.0.rc.2') 16 | end 17 | 18 | class BundlerFixture 19 | def gemfile_contents 20 | File.read(gemfile_filename) 21 | end 22 | 23 | def lockfile_spec_version(gem_name) 24 | parsed_lockfile_spec(gem_name).version.to_s 25 | end 26 | end 27 | 28 | def with_clean_env 29 | Bundler.with_clean_env do 30 | ENV['GEM_PATH'] = nil if ENV['GEM_PATH'] == '' # bug fix for clean_env? 31 | yield 32 | end 33 | end 34 | 35 | def bundler_patch(options) 36 | exec = File.expand_path('../bin/bundler-patch', __dir__) 37 | opts = options.map do |k, v| 38 | if k == :gems_to_update 39 | next 40 | elsif v.class == TrueClass 41 | "--#{k}" 42 | else 43 | "--#{k} #{v}" 44 | end 45 | end.join(' ') 46 | cmd = "#{exec} #{opts} #{options[:gems_to_update].join(' ')}" 47 | puts '* test shell_command' 48 | result = shell_command(cmd) 49 | result[:stdout].tap { |o| puts o unless ENV['BP_DEBUG'] } 50 | end 51 | -------------------------------------------------------------------------------- /spec/bundler/unit/updater_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | require 'fileutils' 4 | 5 | describe UpdateSpec do 6 | describe 'calc_new_version' do 7 | describe 'ruby versions' do 8 | before do 9 | patched_versions = %w(1.9.3-p550 2.1.4 ruby-2.1.4-p265 jruby-1.7.16.1) 10 | @u = UpdateSpec.new(patched_versions: patched_versions) 11 | end 12 | 13 | it 'ruby versions' do 14 | @u.calc_new_version('1.8').should == '1.9.3-p550' 15 | @u.calc_new_version('1.9').should == '1.9.3-p550' 16 | @u.calc_new_version('1.9.3-p484').should == '1.9.3-p550' 17 | @u.calc_new_version('2.0.0-p95').should == '2.1.4' 18 | @u.calc_new_version('2').should == '2.1.4' 19 | @u.calc_new_version('2.1.2').should == '2.1.4' 20 | @u.calc_new_version('2.1.2-p95').should == '2.1.4' 21 | @u.calc_new_version('jruby-1.7').should == 'jruby-1.7.16.1' 22 | @u.calc_new_version('jruby-1.6.5').should == 'jruby-1.7.16.1' 23 | @u.calc_new_version('1.7').should == '1.9.3-p550' 24 | @u.calc_new_version('ruby-2.1.2-p95').should == 'ruby-2.1.4-p265' 25 | @u.calc_new_version('ruby-2.1.2-p0').should == 'ruby-2.1.4-p265' 26 | @u.calc_new_version('ruby-2.1.2').should == 'ruby-2.1.4-p265' 27 | end 28 | end 29 | 30 | describe 'gem versions' do 31 | it 'should stay put on major version upgrade' do 32 | # major version should mean breaking changes, so don't do it. 33 | @u = UpdateSpec.new(patched_versions: %w(3.1.1)) 34 | @u.calc_new_version('2.4').should == nil 35 | end 36 | end 37 | end 38 | 39 | it 'should not dump output on test run' 40 | end 41 | -------------------------------------------------------------------------------- /spec/bundler/unit/cli_options_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe Bundler::Patch::CLI::Options do 4 | context 'normalize_options' do 5 | def normalized_should_eql(actual, expected) 6 | actual = actual.delete_if { |k, _| k == :target } 7 | actual.should == expected 8 | end 9 | 10 | it 'should support hyphen and underscore options equally' do 11 | opts = {:'hyphen-ated' => 1, 'string' => 1, :symbol => 1, :under_score => 1} 12 | norm = Bundler::Patch::CLI::Options.new.normalize_options(opts) 13 | normalized_should_eql(norm, {:hyphen_ated => 1, :string => 1, :symbol => 1, :under_score => 1}) 14 | end 15 | 16 | it 'should not blow away an earlier setting' do 17 | opts = {:'a-b' => 1, :a_b => nil} 18 | norm = Bundler::Patch::CLI::Options.new.normalize_options(opts) 19 | normalized_should_eql(norm, {:a_b => 1}) 20 | end 21 | 22 | it 'should map old names to new names' do 23 | opts = {:prefer_minimal => true, :minor_preferred => true, :strict_updates => true} 24 | norm = Bundler::Patch::CLI::Options.new.normalize_options(opts) 25 | normalized_should_eql(norm, {:minimal => true, :minor => true, :strict => true}) 26 | end 27 | end 28 | 29 | context 'target bundle' do 30 | before do 31 | @tmp_dir = File.join(__dir__, 'fixture') 32 | FileUtils.makedirs @tmp_dir 33 | end 34 | 35 | after do 36 | FileUtils.rmtree(@tmp_dir) 37 | end 38 | 39 | it 'should detect having a directory passed and compensate with default Gemfile name' do 40 | bf = BundlerFixture.new(dir: @tmp_dir) 41 | bf.create_gemfile(gem_dependencies: bf.create_dependency('rack', '~> 1.0')) 42 | 43 | opts = {:gemfile => @tmp_dir} 44 | norm = Bundler::Patch::CLI::Options.new.normalize_options(opts) 45 | norm[:target].dir.should == @tmp_dir 46 | norm[:target].gemfile.should == 'Gemfile' 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | before_install: 4 | - curl -sSl https://raw.githubusercontent.com/chrismo/bundler-fixture/master/only-bundler.sh | bash -s ${BUNDLER_TEST_VERSION} ${RUBYGEMS_VERSION} 5 | - gem env 6 | 7 | script: bundle exec rake test:all 8 | 9 | # Lookup RubyGems version installed with Ruby at https://github.com/ruby/ruby/blob/ruby_2_4/lib/rubygems.rb 10 | matrix: 11 | include: 12 | - rvm: 2.3.8 13 | env: 14 | - RUBYGEMS_VERSION=2.5.2 15 | - BUNDLER_TEST_VERSION=1.11.2 16 | - BP_DEBUG=1 17 | - rvm: 2.3.8 18 | env: 19 | - RUBYGEMS_VERSION=2.5.2 20 | - BUNDLER_TEST_VERSION=1.12.5 21 | - BP_DEBUG=1 22 | - rvm: 2.4.5 23 | env: 24 | - RUBYGEMS_VERSION=2.6.14 25 | - BUNDLER_TEST_VERSION=1.13.6 26 | - BP_DEBUG=1 27 | - rvm: 2.4.5 28 | env: 29 | - RUBYGEMS_VERSION=2.6.14 30 | - BUNDLER_TEST_VERSION=1.14.6 31 | - BP_DEBUG=1 32 | - rvm: 2.5.3 33 | env: 34 | - RUBYGEMS_VERSION=2.7.6 35 | - BUNDLER_TEST_VERSION=1.15.4 36 | - BP_DEBUG=1 37 | - rvm: 2.5.3 38 | env: 39 | - RUBYGEMS_VERSION=2.7.6 40 | - BUNDLER_TEST_VERSION=1.16.6 41 | - BP_DEBUG=1 42 | - rvm: 2.3.8 43 | env: 44 | - RUBYGEMS_VERSION=2.5.2 45 | - BUNDLER_TEST_VERSION=1.17.3 46 | - BP_DEBUG=1 47 | - rvm: 2.4.5 48 | env: 49 | - RUBYGEMS_VERSION=2.6.14 50 | - BUNDLER_TEST_VERSION=1.17.3 51 | - BP_DEBUG=1 52 | - rvm: 2.5.3 53 | env: 54 | - RUBYGEMS_VERSION=2.7.6 55 | - BUNDLER_TEST_VERSION=1.17.3 56 | - BP_DEBUG=1 57 | - rvm: 2.6.0 58 | env: 59 | - RUBYGEMS_VERSION=3.0.1 60 | - BUNDLER_TEST_VERSION=1.17.3 61 | - BP_DEBUG=1 62 | - rvm: 2.6.0 63 | env: 64 | - RUBYGEMS_VERSION=latest 65 | - BUNDLER_TEST_VERSION=1.17.3 66 | - BP_DEBUG=1 67 | - rvm: 2.5.3 68 | env: 69 | - RUBYGEMS_VERSION=latest 70 | - BUNDLER_TEST_VERSION=latest 71 | - BP_DEBUG=1 72 | - rvm: 2.6.0 73 | env: 74 | - RUBYGEMS_VERSION=latest 75 | - BUNDLER_TEST_VERSION=latest 76 | - BP_DEBUG=1 77 | -------------------------------------------------------------------------------- /lib/bundler/patch/updater.rb: -------------------------------------------------------------------------------- 1 | module Bundler::Patch 2 | class UpdateSpec 3 | attr_accessor :target_file, :target_dir, :regexes, :patched_versions 4 | 5 | def initialize(target_file: '', 6 | target_dir: Dir.pwd, 7 | regexes: [/.*/], 8 | patched_versions: []) 9 | @target_file = target_file 10 | @target_dir = target_dir 11 | @regexes = regexes 12 | @patched_versions = patched_versions 13 | end 14 | 15 | def target_path_fn 16 | File.expand_path(File.join(@target_dir, @target_file)) 17 | end 18 | 19 | def calc_new_version(old_version) 20 | old = old_version 21 | all = @patched_versions.dup 22 | return old_version if all.include?(old) 23 | 24 | all << old 25 | all.sort! 26 | all.delete_if { |v| v.split(/\./).first != old.split(/\./).first } # strip non-matching major revs 27 | all[all.index(old) + 1] 28 | end 29 | 30 | def file_replace 31 | filename = target_path_fn 32 | unless File.exist?(filename) 33 | puts "Cannot find #{filename}" 34 | return 35 | end 36 | 37 | guts = File.read(filename) 38 | any_changes = false 39 | [@regexes].flatten.each do |re| 40 | any_changes = guts.gsub!(re) do |match| 41 | if block_given? 42 | yield match, re 43 | else 44 | update_to_new_version(match, re) 45 | end 46 | end || any_changes 47 | end 48 | 49 | if any_changes 50 | File.open(filename, 'w') { |f| f.print guts } 51 | verbose_puts "Updated #{filename}" 52 | else 53 | verbose_puts "No changes for #{filename}" 54 | end 55 | end 56 | 57 | def update_to_new_version(match, re) 58 | current_version = match.scan(re).join 59 | new_version = calc_new_version(current_version) 60 | if new_version 61 | match.sub(current_version, new_version).tap { |s| puts "Updating to #{s}" } 62 | else 63 | match 64 | end 65 | end 66 | 67 | alias_method :update, :file_replace 68 | 69 | def verbose_puts(text) 70 | puts text if @verbose 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/bundler/unit/ruby_version_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe RubyVersion do 4 | after do 5 | FileUtils.rmtree(File.join(__dir__, 'fixture')) 6 | end 7 | 8 | def patched_versions 9 | %w(1.9.3-p550 2.1.4 ruby-2.1.4-p265 jruby-1.7.16.1) 10 | end 11 | 12 | def setup_subject(filename: nil, gemfile: 'Gemfile', template: '$version') 13 | dirs = %w(./fixture/1_9 ./fixture/2_1 ./fixture/java_1_7) 14 | old = %w(1.9.3-p484 2.1.2 jruby-1.7.16) 15 | 16 | specs = dirs.map do |dir| 17 | target_bundle = TargetBundle.new(dir: File.join(__dir__, dir), gemfile: gemfile) 18 | Bundler::Patch::RubyVersion.new(target_bundle: target_bundle, patched_versions: patched_versions) 19 | end.flatten 20 | 21 | dirs.each_with_index do |dir, i| 22 | dir = File.join(__dir__, dir) 23 | fn = File.join(dir, filename || gemfile) 24 | FileUtils.makedirs dir 25 | File.open(fn, 'w') { |f| f.puts template.gsub(/\$version/, old[i]) } 26 | end 27 | 28 | specs 29 | end 30 | 31 | it 'should update ruby version files in different dirs' do 32 | dirs = setup_subject(filename: '.ruby-version') 33 | 34 | dirs.map(&:update) 35 | 36 | read_spec_contents(dirs[0], '.ruby-version').should == '1.9.3-p550' 37 | read_spec_contents(dirs[1], '.ruby-version').should == '2.1.4' 38 | read_spec_contents(dirs[2], '.ruby-version').should == 'jruby-1.7.16.1' 39 | end 40 | 41 | it 'should update Gemfile' do 42 | dirs = setup_subject(gemfile: 'Gemfile', template: "ruby '$version'") 43 | 44 | dirs.map(&:update) 45 | 46 | read_spec_contents(dirs[0], 'Gemfile').should == "ruby '1.9.3-p550'" 47 | read_spec_contents(dirs[1], 'Gemfile').should == "ruby '2.1.4'" 48 | read_spec_contents(dirs[2], 'Gemfile').should == "ruby 'jruby-1.7.16.1'" 49 | end 50 | 51 | it 'should update gems.rb' do 52 | dirs = setup_subject(gemfile: 'gems.rb', template: "ruby '$version'") 53 | 54 | dirs.map(&:update) 55 | 56 | read_spec_contents(dirs[0], 'gems.rb').should == "ruby '1.9.3-p550'" 57 | read_spec_contents(dirs[1], 'gems.rb').should == "ruby '2.1.4'" 58 | read_spec_contents(dirs[2], 'gems.rb').should == "ruby 'jruby-1.7.16.1'" 59 | end 60 | 61 | def read_spec_contents(spec, filename) 62 | File.read(File.join(spec.target_dir, filename)).chomp 63 | end 64 | 65 | it 'should support custom file replacement definitions' do 66 | Bundler::Patch::RubyVersion.files['foo'] = 'bar' 67 | Bundler::Patch::RubyVersion.files['foo'].should == 'bar' 68 | end 69 | 70 | it 'should not blow up if no new version is found - dump warning?' 71 | end 72 | -------------------------------------------------------------------------------- /lib/bundler/patch/gem_version_patch_promoter.rb: -------------------------------------------------------------------------------- 1 | module Bundler::Patch 2 | class GemVersionPatchPromoter < Bundler::GemVersionPromoter 3 | attr_accessor :minimal, :gems_to_update 4 | 5 | private 6 | 7 | def sort_dep_specs(spec_groups, locked_spec) 8 | result = super(spec_groups, locked_spec) 9 | return result unless locked_spec 10 | 11 | @gem_name = locked_spec.name 12 | @locked_version = locked_spec.version 13 | gem_patch = @gems_to_update.gem_patch_for(@gem_name) 14 | @new_version = gem_patch ? gem_patch.new_version : nil 15 | 16 | return result unless @minimal || @new_version 17 | 18 | # STDERR.puts "during sort_versions: #{debug_format_result(spec_groups.first.first.name, result).inspect}" if ENV["DEBUG_RESOLVER"] 19 | 20 | # Custom sort_by-ish behavior to minimize index calls. 21 | result = result.map { |a| [result.index(a), a] }.sort do |(a_index, a), (b_index, b)| 22 | @a_ver = a.version 23 | @b_ver = b.version 24 | case 25 | when @minimal && unlocking_gem? && 26 | (neither_version_matches(@locked_version) && 27 | (!@new_version || both_versions_gt_or_equal_to_version(@new_version))) 28 | @b_ver <=> @a_ver 29 | else 30 | a_index <=> b_index # no change in current ordering 31 | end 32 | end.map { |a| a.last } 33 | 34 | post_sort(result) 35 | end 36 | 37 | def unlocking_gem? 38 | @gems_to_update.unlocking_gem?(@gem_name) 39 | end 40 | 41 | def one_version_matches(match_version) 42 | [@a_ver, @b_ver].include?(match_version) 43 | end 44 | 45 | def neither_version_matches(match_version) 46 | !one_version_matches(match_version) 47 | end 48 | 49 | def both_versions_gt_or_equal_to_version(version) 50 | version && @a_ver >= version && @b_ver >= version 51 | end 52 | 53 | # Sorting won't work properly for some specific arrangements to the end of the list because not 54 | # all versions are compared in quicksort and the result isn't deterministic. 55 | def post_sort(result) 56 | result = super(result) 57 | 58 | if @new_version && unlocking_gem? && segments_match(:major, @new_version, @locked_version) 59 | if @minimal || (!@minimal && result.last.version < @new_version) 60 | # This handles two cases: 61 | # - minimal doesn't want to go past requested new_version 62 | # - new_version is up a minor rev but level is :patch 63 | result = move_version_to_end(result, @new_version) 64 | end 65 | end 66 | 67 | result 68 | end 69 | 70 | def segments_match(level, a_ver, b_ver) 71 | index = [:major, :minor].index(level) 72 | a_ver.segments[index] == b_ver.segments[index] 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /spec/bundler/unit/advisory_consolidator_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe AdvisoryConsolidator do 4 | before do 5 | @bf = BundlerFixture.new 6 | @inc = 1 7 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 8 | end 9 | 10 | after do 11 | ENV['BUNDLE_GEMFILE'] = nil 12 | @bf.clean_up 13 | end 14 | 15 | def add_fake_advisory(gem:, patched_versions:) 16 | ad = Bundler::Advise::Advisory.new(gem: gem, patched_versions: patched_versions) 17 | gem_dir = File.join(@bf.dir, 'gems', gem) 18 | FileUtils.makedirs gem_dir 19 | File.open(File.join(gem_dir, "#{gem}-patch-#{@inc += 1}.yml"), 'w') { |f| f.print ad.to_yaml } 20 | end 21 | 22 | def all_ads 23 | [Bundler::Advise::Advisories.new(dir: @bf.dir, repo: nil)] 24 | end 25 | 26 | context 'advisory consolidator' do 27 | it 'should consolidate multiple advisories for same gem' do 28 | # rack has multiple advisories that if applied in a default 29 | # sequential order leave the gem on an insecure version. 30 | 31 | Dir.chdir(@bf.dir) do 32 | [ 33 | ['~> 1.1.6', '~> 1.2.8', '~> 1.3.10', '~> 1.4.5', '>= 1.5.2'], 34 | ['~> 1.4.5', '>= 1.5.2'], 35 | ['>= 1.6.2', '~> 1.5.4', '~> 1.4.6'] 36 | ].each do |patch_group| 37 | add_fake_advisory(gem: 'rack', patched_versions: patch_group) 38 | end 39 | 40 | GemfileLockFixture.create(dir: @bf.dir, gems: {rack: '1.4.4'}) 41 | 42 | ac = AdvisoryConsolidator.new({}, all_ads) 43 | res = ac.vulnerable_gems 44 | res.first.patched_versions.should == %w(1.1.6 1.2.8 1.3.10 1.4.6 1.5.4 1.6.2) 45 | res.length.should == 1 46 | end 47 | end 48 | 49 | it 'should cope with a disallowed major version increment appropriately' do 50 | Dir.chdir(@bf.dir) do 51 | add_fake_advisory(gem: 'foo', patched_versions: ['>= 3.2.0']) 52 | 53 | GemfileLockFixture.create(dir: @bf.dir, gems: {foo: '2.2.8'}) 54 | 55 | ac = AdvisoryConsolidator.new({}, all_ads) 56 | gem_patches = ac.patch_gemfile_and_get_gem_specs_to_patch 57 | gem_patches.length.should == 1 58 | gp = gem_patches.first 59 | gp.gem_name.should == 'foo' 60 | gp.old_version.to_s.should == '2.2.8' 61 | gp.patched_versions.should == ['3.2.0'] 62 | end 63 | end 64 | end 65 | 66 | context 'GemPatch' do 67 | it 'should be equal if gem_name matches' do 68 | (GemPatch.new(gem_name: 'foo') == GemPatch.new(gem_name: 'foo')).should be true 69 | end 70 | 71 | it 'should not be equal if gem_name does not match' do 72 | GemPatch.new(gem_name: 'foo').should_not == GemPatch.new(gem_name: 'bar') 73 | end 74 | 75 | it 'should be uniq-able' do 76 | [GemPatch.new(gem_name: 'foo'), GemPatch.new(gem_name: 'foo')].uniq.length.should == 1 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/bundler/patch/advisory_consolidator.rb: -------------------------------------------------------------------------------- 1 | module Bundler::Patch 2 | class AdvisoryConsolidator 3 | def initialize(options={}, all_ads=nil) 4 | @options = options 5 | @all_ads = all_ads || [].tap do |a| 6 | unless options[:skip_bundler_advise] 7 | if options[:ruby_advisory_db_path] 8 | a << Bundler::Advise::Advisories.new(dir: options[:ruby_advisory_db_path]) 9 | else 10 | a << Bundler::Advise::Advisories.new # annoying 11 | end 12 | end 13 | a << Bundler::Advise::Advisories.new(dir: options[:advisory_db_path], repo: nil) if options[:advisory_db_path] 14 | end 15 | end 16 | 17 | def vulnerable_gems 18 | @all_ads.map do |ads| 19 | ads.update if ads.repo 20 | File.exist?(Bundler.default_lockfile) ? Bundler::Advise::GemAdviser.new(advisories: ads).scan_lockfile : [] 21 | end.flatten.map do |advisory| 22 | patched = advisory.patched_versions.map do |pv| 23 | # this is a little stupid for compound requirements, but works itself out in consolidate_gemfiles 24 | pv.requirements.map { |_, v| v.to_s } 25 | end.flatten 26 | Gemfile.new(gem_name: advisory.gem, patched_versions: patched) 27 | end.group_by do |gemfile| 28 | gemfile.gem_name 29 | end.map do |_, gemfiles| 30 | consolidate_gemfiles(gemfiles) 31 | end.flatten 32 | end 33 | 34 | def patch_gemfile_and_get_gem_specs_to_patch 35 | gem_update_specs = vulnerable_gems 36 | locked = File.exist?(Bundler.default_lockfile) ? 37 | Bundler::LockfileParser.new(Bundler.read_file(Bundler.default_lockfile)).specs : [] 38 | 39 | gem_update_specs.map(&:update) # modify requirements in Gemfile if necessary 40 | 41 | gem_update_specs.map do |up_spec| 42 | old_version = locked.detect { |s| s.name == up_spec.gem_name }.version.to_s 43 | new_version = up_spec.calc_new_version(old_version) 44 | if new_version 45 | GemPatch.new(gem_name: up_spec.gem_name, old_version: old_version, 46 | new_version: new_version, patched_versions: up_spec.patched_versions) 47 | else 48 | GemPatch.new(gem_name: up_spec.gem_name, old_version: old_version, patched_versions: up_spec.patched_versions) 49 | end 50 | end 51 | end 52 | 53 | private 54 | 55 | def consolidate_gemfiles(gemfiles) 56 | gemfiles if gemfiles.length == 1 57 | all_gem_names = gemfiles.map(&:gem_name).uniq 58 | raise 'Must be all same gem name' unless all_gem_names.length == 1 59 | highest_minor_patched = gemfiles.map do |g| 60 | g.patched_versions 61 | end.flatten.group_by do |v| 62 | Gem::Version.new(v).segments[0..1].join('.') 63 | end.map do |_, all| 64 | all.sort.last 65 | end 66 | Gemfile.new(target_bundle: @options[:target] || TargetBundle.new, 67 | gem_name: all_gem_names.first, patched_versions: highest_minor_patched) 68 | end 69 | end 70 | 71 | class GemPatch 72 | include Comparable 73 | 74 | # TODO: requested_version is better name than new_version? 75 | attr_reader :gem_name, :old_version, :new_version, :patched_versions 76 | 77 | def initialize(gem_name:, old_version: nil, new_version: nil, patched_versions: nil) 78 | @gem_name = gem_name 79 | @old_version = Gem::Version.new(old_version) if old_version 80 | @new_version = Gem::Version.new(new_version) if new_version 81 | @patched_versions = patched_versions 82 | end 83 | 84 | def <=>(other) 85 | self.gem_name <=> other.gem_name 86 | end 87 | 88 | def hash 89 | @gem_name.hash 90 | end 91 | 92 | def eql?(other) 93 | @gem_name.eql?(other.gem_name) 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/bundler/patch/gemfile.rb: -------------------------------------------------------------------------------- 1 | module Bundler::Patch 2 | class Gemfile < UpdateSpec 3 | attr_reader :gem_name 4 | 5 | def initialize(target_bundle: TargetBundle.new, 6 | gem_name:, 7 | patched_versions: []) 8 | super(target_file: target_bundle.gemfile, 9 | target_dir: target_bundle.dir, 10 | patched_versions: patched_versions) 11 | @gem_name = gem_name 12 | end 13 | 14 | def to_s 15 | "#{@gem_name} #{patched_versions}" 16 | end 17 | 18 | def update 19 | # Bundler evals the whole Gemfile in Bundler::Dsl.evaluate 20 | # It has a few magics to parse all possible calls to `gem` 21 | # command. It doesn't have anything to output the entire 22 | # Gemfile, I don't think it ever does that. (There is code 23 | # to init a Gemfile from a gemspec, but it doesn't look 24 | # like it's intended to recreate one just evaled - I don't 25 | # see any code that would handle additional sources or 26 | # groups - see lib/bundler/rubygems_ext.rb #to_gemfile). 27 | # 28 | # So without something in Bundler that round-trips from 29 | # Gemfile back to disk and maintains integrity, then we 30 | # couldn't re-use it to make modifications to the Gemfile 31 | # like we'd want to, so we'll do this ourselves. 32 | # 33 | # We'll still instance_eval the gem line though, to properly 34 | # handle the various options and possible multiple reqs. 35 | @regexes = /^\s*gem.*['"]\s*#{@gem_name}\s*['"].*$/ 36 | file_replace do |match, re| 37 | update_to_new_gem_version(match) 38 | end 39 | end 40 | 41 | def update_to_new_gem_version(match) 42 | dep = instance_eval(match) 43 | req = dep.requirement 44 | 45 | prefix = req.exact? ? '' : req.specific? ? '~> ' : '>= ' 46 | 47 | current_version = req.requirements.first.last.to_s 48 | new_version = calc_new_version(current_version) 49 | 50 | # return match if req.satisfied_by?(Gem::Version.new(new_version)) 51 | # 52 | # TODO: This ^^ could be acceptable, slightly less complex, to not ever 53 | # touch the requirement if it already covers the patched version. 54 | # But I can't recall Bundler behavior in all these cases, to ensure 55 | # at least the patch version is updated and/or we would like to be as 56 | # conservative as possible in updating - can't recall how much influence 57 | # we have over `bundle update` (not much) 58 | 59 | return match if req.compound? && req.satisfied_by?(Gem::Version.new(new_version)) 60 | 61 | if new_version && prefix =~ /~/ 62 | # could Gem::Version#approximate_recommendation work here? 63 | 64 | # match segments. if started with ~> 1.2 and new_version is 3 segments, replace with 2 segments. 65 | count = current_version.split(/\./).length 66 | new_version = new_version.split(/\./)[0..(count-1)].join('.') 67 | end 68 | 69 | if new_version 70 | match.sub(requirements_args_regexp, " '#{prefix}#{new_version}'").tap { |s| "Updating to #{s}" } 71 | else 72 | match 73 | end 74 | end 75 | 76 | private 77 | 78 | def requirements_args_regexp 79 | ops = Gem::Requirement::OPS.keys.join "|" 80 | /(\s*['\"]\s*(#{ops})?\s*#{Gem::Version::VERSION_PATTERN}\s*['"],*)+/ 81 | end 82 | 83 | # See Bundler::Dsl for reference 84 | def gem(name, *args) 85 | # we're not concerned with options here. 86 | _options = args.last.is_a?(Hash) ? args.pop.dup : {} 87 | version = args || ['>= 0'] 88 | 89 | # there is a normalize_options step that DOES involve 90 | # the args captured in version for `git` and `path` 91 | # sources that's skipped here ... need to dig into that 92 | # at some point. 93 | 94 | Gem::Dependency.new(name, version) 95 | end 96 | end 97 | end 98 | 99 | class Gem::Requirement 100 | def compound? 101 | @requirements.length > 1 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/bundler/integration/use_target_ruby_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | require 'tmpdir' 4 | 5 | describe 'integration tests' do 6 | before do 7 | @tmp_dir = File.expand_path('../../../tmp', __dir__) 8 | end 9 | 10 | after do 11 | FileUtils.remove_entry_secure(@tmp_dir) if File.exist?(@tmp_dir) 12 | end 13 | 14 | def gemfile_create(ruby_version) 15 | GemfileLockFixture.create(dir: @tmp_dir, ruby_version: ruby_version) do |fix_dir| 16 | yield fix_dir 17 | end 18 | end 19 | 20 | context 'use target ruby' do 21 | let(:other_ruby_version) do 22 | # The referenced Ruby version must be an installed version in the OS. If 23 | # you pick a Pre-installed Ruby version in Travis Build System Information 24 | # then you can kill two birds with one stone. 25 | '2.3.4' 26 | end 27 | 28 | it 'with same ruby no bundle config' do 29 | bf = GemfileLockFixture.create(dir: @tmp_dir, 30 | gems: {rack: nil, addressable: nil}, 31 | locks: {rack: '1.4.1', addressable: '2.1.1'}, 32 | ruby_version: RbConfig::CONFIG['RUBY_PROGRAM_VERSION']) 33 | 34 | with_clean_env do 35 | bundler_patch(gems_to_update: ['rack'], 36 | gemfile: File.join(@tmp_dir, 'Gemfile'), 37 | use_target_ruby: true) 38 | end 39 | 40 | bf.lockfile_spec_version('rack').should == '1.4.7' 41 | bf.lockfile_spec_version('addressable').should == '2.1.1' 42 | end 43 | 44 | it 'with different ruby no bundle config' do 45 | # Only the Gemfile is created here, with no lock file, because it won't work 46 | # in the fixture code to do the lock command against a declared older Ruby. 47 | glf = GemfileLockFixture.new(dir: @tmp_dir, 48 | gems: {rack: '~> 1.4.1', addressable: '2.1.1'}, 49 | ruby_version: other_ruby_version) 50 | glf.create_gemfile 51 | bf = glf.bundler_fixture 52 | 53 | output = nil 54 | with_clean_env do 55 | # Enable BP_DEBUG to troubleshoot the output 56 | output = bundler_patch(gems_to_update: ['rack'], 57 | gemfile: File.join(@tmp_dir, 'Gemfile'), 58 | use_target_ruby: true) 59 | end 60 | 61 | # If the lockfile can't be found, the prior command probably failed. Use 62 | # BP_DEBUG=1 to check it out. 63 | 64 | # There may be a Major version Bundler conflict in the target Ruby. 65 | # Checking the output should suffice. 66 | 67 | # bf.lockfile_spec_version('rack').should == '1.4.7' 68 | # bf.lockfile_spec_version('addressable').should == '2.1.1' 69 | 70 | output.should match /rack 1\.4\.7/ 71 | output.should match /addressable 2\.1\.1/ 72 | end 73 | 74 | it 'with different ruby bundle config install path' do 75 | # Only the Gemfile is created here, with no lock file, because it won't work 76 | # in the fixture code to do the lock command against a declared older Ruby. 77 | glf = GemfileLockFixture.new(dir: @tmp_dir, 78 | gems: {rack: '~> 1.4.1', addressable: '2.1.1'}, 79 | ruby_version: other_ruby_version) 80 | glf.create_gemfile 81 | bf = glf.bundler_fixture 82 | bf.create_config(path: 'local-path') 83 | 84 | output = nil 85 | with_clean_env do 86 | # Enable BP_DEBUG to troubleshoot the output 87 | output = bundler_patch(gems_to_update: ['rack'], 88 | gemfile: File.join(@tmp_dir, 'Gemfile'), 89 | use_target_ruby: true) 90 | end 91 | 92 | # There may be a Major version Bundler conflict in the target Ruby. 93 | # Checking the output should suffice. 94 | 95 | # bf.lockfile_spec_version('rack').should == '1.4.7' 96 | # bf.lockfile_spec_version('addressable').should == '2.1.1' 97 | 98 | output.should match /rack 1\.4\.7/ 99 | output.should match /addressable 2\.1\.1/ 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/bundler/patch/target_bundle.rb: -------------------------------------------------------------------------------- 1 | class TargetBundle 2 | attr_reader :dir, :gemfile 3 | 4 | def self.bundler_version_or_higher(version) 5 | version_greater_than_or_equal_to_other(Bundler::VERSION, version) 6 | end 7 | 8 | def self.version_greater_than_or_equal_to_other(a, b) 9 | Gem::Version.new(a) >= Gem::Version.new(b) 10 | end 11 | 12 | def self.default_gemfile 13 | # TODO: Make gems.rb default in Bundler 3.0. 14 | 'Gemfile' 15 | end 16 | 17 | def initialize(dir: Dir.pwd, gemfile: TargetBundle.default_gemfile) 18 | @dir = dir 19 | @gemfile = gemfile 20 | end 21 | 22 | # First, the version of Ruby itself: 23 | # 1. Look in the Gemfile/lockfile for ruby version 24 | # 2. Look for a .ruby-version file 25 | # 3. (An additional flag so user can specify?) 26 | # 27 | # Second, look bin path presuming version is in current path. 28 | def ruby_version 29 | result = if TargetBundle.bundler_version_or_higher('1.12.0') && File.exist?(lockfile_name) 30 | lockfile_parser = Bundler::LockfileParser.new(Bundler.read_file(lockfile_name)) 31 | lockfile_parser.ruby_version 32 | end 33 | 34 | result ||= if File.exist?(ruby_version_filename) 35 | File.read(File.join(@dir, '.ruby-version')).chomp 36 | elsif File.exist?(gemfile_name) 37 | Bundler::Definition.build(gemfile_name, lockfile_name, nil).ruby_version 38 | end 39 | 40 | result ||= RbConfig::CONFIG['RUBY_PROGRAM_VERSION'] 41 | 42 | version, patch_level = result.to_s.scan(/(\d+\.\d+\.\d+)(p\d+)*/).first 43 | patch_level ? "#{version}-#{patch_level}" : version 44 | end 45 | 46 | # This is hairy here. All the possible variants will make this mucky, but ... can 47 | # prolly get close enough in many circumstances. 48 | def ruby_bin(current_ruby_bin=RbConfig::CONFIG['bindir'], target_ruby_version=self.ruby_version) 49 | [ 50 | target_ruby_version, 51 | target_ruby_version.gsub(/-p\d+/, ''), 52 | "ruby-#{target_ruby_version}", 53 | "ruby-#{target_ruby_version.gsub(/-p\d+/, '')}" 54 | ].map do |ruby_ver| 55 | build_ruby_bin(current_ruby_bin, ruby_ver) 56 | end.detect do |ruby_ver| 57 | print "Looking for #{ruby_ver}... " if ENV['BP_DEBUG'] 58 | File.exist?(ruby_ver).tap { |exist| puts(exist ? 'found' : 'not found') if ENV['BP_DEBUG'] } 59 | end 60 | end 61 | 62 | def build_ruby_bin(current_ruby_bin, target_ruby_version) 63 | current_ruby_bin.split(File::SEPARATOR).reverse.map do |segment| 64 | if segment =~ /\d+\.\d+\.\d+/ 65 | segment.gsub(/(\d+\.\d+\.\d+)-*(p\d+)*/, target_ruby_version) 66 | else 67 | segment 68 | end 69 | end.reverse.join(File::SEPARATOR) 70 | end 71 | 72 | def ruby_bin_exe 73 | ruby_install_name = RbConfig::CONFIG['ruby_install_name'] 74 | exe_ext = RbConfig::CONFIG['EXEEXT'] 75 | File.join(ruby_bin, "#{ruby_install_name}#{exe_ext}") 76 | end 77 | 78 | def target_ruby_is_different? 79 | !(ruby_bin == RbConfig::CONFIG['bindir']) 80 | end 81 | 82 | def gem_home 83 | target_dir('Gem.default_dir') 84 | end 85 | 86 | def bin_dir 87 | target_dir('Gem.bindir') 88 | end 89 | 90 | # Have to run a separate process in the other Ruby, because Gem.default_dir 91 | # depends on RbConfig::CONFIG which is all special data derived from the 92 | # active runtime. It could perhaps be redone here, but I'd rather not copy 93 | # that code in here at the moment. 94 | # 95 | # At one point during development, this would execute Bundler::Settings#path, 96 | # which in most cases would just fall through to Gem.default_dir ... but would 97 | # give preference to GEM_HOME env variable, which could be in a different 98 | # Ruby, and that won't work. 99 | def target_dir(cmd) 100 | result = shell_command "#{ruby_bin_exe} -C#{@dir} -e 'puts #{cmd}'" 101 | path = result[:stdout].chomp 102 | expanded_path = Pathname.new(path).expand_path(@dir).to_s 103 | puts expanded_path if ENV['BP_DEBUG'] 104 | expanded_path 105 | end 106 | 107 | # To properly update another bundle, bundler-patch _does_ need to live in the 108 | # same Ruby version because of its _dependencies_ (it's not a self-contained 109 | # gem), and it can't both act on another bundle location AND find its own 110 | # dependencies in a separate bundle location. 111 | # 112 | # One known issue: older RubyGems in older Rubies don't install bundler-patch 113 | # bin in the right directory. Upgrading RubyGems fixes this. 114 | def install_bundler_patch_in_target 115 | # TODO: reconsider --conservative flag. Had problems with it in place on Travis, but I think I want it. 116 | # cmd = "#{ruby_bin}#{File::SEPARATOR}gem install -V --install-dir #{gem_home} --conservative --no-document --prerelease bundler-patch" 117 | cmd = "#{ruby_bin}#{File::SEPARATOR}gem install -V --install-dir #{gem_home} --no-document --prerelease bundler-patch" 118 | shell_command cmd 119 | end 120 | 121 | private 122 | 123 | def ruby_version_filename 124 | File.join(@dir, '.ruby-version') 125 | end 126 | 127 | def gemfile_name 128 | File.join(@dir, @gemfile) 129 | end 130 | 131 | def lockfile_name 132 | "#{gemfile_name}.lock" 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/bundler/patch/conservative_resolver.rb: -------------------------------------------------------------------------------- 1 | module Bundler::Patch 2 | class ConservativeResolver < Bundler::Resolver 3 | attr_accessor :locked_specs, :gems_to_update, :strict, :minor_preferred, :prefer_minimal 4 | 5 | def initialize(index, source_requirements, base) 6 | case Bundler::Resolver.instance_method(:initialize).arity 7 | when 3 # 1.9 1.10 8 | super(index, source_requirements, base) 9 | when 4 # 1.11 1.12 10 | super(index, source_requirements, base, nil) 11 | end 12 | end 13 | 14 | def search_for(dependency) 15 | res = super(dependency) 16 | 17 | dep = dependency.dep unless dependency.is_a? Gem::Dependency 18 | 19 | super_result = "super search_for: #{debug_format_result(dep, res).inspect}" 20 | 21 | @conservative_search_for ||= {} 22 | res = @conservative_search_for[dep] ||= begin 23 | gem_name = dep.name 24 | 25 | # An Array per version returned, different entries for different platforms. 26 | # We just need the version here so it's ok to hard code this to the first instance. 27 | locked_spec = @locked_specs[gem_name].first 28 | 29 | (@strict ? 30 | filter_specs(res, locked_spec) : 31 | sort_specs(res, locked_spec)).tap do |result| 32 | if ENV['DEBUG_PATCH_RESOLVER'] 33 | STDERR.puts super_result 34 | STDERR.puts "after search_for: #{debug_format_result(dep, result).inspect}" 35 | end 36 | end 37 | end 38 | 39 | # dup is important, in weird (large) cases Bundler will empty the result array corrupting the cache. 40 | # Bundler itself doesn't have this problem because the super search_for does a select on its cached 41 | # search results, effectively duping it. 42 | res.dup 43 | end 44 | 45 | def debug_format_result(dep, res) 46 | a = [dep.to_s, 47 | res.map { |sg| [sg.version, sg.dependencies_for_activated_platforms.map { |dp| [dp.name, dp.requirement.to_s] }] }] 48 | [a.first, a.last.map { |sg_data| [sg_data.first.version, sg_data.last.map { |aa| aa.join(' ') }] }] 49 | end 50 | 51 | def filter_specs(specs, locked_spec) 52 | res = specs.select do |sg| 53 | # SpecGroup is grouped by name/version, multiple entries for multiple platforms. 54 | # We only need the name, which will be the same, so hard coding to first is ok. 55 | gem_spec = sg.first 56 | 57 | if locked_spec 58 | gsv = gem_spec.version 59 | lsv = locked_spec.version 60 | 61 | must_match = @minor_preferred ? [0] : [0, 1] 62 | 63 | matches = must_match.map { |idx| gsv.segments[idx] == lsv.segments[idx] } 64 | (matches.uniq == [true]) ? gsv.send(:>=, lsv) : false 65 | else 66 | true 67 | end 68 | end 69 | 70 | sort_specs(res, locked_spec) 71 | end 72 | 73 | # reminder: sort still filters anything older than locked version 74 | def sort_specs(specs, locked_spec) 75 | return specs unless locked_spec 76 | @gem_name = locked_spec.name 77 | @locked_version = locked_spec.version 78 | 79 | filtered = specs.select { |s| s.first.version >= @locked_version } 80 | 81 | @gem_patch = @gems_to_update.gem_patch_for(@gem_name) 82 | @new_version = @gem_patch ? @gem_patch.new_version : nil 83 | 84 | result = filtered.sort do |a, b| 85 | @a_ver = a.first.version 86 | @b_ver = b.first.version 87 | case 88 | when segments_do_not_match(:major) 89 | @b_ver <=> @a_ver 90 | when !@minor_preferred && segments_do_not_match(:minor) 91 | @b_ver <=> @a_ver 92 | when @prefer_minimal && !unlocking_gem? 93 | @b_ver <=> @a_ver 94 | when @prefer_minimal && unlocking_gem? && 95 | (neither_version_matches(@locked_version) && 96 | (!@new_version || both_versions_gt_or_equal_to_version(@new_version))) 97 | @b_ver <=> @a_ver 98 | else 99 | @a_ver <=> @b_ver 100 | end 101 | end 102 | post_sort(result) 103 | end 104 | 105 | def post_sort(result) 106 | unless unlocking_gem? 107 | result = move_version_to_end(result, @locked_version) 108 | end 109 | 110 | if @new_version && unlocking_gem? && segments_match(:major, @new_version, @locked_version) 111 | if @prefer_minimal || (!@prefer_minimal && (result.last.first.version < @new_version)) 112 | result = move_version_to_end(result, @new_version) 113 | end 114 | end 115 | 116 | result 117 | end 118 | 119 | def unlocking_gem? 120 | @gems_to_update.unlocking_gem?(@gem_name) 121 | end 122 | 123 | def either_version_older_than_locked(locked_version) 124 | @a_ver < locked_version || @b_ver < locked_version 125 | end 126 | 127 | def segments_match(level, a_ver=@a_ver, b_ver=@b_ver) 128 | !segments_do_not_match(level, a_ver, b_ver) 129 | end 130 | 131 | def segments_do_not_match(level, a_ver=@a_ver, b_ver=@b_ver) 132 | index = [:major, :minor].index(level) 133 | a_ver.segments[index] != b_ver.segments[index] 134 | end 135 | 136 | def neither_version_matches(match_version) 137 | !one_version_matches(match_version) 138 | end 139 | 140 | def one_version_matches(match_version) 141 | [@a_ver, @b_ver].include?(match_version) 142 | end 143 | 144 | def both_versions_gt_or_equal_to_version(version) 145 | version && @a_ver >= version && @b_ver >= version 146 | end 147 | 148 | def move_version_to_end(result, version) 149 | move, keep = result.partition { |s| s.first.version.to_s == version.to_s } 150 | keep.concat(move) 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /spec/bundler/unit/target_bundle_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | def ruby_bin_version_or_higher(version) 4 | TargetBundle.version_greater_than_or_equal_to_other(RUBY_VERSION, version) 5 | end 6 | 7 | describe TargetBundle do 8 | before do 9 | @tmp_dir = File.join(__dir__, 'fixture') 10 | FileUtils.makedirs @tmp_dir 11 | end 12 | 13 | after do 14 | FileUtils.rmtree(@tmp_dir) 15 | end 16 | 17 | def gemfile_create(ruby_version) 18 | glf = GemfileLockFixture.new(dir: @tmp_dir, ruby_version: ruby_version) 19 | glf.create_gemfile 20 | yield @tmp_dir if block_given? 21 | glf.bundler_fixture 22 | end 23 | 24 | def lockfile_create(ruby_version) 25 | GemfileLockFixture.create(dir: @tmp_dir, ruby_version: ruby_version) 26 | yield @tmp_dir 27 | end 28 | 29 | it 'should default to current directory and Gemfile' do 30 | TargetBundle.new.tap do |bnd| 31 | bnd.dir.should == Dir.pwd 32 | bnd.gemfile.should == 'Gemfile' 33 | end 34 | end 35 | 36 | it 'should find ruby version from Gemfile' do 37 | # CI will run on older Bundler versions to verify this 38 | gemfile_create(RUBY_VERSION) do |dir| 39 | tb = TargetBundle.new(dir: dir) 40 | tb.ruby_version.to_s.should == RUBY_VERSION 41 | end 42 | end 43 | 44 | it 'should find ruby version from Gemfile or lockfile' do 45 | # CI will run on older Bundler versions to verify this 46 | lockfile_create(RUBY_VERSION) do |dir| 47 | tb = TargetBundle.new(dir: dir) 48 | if TargetBundle.bundler_version_or_higher('1.12.0') 49 | tb.ruby_version.to_s.should == "#{RUBY_VERSION}-p#{RUBY_PATCHLEVEL}" 50 | else 51 | tb.ruby_version.to_s.should == RUBY_VERSION 52 | end 53 | end 54 | end 55 | 56 | it 'should behave with ruby requirement' do 57 | if TargetBundle.bundler_version_or_higher('1.12.0') 58 | conf = RbConfig::CONFIG 59 | lockfile_create("~> #{conf['MAJOR']}.#{conf['MINOR']}") do |dir| 60 | tb = TargetBundle.new(dir: dir) 61 | tb.ruby_version.to_s.should == "#{RUBY_VERSION}-p#{RUBY_PATCHLEVEL}" 62 | end 63 | else 64 | pending 'test irrelevant in versions prior to 1.12.0' 65 | fail 66 | end 67 | end 68 | 69 | it 'should find ruby version in .ruby-version file if Bundler too old' do 70 | if TargetBundle.bundler_version_or_higher('1.12.0') 71 | pending 'test irrelevant in versions >= 1.12.0' 72 | fail 73 | else 74 | rv = File.join(@tmp_dir, '.ruby-version') 75 | File.open(rv, 'w') { |f| f.puts '2.9.30' } 76 | gemfile_create(nil) do |dir| 77 | tb = TargetBundle.new(dir: dir) 78 | tb.ruby_version.to_s.should == '2.9.30' 79 | end 80 | end 81 | end 82 | 83 | it 'should default to current ruby if all other attempts fail' do 84 | tb = TargetBundle.new(dir: @tmp_dir) 85 | tb.ruby_version.to_s.should == RbConfig::CONFIG['RUBY_PROGRAM_VERSION'] 86 | end 87 | 88 | it 'should find ruby version in .ruby-version file if Bundler not too old but somehow does not have it' # maybe 89 | 90 | context 'ruby bin focused tests' do 91 | def tmp_dir(path) 92 | File.join(@tmp_dir, path) 93 | end 94 | 95 | before do 96 | %w(2.2.4 2.3.4 1.9.3-p551 2.1.10).each do |ver| 97 | dir = tmp_dir("versions/#{ver}/bin") 98 | FileUtils.makedirs dir 99 | end 100 | end 101 | 102 | it 'rbenv no patch-level' do 103 | gemfile_create('2.1.10') do |dir| 104 | tb = TargetBundle.new(dir: dir) 105 | tb.ruby_bin(tmp_dir('/versions/2.3.4/bin')).should == tmp_dir('versions/2.1.10/bin') 106 | end 107 | end 108 | 109 | it 'rbenv from no patch-level to patch-level' do 110 | gemfile_create('1.9.3p551') do |dir| 111 | tb = TargetBundle.new(dir: dir) 112 | tb.ruby_bin(tmp_dir('/versions/2.3.4/bin')).should == tmp_dir('versions/1.9.3-p551/bin') 113 | end 114 | end 115 | 116 | it 'rbenv from patch-level to no patch-level' do 117 | gemfile_create('2.3.4') do |dir| 118 | tb = TargetBundle.new(dir: dir) 119 | tb.ruby_bin(tmp_dir('/versions/1.9.3-p551/bin')).should == tmp_dir('versions/2.3.4/bin') 120 | end 121 | end 122 | 123 | # haven't seen this in the wild, but it's easy to support 124 | it 'rbenv from patch-level no hyphen to no patch-level' do 125 | gemfile_create('2.3.4') do |dir| 126 | tb = TargetBundle.new(dir: dir) 127 | tb.ruby_bin(tmp_dir('/versions/1.9.3p551/bin')).should == tmp_dir('versions/2.3.4/bin') 128 | end 129 | end 130 | end 131 | 132 | # This context depends on the referenced Ruby version to actually be installed 133 | # for these specs to pass. The .travis.yml file should have a 2.4.5 hardcoded 134 | # to make sure it exists. 135 | context 'gem_home' do 136 | # ENV vars for use inside Travis CI. Otherwise we use the current version 137 | # installed. 138 | let(:current_ruby_version) { RbConfig::CONFIG['RUBY_PROGRAM_VERSION'] } 139 | let(:test_ruby_version) { ENV['RVM_VER'] || current_ruby_version } 140 | let(:test_maj_min_ver) { test_ruby_version.split('.')[0..1].join('.') } 141 | let(:expected_path_fragment) do 142 | "#{test_ruby_version}/lib/ruby/gems/#{test_maj_min_ver}.0$" 143 | end 144 | 145 | it 'should work with no local config path' do 146 | gemfile_create(test_ruby_version) 147 | with_clean_env do 148 | tb = TargetBundle.new(dir: @tmp_dir) 149 | tb.gem_home.should match expected_path_fragment 150 | end 151 | end 152 | 153 | it 'should work with local config path' do 154 | # This used to have different functionality, but no longer does. Still 155 | # need to doc that in both cases we want the same result. (There's a 156 | # chance that we'll NEED this, but not sure yet). 157 | bf = gemfile_create(test_ruby_version) 158 | bf.create_config(path: 'my-local-path') 159 | with_clean_env do 160 | tb = TargetBundle.new(dir: @tmp_dir) 161 | tb.gem_home.should match expected_path_fragment 162 | end 163 | end 164 | end 165 | 166 | it 'should detect when target ruby is different' do 167 | gemfile_create(RbConfig::CONFIG['RUBY_PROGRAM_VERSION']) 168 | with_clean_env do 169 | tb = TargetBundle.new(dir: @tmp_dir) 170 | tb.target_ruby_is_different?.should == false 171 | end 172 | end 173 | 174 | it 'should detect when target ruby is not different' do 175 | gemfile_create('2.1.10') 176 | with_clean_env do 177 | tb = TargetBundle.new(dir: @tmp_dir) 178 | tb.target_ruby_is_different?.should == true 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /lib/bundler/patch/conservative_definition.rb: -------------------------------------------------------------------------------- 1 | module Bundler::Patch 2 | module ConservativeDefinition 3 | attr_accessor :gems_to_update 4 | 5 | # pass-through options to ConservativeResolver 6 | attr_accessor :strict, :minor_preferred, :prefer_minimal 7 | 8 | # This copies more code than I'd like out of Bundler::Definition, but for now seems the least invasive way in. 9 | # Backing up and intervening into the creation of a Definition instance itself involves a lot more code, a lot 10 | # more preliminary data has to be gathered first. 11 | def resolve 12 | @resolve ||= begin 13 | last_resolve = converge_locked_specs 14 | if Bundler.settings[:frozen] || (!@unlocking && nothing_changed?) 15 | last_resolve 16 | else 17 | # Run a resolve against the locally available gems 18 | base = last_resolve.is_a?(Bundler::SpecSet) ? Bundler::SpecSet.new(last_resolve) : [] 19 | bundler_version = Gem::Version.new(Bundler::VERSION) 20 | if bundler_version >= Gem::Version.new('1.13.0.rc.2') 21 | require 'bundler/patch/gem_version_patch_promoter' 22 | 23 | gvpp = Bundler::Patch::GemVersionPatchPromoter.new(@gem_version_promoter.locked_specs, @gem_version_promoter.unlock_gems) 24 | gvpp.level = @minor_preferred ? :minor : :patch 25 | gvpp.strict = @strict 26 | gvpp.minimal = @prefer_minimal 27 | gvpp.gems_to_update = @gems_to_update 28 | 29 | if bundler_version >= Gem::Version.new('1.14.0.rc.1') 30 | resolver = Bundler::Resolver.new(index, source_requirements, base, gvpp, additional_base_requirements_for_resolve, platforms) 31 | else 32 | resolver = Bundler::Resolver.new(index, source_requirements, base, nil, gvpp, additional_base_requirements_for_resolve) 33 | end 34 | else 35 | resolver = ConservativeResolver.new(index, source_requirements, base) 36 | locked_specs = if @unlocking && @locked_specs.length == 0 37 | # Have to grab these again. Default behavior is to not store any 38 | # locked_specs if updating all gems, because behavior is the same 39 | # with no lockfile OR lockfile but update them all. In our case, 40 | # we need to know the locked versions for conservative comparison. 41 | locked = Bundler::LockfileParser.new(@lockfile_contents) 42 | Bundler::SpecSet.new(locked.specs) 43 | else 44 | @locked_specs 45 | end 46 | 47 | resolver.gems_to_update = @gems_to_update 48 | resolver.locked_specs = locked_specs 49 | resolver.strict = @strict 50 | resolver.minor_preferred = @minor_preferred 51 | resolver.prefer_minimal = @prefer_minimal 52 | end 53 | 54 | result = resolver.start(expanded_dependencies) 55 | spec_set = Bundler::SpecSet.new(result) 56 | last_resolve.merge spec_set 57 | end 58 | end 59 | end 60 | end 61 | 62 | class DefinitionPrep 63 | attr_reader :bundler_def 64 | 65 | def initialize(bundler_def, gem_patches, options) 66 | @bundler_def = bundler_def 67 | @gems_to_update = GemsToPatch.new(gem_patches) 68 | @options = options 69 | end 70 | 71 | def prep 72 | @bundler_def ||= Bundler.definition(@gems_to_update.to_bundler_definition) 73 | @bundler_def.extend ConservativeDefinition 74 | 75 | # Starting with 1.17, this method has to be called externally, which isn't 76 | # ideal in my opinion since the Definition class depends on it. 77 | # https://github.com/bundler/bundler/commit/22f15209b87e0b0792c8a393549e1a10c963d59c 78 | @bundler_def.gem_version_promoter if @bundler_def.respond_to?(:gem_version_promoter) 79 | 80 | @bundler_def.gems_to_update = @gems_to_update 81 | @bundler_def.strict = @options[:strict] 82 | @bundler_def.minor_preferred = @options[:minor] 83 | @bundler_def.prefer_minimal = @options[:minimal] 84 | fixup_empty_remotes if @gems_to_update.to_bundler_definition === true 85 | @bundler_def 86 | end 87 | 88 | # This came out a real-life case with sidekiq and sidekiq-pro where the sidekiq-pro gem is served from their gem 89 | # server and depends on the open-source sidekiq gem served from rubygems.org, and when patching those, without 90 | # the appropriate remotes being set in rubygems_aggregrate, it won't work. 91 | # 92 | # The underlying issue in Bundler 1.10 appears to be when the Definition constructor receives `true` as the 93 | # `unlock` parameter, then @locked_sources is initialized to empty array, and the related rubygems_aggregrate 94 | # source instance ends up with no @remotes set in it, which I think happens during converge_sources. Without 95 | # those set, then the index will list no gem versions in some cases. (It was complicated enough to discover this 96 | # patch, I haven't fully worked out the flaw, though I believe I recreated the problem with plain ol `bundle 97 | # update`). 98 | def fixup_empty_remotes 99 | STDERR.puts 'fixing empty remotes' if ENV['DEBUG_PATCH_RESOLVER'] 100 | b_sources = @bundler_def.send(:sources) 101 | empty_remotes = b_sources.rubygems_sources.detect { |s| s.remotes.empty? } 102 | STDERR.puts "empty_remotes: <#{empty_remotes}>" if ENV['DEBUG_PATCH_RESOLVER'] 103 | empty_remotes.remotes.push(*b_sources.rubygems_remotes) if empty_remotes 104 | empty_remotes = b_sources.rubygems_sources.detect { |s| s.remotes.empty? } 105 | STDERR.puts "empty_remotes after fixed: <#{empty_remotes}>" if ENV['DEBUG_PATCH_RESOLVER'] 106 | end 107 | end 108 | 109 | class GemsToPatch 110 | attr_reader :gem_patches 111 | 112 | def initialize(gem_patches) 113 | @gem_patches = Array(gem_patches) 114 | STDERR.puts "Unlocked gems: #{unlocking_description}" if ENV['DEBUG_PATCH_RESOLVER'] 115 | end 116 | 117 | def to_bundler_definition 118 | unlocking_all? ? true : {gems: to_gem_names} 119 | end 120 | 121 | def to_gem_names 122 | @gem_patches.map(&:gem_name) 123 | end 124 | 125 | def gem_patch_for(gem_name) 126 | @gem_patches.detect { |gp| gp.gem_name == gem_name } 127 | end 128 | 129 | def unlocking_all? 130 | @gem_patches.empty? 131 | end 132 | 133 | def unlocking_gem?(gem_name) 134 | unlocking_all? || to_gem_names.include?(gem_name) 135 | end 136 | 137 | def unlocking_description 138 | unlocking_all? ? 'ALL' : to_gem_names.sort.join(', ') 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/bundler/patch/cli.rb: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | require 'bundler/vendor/thor/lib/thor' 3 | require 'bundler/advise' 4 | require 'slop' 5 | require 'open3' 6 | 7 | module Bundler::Patch 8 | class CLI 9 | def self.execute 10 | original_command = ARGV.join(' ') 11 | 12 | opts = Slop.parse! do 13 | banner "Bundler Patch Version #{Bundler::Patch::VERSION}\nUsage: bundle patch [options] [gems-to-update]\n\nbundler-patch attempts to update gems conservatively.\n" 14 | on '-m', '--minor', 'Prefer update to the latest minor.patch version.' 15 | on '-n', '--minimal', 'Prefer minimal version updates over most recent patch (or minor if -m used).' 16 | on '-s', '--strict', 'Restrict any gem to be upgraded past most recent patch (or minor if -m used).' 17 | on '-l', '--list', 'List vulnerable gems and new version target. No updates will be performed.' 18 | on '-v', '--vulnerable-gems-only', 'Only update vulnerable gems.' 19 | on '-a=', '--advisory-db-path=', 'Optional custom advisory db path. `gems` dir will be appended to this path.' 20 | on '-d=', '--ruby-advisory-db-path=', 'Optional path for ruby advisory db. `gems` dir will be appended to this path.' 21 | on '-r', '--ruby', 'Update Ruby version in related files.' 22 | on '--rubies=', 'Supported Ruby versions. Comma delimited or multiple switches.', as: Array, delimiter: ',' 23 | on '-g=', '--gemfile=', 'Optional Gemfile to execute against. Defaults to Gemfile in current directory.' 24 | on '--use_target_ruby', 'Optionally attempt to use Ruby version of target bundle specified in --gemfile.' 25 | on '-h', 'Show this help' 26 | on '--help', 'Show README.md' 27 | 28 | # will be stripped in help display and normalized to hyphenated options 29 | on '--vulnerable_gems_only' 30 | on '--advisory_db_path=' 31 | on '--ruby_advisory_db_path=' 32 | on '-p', '--prefer_minimal' 33 | on '--minor_preferred' 34 | on '--strict_updates' 35 | end 36 | 37 | options = opts.to_hash 38 | options[:gems_to_update] = ARGV 39 | options[:original_command] = original_command 40 | STDERR.puts options.inspect if ENV['DEBUG'] 41 | 42 | show_help(opts) if options[:h] 43 | show_readme if ARGV.include?('help') || options[:help] 44 | 45 | CLI.new.patch(options) 46 | end 47 | 48 | def self.show_help(slop) 49 | slop.options.delete_if { |o| o.long =~ /_/ } 50 | puts slop 51 | exit 52 | end 53 | 54 | def self.show_readme 55 | Kernel.exec "less '#{File.expand_path('../../../../README.md', __FILE__)}'" 56 | exit 57 | end 58 | 59 | def initialize 60 | @no_vulns_message = 'No known vulnerabilities to update.' 61 | end 62 | 63 | def patch(options={}) 64 | Bundler.ui = Bundler::UI::Shell.new 65 | 66 | options = Bundler::Patch::CLI::Options.new.normalize_options(options) 67 | 68 | tb = options[:target] 69 | if options[:use_target_ruby] && tb.target_ruby_is_different? 70 | launch_target_bundler_patch(options) 71 | else 72 | return list(options) if options[:list] 73 | 74 | patch_ruby(options) if options[:ruby] 75 | 76 | patch_gems(options) 77 | end 78 | end 79 | 80 | def launch_target_bundler_patch(options) 81 | tb = options[:target] 82 | ruby = tb.ruby_bin_exe 83 | tb.install_bundler_patch_in_target 84 | bundler_patch = File.join(tb.bin_dir, 'bundler-patch') 85 | full_command = %Q{GEM_HOME="#{tb.gem_home}" "#{ruby}" "#{bundler_patch}" #{options[:original_command].gsub(/use_target_ruby/, '')}} 86 | result = shell_command(full_command) 87 | puts result[:stdout] unless ENV['BP_DEBUG'] 88 | end 89 | 90 | private 91 | 92 | def list(options) 93 | gem_patches = AdvisoryConsolidator.new(options).vulnerable_gems 94 | 95 | if gem_patches.empty? 96 | Bundler.ui.info @no_vulns_message 97 | else 98 | Bundler.ui.info '' # extra line to separate from advisory db update text 99 | Bundler.ui.info 'Detected vulnerabilities:' 100 | Bundler.ui.info '-------------------------' 101 | Bundler.ui.info gem_patches.map(&:to_s).uniq.sort.join("\n") 102 | end 103 | end 104 | 105 | def patch_ruby(options) 106 | supported = options[:rubies] 107 | RubyVersion.new(target_bundle: options[:target], patched_versions: supported).update 108 | end 109 | 110 | def patch_gems(options) 111 | vulnerable_patches = AdvisoryConsolidator.new(options).patch_gemfile_and_get_gem_specs_to_patch 112 | requested_patches = (options.delete(:gems_to_update) || []).map { |gem_name| GemPatch.new(gem_name: gem_name) } 113 | 114 | all_gem_patches = GemsToPatchReconciler.new(vulnerable_patches, requested_patches).reconciled_patches 115 | all_gem_patches.push(*vulnerable_patches) if options[:vulnerable_gems_only] && all_gem_patches.empty? 116 | 117 | vulnerable_patches, warnings = vulnerable_patches.partition { |gp| !gp.new_version.nil? } 118 | 119 | unless warnings.empty? 120 | warnings.each do |gp| 121 | Bundler.ui.warn "* Could not attempt upgrade for #{gp.gem_name} from #{gp.old_version} to any patched versions " \ 122 | + "#{gp.patched_versions.join(', ')}. Most often this is because a major version increment would be " \ 123 | + "required and it's safer for a major version increase to be done manually." 124 | end 125 | end 126 | 127 | if vulnerable_patches.empty? 128 | Bundler.ui.info @no_vulns_message 129 | else 130 | vulnerable_patches.each do |gp| 131 | Bundler.ui.info "Attempting conservative update for vulnerable gem '#{gp.gem_name}': #{gp.old_version} => #{gp.new_version}" 132 | end 133 | end 134 | 135 | if all_gem_patches.empty? 136 | if options[:vulnerable_gems_only] 137 | return # nothing to do 138 | else 139 | Bundler.ui.info 'Updating all gems conservatively.' 140 | end 141 | else 142 | Bundler.ui.info "Updating '#{all_gem_patches.map(&:gem_name).join(' ')}' conservatively." 143 | end 144 | conservative_update(all_gem_patches, options) 145 | end 146 | 147 | def conservative_update(gem_patches, options={}, bundler_def=nil) 148 | prep = DefinitionPrep.new(bundler_def, gem_patches, options).tap { |p| p.prep } 149 | 150 | # update => true is very important, otherwise without any Gemfile changes, the installer 151 | # may end up concluding everything can be resolved locally, nothing is changing, 152 | # and then nothing is done. lib/bundler/cli/update.rb also hard-codes this. 153 | Bundler::Installer.install(options[:target].dir, prep.bundler_def, {'update' => true}) 154 | Bundler.load.cache if Bundler.app_cache.exist? 155 | end 156 | end 157 | end 158 | 159 | def shell_command(command) 160 | puts "-command: #{command}" if ENV['BP_DEBUG'] 161 | stdout, stderr, status = Open3.capture3(command) 162 | if ENV['BP_DEBUG'] 163 | puts "--stdout:#{indent(stdout)}" 164 | puts "--stderr:#{indent(stderr)}" 165 | end 166 | {stdout: stdout, 167 | stderr: stderr, 168 | status: status} 169 | end 170 | 171 | def indent(s) 172 | s.split("\n").map { |ln| " #{ln}" }.join("\n") 173 | end 174 | 175 | if __FILE__ == $0 176 | Bundler::Patch::CLI.execute 177 | end 178 | -------------------------------------------------------------------------------- /spec/bundler/unit/conservative_resolver_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe ConservativeResolver do 4 | before do 5 | @bf = BundlerFixture.new 6 | skip 'Testing against Bundler >= 1.13' if bundler_1_13? 7 | end 8 | 9 | after do 10 | @bf.clean_up 11 | end 12 | 13 | context 'conservative resolver' do 14 | def create_specs(gem_name, versions) 15 | @bf.create_specs(gem_name, versions).map { |s| Array(s) } 16 | end 17 | 18 | def locked(gem_name, version) 19 | @bf.create_spec(gem_name, version) 20 | end 21 | 22 | def versions(result) 23 | result.flatten.map(&:version).map(&:to_s) 24 | end 25 | 26 | def unlocking(options={}) 27 | @cr.gems_to_update = GemsToPatch.new(GemPatch.new(gem_name: 'foo')) 28 | end 29 | 30 | def keep_locked(options={}) 31 | @cr.gems_to_update = GemsToPatch.new(GemPatch.new(gem_name: 'bar')) 32 | end 33 | 34 | before do 35 | @cr = ConservativeResolver.new(nil, {}, []) 36 | @cr.gems_to_update = GemsToPatch.new(nil) 37 | end 38 | 39 | # Rightmost (highest array index) in result is most preferred. 40 | # Leftmost (lowest array index) in result is least preferred. 41 | # `create_specs` has all version of gem in index. 42 | # `locked` is the version currently in the .lock file. 43 | # 44 | # In default (not strict) mode, all versions in the index will 45 | # be returned, allowing Bundler the best chance to resolve all 46 | # dependencies, but sometimes resulting in upgrades that some 47 | # would not consider conservative. 48 | context 'filter specs (strict) (minor not allowed)' do 49 | it 'when keeping locked, keep current, next release' do 50 | keep_locked 51 | res = @cr.filter_specs(create_specs('foo', %w(1.7.8 1.7.9 1.8.0)), 52 | locked('foo', '1.7.8')) 53 | versions(res).should == %w(1.7.9 1.7.8) 54 | end 55 | 56 | it 'when unlocking prefer next release first' do 57 | unlocking 58 | res = @cr.filter_specs(create_specs('foo', %w(1.7.8 1.7.9 1.8.0)), 59 | locked('foo', '1.7.8')) 60 | versions(res).should == %w(1.7.8 1.7.9) 61 | end 62 | 63 | it 'when unlocking keep current when already at latest release' do 64 | unlocking 65 | res = @cr.filter_specs(create_specs('foo', %w(1.7.9 1.8.0 2.0.0)), 66 | locked('foo', '1.7.9')) 67 | versions(res).should == %w(1.7.9) 68 | end 69 | end 70 | 71 | context 'filter specs (strict) (minor preferred)' do 72 | it 'should have specs' 73 | end 74 | 75 | context 'sort specs (not strict) (minor not allowed)' do 76 | it 'when not unlocking, same order but make sure locked version is most preferred to stay put' do 77 | keep_locked 78 | res = @cr.sort_specs(create_specs('foo', %w(1.7.6 1.7.7 1.7.8 1.7.9 1.8.0 1.8.1 2.0.0 2.0.1)), 79 | locked('foo', '1.7.7')) 80 | versions(res).should == %w(2.0.0 2.0.1 1.8.0 1.8.1 1.7.8 1.7.9 1.7.7) 81 | end 82 | 83 | it 'when unlocking favor next release, then current over minor increase' do 84 | unlocking 85 | res = @cr.sort_specs(create_specs('foo', %w(1.7.7 1.7.8 1.7.9 1.8.0)), 86 | locked('foo', '1.7.8')) 87 | versions(res).should == %w(1.8.0 1.7.8 1.7.9) 88 | end 89 | 90 | it 'when unlocking do proper integer comparison, not string' do 91 | unlocking 92 | res = @cr.sort_specs(create_specs('foo', %w(1.7.7 1.7.8 1.7.9 1.7.15 1.8.0)), 93 | locked('foo', '1.7.8')) 94 | versions(res).should == %w(1.8.0 1.7.8 1.7.9 1.7.15) 95 | end 96 | 97 | it 'leave current when unlocking but already at latest release' do 98 | unlocking 99 | res = @cr.sort_specs(create_specs('foo', %w(1.7.9 1.8.0 2.0.0)), 100 | locked('foo', '1.7.9')) 101 | versions(res).should == %w(2.0.0 1.8.0 1.7.9) 102 | end 103 | 104 | it 'when new_version specified, still update to most recent release past patched new_version' do 105 | # new_version can be specified when gem is vulnerable 106 | @cr.gems_to_update = GemsToPatch.new(GemPatch.new(gem_name: 'foo', new_version: '1.7.8')) 107 | versions = %w(1.7.5 1.7.7 1.7.8 1.7.9 1.8.0 2.0.0 2.1.0 3.0.0 3.0.1 3.1.0) 108 | res = @cr.sort_specs(create_specs('foo', versions), 109 | locked('foo', '1.7.5')) 110 | versions(res).should == %w(3.1.0 3.0.0 3.0.1 2.1.0 2.0.0 1.8.0 1.7.5 1.7.7 1.7.8 1.7.9) 111 | end 112 | 113 | it 'when new_version specified up a minor rev without prefer minor, update to new_version' do 114 | # new_version can be specified when gem is vulnerable 115 | @cr.gems_to_update = GemsToPatch.new(GemPatch.new(gem_name: 'foo', new_version: '1.8.1')) 116 | versions = %w(1.7.5 1.7.7 1.7.8 1.7.9 1.8.0 1.8.1 1.8.2 2.0.0 2.1.0 3.0.0) 117 | res = @cr.sort_specs(create_specs('foo', versions), 118 | locked('foo', '1.7.5')) 119 | versions(res).should == %w(3.0.0 2.1.0 2.0.0 1.8.0 1.8.2 1.7.5 1.7.7 1.7.8 1.7.9 1.8.1) 120 | end 121 | 122 | it 'when new_version specified, with prefer minimal, make sure to at least get to new_version' do 123 | @cr.gems_to_update = GemsToPatch.new(GemPatch.new(gem_name: 'foo', new_version: '1.7.7')) 124 | @cr.prefer_minimal = true 125 | versions = %w(1.7.5 1.7.6 1.7.7 1.7.8 1.7.9 1.8.0 2.0.0 2.1.0 3.0.0 3.0.1 3.1.0) 126 | res = @cr.sort_specs(create_specs('foo', versions), 127 | locked('foo', '1.7.5')) 128 | versions(res).should == %w(3.1.0 3.0.1 3.0.0 2.1.0 2.0.0 1.8.0 1.7.5 1.7.6 1.7.9 1.7.8 1.7.7) 129 | end 130 | 131 | it 'when prefer_minimal, and not updating this gem, order is strictly oldest to newest' do 132 | keep_locked 133 | @cr.prefer_minimal = true 134 | versions = %w(1.7.5 1.7.8 1.7.9 1.8.0 2.0.0 2.1.0 3.0.0 3.0.1 3.1.0) 135 | res = @cr.sort_specs(create_specs('foo', versions), 136 | locked('foo', '1.7.5')) 137 | versions(res).should == versions.reverse 138 | end 139 | 140 | it 'when prefer_minimal, and updating this gem, order is oldest to newest except current' do 141 | unlocking 142 | @cr.prefer_minimal = true 143 | versions = %w(1.7.5 1.7.8 1.7.9 1.8.0 2.0.0 2.1.0 3.0.0 3.0.1 3.1.0) 144 | res = @cr.sort_specs(create_specs('foo', versions), 145 | locked('foo', '1.7.5')) 146 | versions(res).should == %w(3.1.0 3.0.1 3.0.0 2.1.0 2.0.0 1.8.0 1.7.5 1.7.9 1.7.8) 147 | end 148 | end 149 | 150 | context 'sort specs (not strict) (minor allowed)' do 151 | it 'when unlocking favor next release, then minor increase over current' do 152 | unlocking 153 | @cr.minor_preferred = true 154 | res = @cr.sort_specs(create_specs('foo', %w(0.2.0 0.3.0 0.3.1 0.9.0 1.0.0 2.0.0 2.0.1)), 155 | locked('foo', '0.2.0')) 156 | versions(res).should == %w(2.0.0 2.0.1 1.0.0 0.2.0 0.3.0 0.3.1 0.9.0) 157 | end 158 | 159 | it 'new version specified' 160 | 161 | it 'new version specified, prefer_minimal' 162 | end 163 | 164 | context 'caching search results' do 165 | before do 166 | bundler_def = @bf.create_definition( 167 | gem_dependencies: [@bf.create_dependency('foo')], 168 | source_specs: [@bf.create_spec('foo', '2.4.0')], ensure_sources: false, update_gems: 'foo') 169 | index = bundler_def.instance_variable_get('@index') 170 | @cr = ConservativeResolver.new(index, {}, Bundler::SpecSet.new([])) 171 | @cr.locked_specs = {'foo' => [@bf.create_spec('foo', '2.4.0')]} 172 | @cr.gems_to_update = GemsToPatch.new([]) 173 | end 174 | 175 | it 'should dup the output to protect the cache' do 176 | # Bundler will (somewhere) do this on occasion during a large resolution. Let's protect against it. 177 | dep = Bundler::DepProxy.new(Gem::Dependency.new('foo', '>= 0'), 'ruby') 178 | res = @cr.search_for(dep) 179 | res.clear 180 | @cr.search_for(dep).should_not == [] 181 | end 182 | end 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /spec/bundler/unit/gemfile_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | require 'fileutils' 4 | 5 | RSpec::Matchers.define :have_line do |expected| 6 | match do |actual| 7 | actual.split(/\n/).map(&:strip).include?(expected) 8 | end 9 | failure_message do |actual| 10 | "expected line <#{expected}> would be in:\n#{actual}" 11 | end 12 | end 13 | 14 | describe Gemfile do 15 | before do 16 | @tmpdir = Dir.mktmpdir 17 | @bf = BundlerFixture.new 18 | end 19 | 20 | after do 21 | FileUtils.remove_entry_secure @tmpdir 22 | end 23 | 24 | def dump 25 | puts "---Gemfile#{'-' * 80}" 26 | puts File.read('Gemfile') 27 | puts 28 | puts "---Gemfile.lock#{'-' * 75}" 29 | puts File.read('Gemfile.lock') 30 | end 31 | 32 | def gem_fixture_create(dir, gems, locks=nil) 33 | GemfileLockFixture.create(dir: dir, gems: gems, locks: locks) do |fix_dir| 34 | yield fix_dir 35 | end 36 | end 37 | 38 | describe 'Gemfile definition' do 39 | describe 'gem name matching' do 40 | it 'should not get confused by gems with same ending' do 41 | gem_fixture_create(@tmpdir, {rails: '3.2.2', :'jquery-rails' => '3.1.3'}) do 42 | s = Gemfile.new(gem_name: 'rails', patched_versions: ['3.2.22.2']) 43 | s.update 44 | File.read('Gemfile').should have_line("gem 'rails', '3.2.22.2'") 45 | File.read('Gemfile').should have_line("gem 'jquery-rails', '3.1.3'") 46 | end 47 | end 48 | end 49 | 50 | describe 'requirements cases' do 51 | # cases based on http://guides.rubygems.org/patterns/#pessimistic-version-constraint 52 | 53 | it 'should support no version' do 54 | gem_fixture_create(@tmpdir, {foo: nil}, {foo: '1.2.3'}) do 55 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['1.2.4']) 56 | s.update 57 | # TODO: consider 'fixing' to "gem 'foo', '>= 1.2.4'" 58 | File.read('Gemfile').should have_line("gem 'foo'") 59 | File.read('Gemfile.lock').should have_line('foo (1.2.3)') # not updating Gemfile.lock anymore 60 | end 61 | end 62 | 63 | it 'should support exact version' do 64 | gem_fixture_create(@tmpdir, {foo: '1.2.3'}) do 65 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['1.2.4']) 66 | s.update 67 | File.read('Gemfile').should have_line("gem 'foo', '1.2.4'") 68 | File.read('Gemfile.lock').should have_line('foo (1.2.3)') # not updating Gemfile.lock anymore 69 | end 70 | end 71 | 72 | it 'should support exact version across major rev' do 73 | # TODO: major rev usually means breaking changes, so stay put. output warning? 74 | gem_fixture_create(@tmpdir, {foo: '1.2.3'}) do 75 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['2.0.0']) 76 | s.update 77 | File.read('Gemfile').should have_line("gem 'foo', '1.2.3'") 78 | File.read('Gemfile.lock').should have_line('foo (1.2.3)') 79 | end 80 | end 81 | 82 | it 'should support greater than version' do 83 | gem_fixture_create(@tmpdir, {foo: '> 1.2'}, {foo: '1.2.5'}) do 84 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['1.3.0']) 85 | s.update 86 | File.read('Gemfile').should have_line("gem 'foo', '>= 1.3.0'") 87 | end 88 | end 89 | 90 | it 'should support greater than or equal version' do 91 | gem_fixture_create(@tmpdir, {foo: '>=1.2'}, {foo: '1.2.5'}) do 92 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['1.3.0']) 93 | s.update 94 | File.read('Gemfile').should have_line("gem 'foo', '>= 1.3.0'") 95 | end 96 | end 97 | 98 | it 'should support less than version when patched still less than spec' do 99 | gem_fixture_create(@tmpdir, {foo: '< 3'}, {foo: '2.4'}) do 100 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['2.5.1']) 101 | s.update 102 | File.read('Gemfile').should have_line("gem 'foo', '< 3'") 103 | end 104 | end 105 | 106 | it 'should support less than version when patched greater than spec and across minor rev' do 107 | gem_fixture_create(@tmpdir, {foo: '< 2.6'}, {foo: '2.4'}) do 108 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['2.7.1']) 109 | s.update 110 | File.read('Gemfile').should have_line("gem 'foo', '~> 2.7'") 111 | end 112 | end 113 | 114 | it 'should support less than version when patched greater than spec and across major rev' do 115 | # TODO: major rev usually means breaking changes, so stay put. output warning? 116 | pending('this case will need some special handling') 117 | gem_fixture_create(@tmpdir, {foo: '< 3'}, {foo: '2.4'}) do 118 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['3.1.1']) 119 | s.update 120 | File.read('Gemfile').should have_line("gem 'foo', '< 3'") 121 | end 122 | end 123 | 124 | it 'should support less than equal to version' do 125 | # `<=` operator isn't documented on the web, but it is supported in the code 126 | gem_fixture_create(@tmpdir, {foo: '<= 2.6'}, {foo: '2.4'}) do 127 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['2.7.1']) 128 | s.update 129 | File.read('Gemfile').should have_line("gem 'foo', '~> 2.7'") 130 | end 131 | end 132 | 133 | it 'should support twiddle-wakka with two segments' do 134 | gem_fixture_create(@tmpdir, {foo: '~>1.2'}, {foo: '1.2.5'}) do 135 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['1.3.0']) 136 | s.update 137 | File.read('Gemfile').should have_line("gem 'foo', '~> 1.3'") 138 | end 139 | end 140 | 141 | it 'should support twiddle-wakka with three segments' do 142 | gem_fixture_create(@tmpdir, {foo: '~>1.2.1'}, {foo: '1.2.5'}) do 143 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['1.3.0']) 144 | s.update 145 | File.read('Gemfile').should have_line("gem 'foo', '~> 1.3.0'") 146 | end 147 | end 148 | 149 | # long form is an equivalent twiddle-wakka 150 | it 'should support twiddle-wakka long form leaving existing if patch within existing requirement' do 151 | # equivalent to ~> 1.2.0 152 | foo_requirements = ['>= 1.2.0', '< 1.3.0'] 153 | gem_fixture_create(@tmpdir, {foo: foo_requirements}, {foo: '1.2.5'}) do 154 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['1.2.7']) 155 | s.update 156 | # TODO: this is inconsistent, should probably change to ~> 1.2.7. Other cases 157 | # change the Gemfile to ensure it won't ever load a lower one. ... Except 158 | # Bundler does take care of that doesn't it? 159 | # 160 | # But, the case of '>= 1.2.0', '< 1.4.2' -- would have to be changed to 161 | # '>= 1.2.7', '< 1.4.2' 162 | # 163 | # Compound forms aren't common, and supporting a more intelligent upgrade when the 164 | # patch is still inside the req is probably not worth the trouble. 165 | # File.read('Gemfile').should have_line("gem 'foo', '< 1.3.0', '>= 1.2.0'") 166 | expected_reqs = @bf.requirement_to_s(Gem::Requirement.new(foo_requirements)) 167 | File.read('Gemfile').should have_line("gem 'foo', #{expected_reqs}") 168 | end 169 | end 170 | 171 | it 'should support twiddle-wakka long form replacing req if patch outside existing requirement' do 172 | # equivalent to ~> 1.2.0 173 | gem_fixture_create(@tmpdir, {foo: ['>= 1.2.0', '< 1.3.0']}, {foo: '1.2.5'}) do 174 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['1.3.0']) 175 | s.update 176 | File.read('Gemfile').should have_line("gem 'foo', '~> 1.3.0'") 177 | end 178 | end 179 | 180 | it 'should support compound with twiddle-wakka if patch inside existing req' do 181 | foo_requirements = ['>= 1.2.1.2', '~> 1.2.1'] 182 | gem_fixture_create(@tmpdir, {foo: foo_requirements}, {foo: '1.2.1.3'}) do 183 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['1.2.4']) 184 | s.update 185 | # The requirements are ordered as of RubyGems 3.0 for compound requirements 186 | expected_reqs = @bf.requirement_to_s(Gem::Requirement.new(foo_requirements)) 187 | File.read('Gemfile').should have_line("gem 'foo', #{expected_reqs}") 188 | end 189 | end 190 | 191 | it 'should support compound with twiddle-wakka if patch outside existing req' do 192 | gem_fixture_create(@tmpdir, {foo: ['>= 1.2.1.2', '~> 1.2.1']}, {foo: '1.2.1.3'}) do 193 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['1.3.0']) 194 | s.update 195 | File.read('Gemfile').should have_line("gem 'foo', '~> 1.3.0'") 196 | end 197 | end 198 | 199 | it 'should be okay with whitespace variations' do 200 | gem_fixture_create(@tmpdir, {:' foo ' => ' > 1.2 '}, {:' foo ' => ' 1.2.5 '}) do 201 | s = Gemfile.new(gem_name: 'foo', patched_versions: ['1.3.0']) 202 | s.update 203 | File.read('Gemfile').should have_line("gem ' foo ', '>= 1.3.0'") 204 | end 205 | end 206 | end 207 | 208 | describe 'Insecure sources' do 209 | it 'should support http to https' 210 | 211 | it 'should support git to https' 212 | 213 | it 'should support source standalone declaration' 214 | 215 | it 'should support source block' 216 | 217 | it 'should support source inline' 218 | end 219 | 220 | describe '.gemspec files' do 221 | it 'should support .gemspec files too' 222 | end 223 | 224 | describe 'update gemfile requirements' do 225 | it 'could have command to update all specific versions to twiddle-waka' 226 | 227 | it 'could have command to update all too-specific twiddle-waka to less specific' 228 | 229 | it 'could have command to update all greater than or equal to to twiddle-waka' 230 | end 231 | end 232 | end 233 | 234 | 235 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bundler-patch 2 | 3 | `bundler-patch` can update your gems conservatively to deal with vulnerable 4 | gems or just get more current. 5 | 6 | By default, "conservatively" means it will prefer the latest patch releases 7 | from the current version, over the latest minor releases or the latest major 8 | releases. This is somewhat opposite from `bundle update` which prefers 9 | newest/major versions first. 10 | 11 | Works with Bundler 1.9 and higher. Starting with Bundler 1.14 (undocumented in 12 | 1.13), much of the core behavior in `bundler-patch` has been ported to Bundler 13 | itself. See [Patch Level 14 | Options](http://bundler.io/v1.14/man/bundle-update.1.html#PATCH-LEVEL-OPTIONS), 15 | [Overlapping 16 | Dependencies](http://bundler.io/v1.14/man/bundle-update.1.html#OVERLAPPING-DEPENDENCIES) 17 | in the `bundle update` docs, also [Patch Level 18 | Options](http://bundler.io/v1.14/man/bundle-outdated.1.html#PATCH-LEVEL-OPTIONS) 19 | in the `bundle outdated` docs. 20 | 21 | 22 | [![Build Status](https://travis-ci.org/livingsocial/bundler-patch.svg?branch=master)](https://travis-ci.org/livingsocial/bundler-patch) 23 | 24 | ## Installation 25 | 26 | $ gem install bundler-patch 27 | 28 | ## Usage 29 | 30 | With the `bundler-patch` binary available, both `bundler-patch` and `bundle 31 | patch` can be used to execute. 32 | 33 | Without any options, all gems will be conservatively updated. An attempt to 34 | upgrade any vulnerable gem (according to 35 | https://github.com/rubysec/ruby-advisory-db) to a patched version will be 36 | made. 37 | 38 | $ bundle patch 39 | 40 | "Conservatively" means it will sort all available versions to prefer the latest 41 | patch releases from the current version, then the latest minor releases and 42 | then the latest major releases. 43 | 44 | "Prefer" means that no available versions are removed from consideration*, to 45 | help ensure a suitable dependency graph can be reconciled. This does mean some 46 | gems cannot be upgraded or may be upgraded to unexpected versions. NOTE: There 47 | is a `--strict` option which _will_ remove versions from consideration, 48 | see below. 49 | 50 | _*That's a white-lie. bundler-patch will actually remove from consideration 51 | any versions older than the currently locked version, which `bundle update` 52 | will not do. It's not common, but it is possible for `bundle update` to 53 | regress a gem to an older version, if necessary to reconcile the dependency 54 | graph._ 55 | 56 | Gem requirements as defined in the Gemfile will still define what versions are 57 | available. The new conservative behavior controls the preference order of those 58 | versions. 59 | 60 | For example, if gem 'foo' is locked at 1.0.2, with no gem requirement defined 61 | in the Gemfile, and versions 1.0.3, 1.0.4, 1.1.0, 1.1.1, 2.0.0 all exist, the 62 | default order of preference will be "1.0.4, 1.0.3, 1.0.2, 1.1.1, 1.1.0, 63 | 2.0.0". 64 | 65 | In the same example, if gem 'foo' has a requirement of '~> 1.0', version 2.0.0 66 | will be removed from consideration as always. 67 | 68 | With no gem names provided on the command line, all gems will be unlocked and 69 | open for updating. A list of gem names can be passed to restrict to just those 70 | gems. 71 | 72 | $ bundle patch foo bar 73 | 74 | * `-m/--minor` option will give preference for minor versions over patch 75 | versions. 76 | 77 | * `-n/--minimal` option will reverse the preference order within patch, 78 | minor, major groups to just 'the next' version. In the prior example, the 79 | order of preference changes to "1.0.3, 1.0.4, 1.0.2, 1.1.0, 1.1.1, 2.0.0" 80 | 81 | * `-s/--strict` option will actually remove from consideration versions 82 | outside either the current patch version (or minor version if `-m` 83 | specified). This increases the chances of Bundler being unable to reconcile 84 | the dependency graph and could raise a `VersionConflict`. 85 | 86 | `bundler-patch` will also check for vulnerabilities based on the 87 | `ruby-advisory-db`, but also will _modify_ (if necessary) the gem requirement 88 | in the Gemfile on vulnerable gems to ensure they can be upgraded. 89 | 90 | * `-l/--list` option will just list vulnerable gems. No updates will be 91 | performed. 92 | 93 | * `-a/--advisory-db-path` option can provide the path to an additional 94 | custom ruby-advisory-db styled directory. The path should not include the 95 | final `gems` directory, that will be appended automatically. This can be 96 | used for flagging necessary updates for custom/internal gems. 97 | 98 | * `-d/--ruby-advisory-db-path` option can override the default path where the 99 | ruby-advisory-db repository is checked out into. 100 | 101 | The rules for updating vulnerable gems are almost identical to the general 102 | `bundler-patch` behavior described above, and abide by the same options (`-m`, 103 | `-n`, and `-s`) though there are some tweaks to encourage getting to at least 104 | a patched version of the gem. Keep in mind Bundler may still choose unexpected 105 | versions in order to satisfy the dependency graph. 106 | 107 | * `-v/--vulnerable-gems-only` option will automatically restrict the gems 108 | to update list to currently vulnerable gems. If a combination of `-v` and 109 | a list of gem names are passed, the `-v` option is ignored in favor of 110 | the listed gem names. 111 | 112 | `bundler-patch` can also update the Ruby version listed in .ruby-version and 113 | the Gemfile if given a list of the latest Ruby versions that are available with 114 | the following options. Jumps of major versions will not be made at all and this 115 | feature is designed such that the version will be updated to only the next 116 | available in the list. If the current version is 2.3.1, and the list of 117 | `--rubies` is "2.3.2, 2.3.3", then 2.3.2 will be used, not 2.3.3. The intention 118 | is for this list to be only the most recent version(s) of Ruby supported, (e.g. 119 | "2.1.10, 2.2.7, 2.3.4"). 120 | 121 | * `-r/--ruby` option indicates updates to Ruby version will be made. 122 | * `--rubies` a comma-delimited list of target Ruby versions to upgrade to. 123 | 124 | ## Examples 125 | 126 | ### Single Gem 127 | 128 | | Requirements| Locked | Available | Options | Result | 129 | |-------------|---------|-----------------------------|----------|--------| 130 | | foo | 1.4.3 | 1.4.4, 1.4.5, 1.5.0, 1.5.1 | | 1.4.5 | 131 | | foo | 1.4.3 | 1.4.4, 1.4.5, 1.5.0, 1.5.1 | -m | 1.5.1 | 132 | | foo | 1.4.3 | 1.4.4, 1.4.5, 1.5.0, 1.5.1 | -n | 1.4.4 | 133 | | foo | 1.4.3 | 1.4.4, 1.4.5, 1.5.0, 1.5.1 | -m -n | 1.5.0 | 134 | 135 | ### Two Gems 136 | 137 | Given the following gem specifications: 138 | 139 | - foo 1.4.3, requires: ~> bar 2.0 140 | - foo 1.4.4, requires: ~> bar 2.0 141 | - foo 1.4.5, requires: ~> bar 2.1 142 | - foo 1.5.0, requires: ~> bar 2.1 143 | - foo 1.5.1, requires: ~> bar 3.0 144 | - bar with versions 2.0.3, 2.0.4, 2.1.0, 2.1.1, 3.0.0 145 | 146 | Gemfile: 147 | 148 | gem 'foo' 149 | 150 | Gemfile.lock: 151 | 152 | foo (1.4.3) 153 | bar (~> 2.0) 154 | bar (2.0.3) 155 | 156 | | # | Command Line | Result | 157 | |---|---------------------------------|---------------------------| 158 | | 1 | bundle patch | 'foo 1.4.5', 'bar 2.1.1' | 159 | | 2 | bundle patch foo | 'foo 1.4.5', 'bar 2.1.1' | 160 | | 3 | bundle patch --minor | 'foo 1.5.1', 'bar 3.0.0' | 161 | | 4 | bundle patch --minor --strict | 'foo 1.5.0', 'bar 2.1.1' | 162 | | 5 | bundle patch --strict | 'foo 1.4.4', 'bar 2.0.4' | 163 | | 6 | bundle patch --minimal | 'foo 1.4.4', 'bar 2.0.4' | 164 | | 7 | bundle patch --strict foo | 'foo 1.4.4', 'bar 2.0.3' | 165 | | 8 | bundle patch --minimal --minor | 'foo 1.5.0', 'bar 2.1.0' | 166 | 167 | In case 1, `bar` is upgraded to 2.1.1, a minor version increase, because the 168 | dependency from `foo` 1.4.5 required it. 169 | 170 | In case 2, `bar` still moves because it is not a _declared_ dependency in the 171 | Gemfile, but it is a dependency of `foo` and is therefore free to move if 172 | `foo`'s requirement of `bar` changes. If `bar` appeared in the Gemfile, then 173 | it would stay put in this case and `foo` would only move to 1.4.4. 174 | 175 | In case 3, `bar` goes up a whole major release, because a minor increase is 176 | preferred now for `foo`, and when it goes to 1.5.1, it requires 3.0.0 of 177 | `bar`. 178 | 179 | In case 4, `foo` is preferred up to a 1.5.x, but 1.5.1 won't work because the 180 | strict `-s` flag removes `bar` 3.0.0 from consideration since it's a major 181 | increment. 182 | 183 | In case 5, both `foo` and `bar` have any minor or major increments removed 184 | from consideration because of the `-s` strict flag, so the most they can 185 | move is up to 1.4.4 and 2.0.4. 186 | 187 | In case 6, the prefer minimal switch `-n` means they only increment to the 188 | next available release. 189 | 190 | In case 7, the `-s` strict flag removes any `bar` 2.1 versions from 191 | consideration, which restricts `foo` to 1.4.4 at latest. `bar` is not unlocked 192 | and therefore doesn't move. 193 | 194 | In case 8, the `-n` and `-m` switches allow both to move to just the next 195 | available minor version. 196 | 197 | 198 | ## Troubleshooting 199 | 200 | First, make sure the current `bundle` command itself runs to completion on its 201 | own without any problems. 202 | 203 | The most frequent problems with this tool involve expectations around what 204 | gems should or shouldn't be upgraded. This can quickly get complicated as even 205 | a small dependency tree can involve many moving parts, and Bundler works hard 206 | to find a combination that satisfies all of the dependencies and requirements. 207 | 208 | NOTE: the requirements in the Gemfile trump anything else. The most control 209 | you have is by modifying those in the Gemfile, in some circumstances it may be 210 | better to pin your versions to what you need instead of trying to diagnose why 211 | Bundler isn't calculating the versions you expect with a broader requirement. 212 | If there is an incompatibility, pinning to desired versions can also aide in 213 | debugging dependency conflicts. 214 | 215 | You can get a (very verbose) look into how Bundler's resolution algorithm is 216 | working by setting the `DEBUG_RESOLVER` environment variable. While it can be 217 | tricky to dig through, it should explain how it came to the conclusions it 218 | came to. 219 | 220 | In particular, grep for 'Unwinding for conflict' in the debug output to 221 | isolate some key issues that may be preventing the outcome you expect. 222 | 223 | Adding to the usual Bundler complexity, `bundler-patch` is injecting its own 224 | logic to the resolution process to achieve its goals. If there's a bug 225 | involved, it's almost certainly in the `bundler-patch` code as Bundler has 226 | been around a long time and has thorough testing and real world experience. 227 | 228 | `bundler-patch` can dump its own debug output, potentially helpful, with 229 | `DEBUG_PATCH_RESOLVER`. 230 | 231 | To get additional Bundler debugging output, enable the `DEBUG` env variable. 232 | This will include all of the details of the downloading the full dependency 233 | data from remote sources. 234 | 235 | At the end of all of this though, again, the requirements in the Gemfile 236 | trump anything else, and the most control you have is by modifying those 237 | in the Gemfile. 238 | 239 | ## Breaking Changes from 0.x to 1.0 240 | 241 | * Command line options with underscores now uses hyphens instead of 242 | underscores. (Underscore versions will still work, but are undocumented). 243 | 244 | * Some options have been renamed. (Old names will still work, but will be 245 | undocumented). 246 | 247 | * `--minor_preferred` => `--minor` 248 | * `--prefer_minimal` => `--minimal` / `-p` => `-n` 249 | * `--strict_updates` => `--strict` 250 | 251 | In the "Two Gems" cases documented above, case 2 was _wrong_ (the docs were 252 | incorrect, there was no bug in the code). Case 2 has been corrected and a 253 | new similar case has been inserted towards the end of the table. 254 | 255 | ## Development 256 | 257 | ### How To 258 | 259 | After checking out the repo, run `bin/setup` to install dependencies. Then, 260 | run `rake spec` to run the tests. You can also run `bin/console` for an 261 | interactive prompt that will allow you to experiment. 262 | 263 | To install this gem onto your local machine, run `bundle exec rake install`. 264 | To release a new version, update the version number in `version.rb`, and then 265 | run `bundle exec rake release`, which will create a git tag for the version, 266 | push git commits and tags, and push the `.gem` file to 267 | [rubygems.org](https://rubygems.org). 268 | 269 | ## Contributing 270 | 271 | Bug reports and pull requests are welcome on GitHub at 272 | https://github.com/livingsocial/bundler-patch. 273 | 274 | ## License 275 | 276 | The gem is available as open source under the terms of the [MIT 277 | License](http://opensource.org/licenses/MIT). 278 | -------------------------------------------------------------------------------- /spec/bundler/integration/integration_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe CLI do 4 | before do 5 | setup_bundler_fixture 6 | end 7 | 8 | def setup_bundler_fixture(gemfile: 'Gemfile') 9 | @bf = BundlerFixture.new(dir: File.expand_path('../../../tmp', __dir__), gemfile: gemfile) 10 | end 11 | 12 | after do 13 | @bf.clean_up 14 | ENV['BUNDLE_GEMFILE'] = nil 15 | end 16 | 17 | def lockfile_spec_version(gem_name) 18 | @bf.parsed_lockfile_spec(gem_name).version.to_s 19 | end 20 | 21 | context 'integration tests' do 22 | it 'single gem with vulnerability' do 23 | Dir.chdir(@bf.dir) do 24 | # TODO: tap then create is a no-op. Replace with just create. And it returns a @bf instance, so no need for two? 25 | GemfileLockFixture.tap do |fix| 26 | fix.create(dir: @bf.dir, 27 | gems: {rack: nil, addressable: nil}, 28 | locks: {rack: '1.4.1', addressable: '2.1.1'}) 29 | end 30 | 31 | with_clean_env do 32 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 33 | CLI.new.patch(gems_to_update: ['rack']) 34 | end 35 | 36 | lockfile_spec_version('rack').should == '1.4.7' 37 | lockfile_spec_version('addressable').should == '2.1.1' 38 | end 39 | end 40 | 41 | # There's SO much global state in SO any nooks and crannies, even with 1.15 Bundler.reset! and additional hacks 42 | # like Gem.instance_variable_set("@paths", nil) (which I tried below), there's just no good way to inline a 43 | # call back into Bundler to make it clean. with_clean_env plus backtick seems to be the best. 44 | it 'single gem with vulnerability with --gemfile option' do 45 | bf = GemfileLockFixture.create(dir: @bf.dir, 46 | gems: {rack: nil, addressable: nil}, 47 | locks: {rack: '1.4.1', addressable: '2.1.1'}) 48 | bf.create_config(path: 'local_path') 49 | 50 | with_clean_env do 51 | bundler_patch(gemfile: File.join(@bf.dir, 'Gemfile'), gems_to_update: ['rack']) 52 | end 53 | 54 | lockfile_spec_version('rack').should == '1.4.7' 55 | lockfile_spec_version('addressable').should == '2.1.1' 56 | 57 | with_clean_env do 58 | Dir.chdir(bf.dir) do 59 | contents = `bundle show rack` 60 | contents.should match /local_path/ 61 | end 62 | end 63 | end 64 | 65 | it 'all gems, one with vulnerability' do 66 | Dir.chdir(@bf.dir) do 67 | GemfileLockFixture.tap do |fix| 68 | fix.create(dir: @bf.dir, 69 | gems: {rack: nil, addressable: nil}, 70 | locks: {rack: '1.4.1', addressable: '2.1.1'}) 71 | end 72 | 73 | with_clean_env do 74 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 75 | CLI.new.patch 76 | end 77 | 78 | lockfile_spec_version('rack').should == '1.4.7' 79 | lockfile_spec_version('addressable').should == '2.1.2' 80 | end 81 | end 82 | 83 | it 'all gems, one with vulnerability, -v flag' do 84 | Dir.chdir(@bf.dir) do 85 | GemfileLockFixture.tap do |fix| 86 | fix.create(dir: @bf.dir, 87 | gems: {rack: nil, addressable: nil}, 88 | locks: {rack: '1.4.1', addressable: '2.1.1'}) 89 | end 90 | 91 | with_clean_env do 92 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 93 | CLI.new.patch(vulnerable_gems_only: true) 94 | end 95 | 96 | lockfile_spec_version('rack').should == '1.4.7' 97 | lockfile_spec_version('addressable').should == '2.1.1' 98 | end 99 | end 100 | 101 | it 'all gems, no vulnerability, -v flag, should do nothing' do 102 | Dir.chdir(@bf.dir) do 103 | GemfileLockFixture.tap do |fix| 104 | fix.create(dir: @bf.dir, 105 | gems: {addressable: nil}, 106 | locks: {addressable: '2.1.1'}) 107 | end 108 | 109 | with_clean_env do 110 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 111 | CLI.new.patch(vulnerable_gems_only: true) 112 | end 113 | 114 | lockfile_spec_version('addressable').should == '2.1.1' 115 | end 116 | end 117 | 118 | it 'single gem, minor allowed' do 119 | Dir.chdir(@bf.dir) do 120 | GemfileLockFixture.tap do |fix| 121 | fix.create(dir: @bf.dir, 122 | gems: {rack: nil, addressable: nil}, 123 | locks: {rack: '0.2.0', addressable: '2.1.1'}) 124 | end 125 | 126 | with_clean_env do 127 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 128 | CLI.new.patch(minor: true, gems_to_update: ['rack']) 129 | end 130 | 131 | lockfile_spec_version('rack').should == '0.9.1' 132 | lockfile_spec_version('addressable').should == '2.1.1' 133 | end 134 | end 135 | 136 | it 'all gems, one with vulnerability, strict mode' do 137 | Dir.chdir(@bf.dir) do 138 | GemfileLockFixture.tap do |fix| 139 | fix.create(dir: @bf.dir, 140 | gems: {rack: nil, addressable: nil}, 141 | locks: {rack: '1.4.1', addressable: '2.1.1'}) 142 | end 143 | 144 | with_clean_env do 145 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 146 | CLI.new.patch(strict: true) 147 | end 148 | 149 | # only diff here would be if a dependency of rack would otherwise go up a minor 150 | # or major version. since there is no dependency here, this is the same result 151 | # with or without strict flag. this integration test inadequate to demonstrate 152 | # the difference. 153 | lockfile_spec_version('rack').should == '1.4.7' 154 | lockfile_spec_version('addressable').should == '2.1.2' 155 | end 156 | end 157 | 158 | it 'single gem with vulnerability, strict mode' do 159 | Dir.chdir(@bf.dir) do 160 | GemfileLockFixture.tap do |fix| 161 | fix.create(dir: @bf.dir, 162 | gems: {rack: nil, addressable: nil}, 163 | locks: {rack: '1.4.1', addressable: '2.1.1'}) 164 | end 165 | 166 | with_clean_env do 167 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 168 | CLI.new.patch(strict: true, gems_to_update: ['rack']) 169 | end 170 | 171 | lockfile_spec_version('rack').should == '1.4.7' 172 | lockfile_spec_version('addressable').should == '2.1.1' 173 | end 174 | end 175 | 176 | it 'single gem with vulnerability, minimal mode' do 177 | Dir.chdir(@bf.dir) do 178 | GemfileLockFixture.tap do |fix| 179 | fix.create(dir: @bf.dir, 180 | gems: {rack: nil, addressable: nil}, 181 | locks: {rack: '1.4.1', addressable: '2.1.1'}) 182 | end 183 | 184 | with_clean_env do 185 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 186 | CLI.new.patch(minimal: true, gems_to_update: ['rack']) 187 | end 188 | 189 | lockfile_spec_version('rack').should == '1.4.6' 190 | lockfile_spec_version('addressable').should == '2.1.1' 191 | end 192 | end 193 | 194 | it 'single gem with vulnerability updates cache' do 195 | Dir.chdir(@bf.dir) do 196 | GemfileLockFixture.tap do |fix| 197 | fix.create(dir: @bf.dir, 198 | gems: {rack: nil, addressable: nil}, 199 | locks: {rack: '1.4.1', addressable: '2.1.1'}) 200 | end 201 | 202 | # Ensure vendor/cache exists 203 | FileUtils.makedirs File.join(@bf.dir, 'vendor', 'cache') 204 | 205 | with_clean_env do 206 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 207 | # Bundler.reset! is only in 1.13, but these are the only bits we need reset for this to work: 208 | %w(root load).each { |name| Bundler.instance_variable_set("@#{name}", nil) } 209 | CLI.new.patch(strict_updates: true, gems_to_update: ['rack']) 210 | end 211 | 212 | lockfile_spec_version('rack').should == '1.4.7' 213 | File.exist?(File.join(@bf.dir, 'vendor', 'cache', 'rack-1.4.7.gem')).should == true 214 | end 215 | end 216 | 217 | it 'single gem with vulnerability, requiring minor upgrade non-minimal' do 218 | Dir.chdir(@bf.dir) do 219 | GemfileLockFixture.tap do |fix| 220 | fix.create(dir: @bf.dir, 221 | gems: {bson: nil}, 222 | locks: {bson: '1.11.1'}) 223 | end 224 | 225 | with_clean_env do 226 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 227 | CLI.new.patch(gems_to_update: ['bson']) 228 | end 229 | 230 | lockfile_spec_version('bson').should == '1.12.3' 231 | end 232 | end 233 | 234 | it 'single gem with vulnerability, requiring minor upgrade minimal' do 235 | Dir.chdir(@bf.dir) do 236 | GemfileLockFixture.tap do |fix| 237 | fix.create(dir: @bf.dir, 238 | gems: {bson: nil}, 239 | locks: {bson: '1.11.1'}) 240 | end 241 | 242 | with_clean_env do 243 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 244 | CLI.new.patch(prefer_minimal: true, gems_to_update: ['bson']) 245 | end 246 | 247 | lockfile_spec_version('bson').should == '1.12.3' 248 | end 249 | end 250 | 251 | it 'single gem, other with vulnerability, strict mode' do 252 | Dir.chdir(@bf.dir) do 253 | GemfileLockFixture.tap do |fix| 254 | fix.create(dir: @bf.dir, 255 | gems: {rack: nil, addressable: nil}, 256 | locks: {rack: '1.4.1', addressable: '2.1.1'}) 257 | end 258 | 259 | with_clean_env do 260 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 261 | CLI.new.patch(strict_updates: true, gems_to_update: ['addressable']) 262 | end 263 | 264 | lockfile_spec_version('rack').should == '1.4.1' 265 | lockfile_spec_version('addressable').should == '2.1.2' 266 | end 267 | end 268 | 269 | it 'single gem with change to Gemfile with custom Gemfile name' do 270 | gemfile_base = 'Custom.gemfile' 271 | gemfile_name = File.join(@bf.dir, gemfile_base) 272 | 273 | setup_bundler_fixture(gemfile: gemfile_base) 274 | 275 | GemfileLockFixture.tap do |fix| 276 | fix.create(dir: @bf.dir, 277 | gems: {rack: '1.4.1'}, 278 | locks: {rack: '1.4.1'}, 279 | gemfile: gemfile_base) 280 | end 281 | 282 | with_clean_env do 283 | CLI.new.patch(gemfile: gemfile_name) 284 | end 285 | 286 | gemfile_contents = File.read(gemfile_name) 287 | gemfile_contents.should include "gem 'rack', '1.4.6'" 288 | lockfile_spec_version('rack').should == '1.4.6' 289 | end 290 | 291 | def with_captured_stdout 292 | begin 293 | old_stdout = $stdout 294 | $stdout = StringIO.new('', 'w') 295 | yield 296 | $stdout.string 297 | ensure 298 | $stdout = old_stdout 299 | end 300 | end 301 | 302 | it 'lists vulnerable gems' do 303 | Dir.chdir(@bf.dir) do 304 | GemfileLockFixture.tap do |fix| 305 | fix.create(dir: @bf.dir, 306 | gems: {rack: nil, addressable: nil}, 307 | locks: {rack: '1.4.1', addressable: '2.1.1'}) 308 | end 309 | 310 | res = nil 311 | with_clean_env do 312 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 313 | res = with_captured_stdout do 314 | CLI.new.patch(list: true) 315 | end 316 | end 317 | 318 | res.should =~ /Detected vulnerabilities/ 319 | res.should =~ /#{Regexp.escape('rack ["1.6.2", "1.5.4", "1.4.6", "2.0.6", "1.1.6", "1.2.8", "1.3.9"]')}/ 320 | end 321 | end 322 | 323 | it 'allows optional config of ruby-advisory-db' do 324 | Dir.chdir(@bf.dir) do 325 | GemfileLockFixture.tap do |fix| 326 | fix.create(dir: @bf.dir, 327 | gems: {rack: nil, addressable: nil}, 328 | locks: {rack: '1.4.1', addressable: '2.1.1'}) 329 | end 330 | 331 | target_dir = File.join(@bf.dir, '.foobar') 332 | File.exist?(File.join(target_dir, 'gems')).should eq false 333 | 334 | with_clean_env do 335 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 336 | CLI.new.patch(gems_to_update: ['rack'], ruby_advisory_db_path: target_dir) 337 | end 338 | 339 | File.exist?(File.join(target_dir, 'gems')).should eq true 340 | end 341 | end 342 | end 343 | 344 | context 'ruby patch' do 345 | before do 346 | @current_ruby_api = RbConfig::CONFIG['ruby_version'] 347 | @current_ruby = RUBY_VERSION 348 | end 349 | 350 | it 'update mri ruby' do 351 | Dir.chdir(@bf.dir) do 352 | File.open('Gemfile', 'w') { |f| f.puts "ruby '#{@current_ruby_api}'" } 353 | CLI.new.patch(ruby: true, rubies: [@current_ruby]) 354 | File.read('Gemfile').chomp.should == "ruby '#{@current_ruby}'" 355 | end 356 | end 357 | 358 | it 'updates ruby version in custom Gemfile' do 359 | fn = File.join(@bf.dir, 'Custom.gemfile') 360 | File.open(fn, 'w') { |f| f.puts "ruby '#{@current_ruby_api}'" } 361 | CLI.new.patch(ruby: true, rubies: [@current_ruby], gemfile: fn) 362 | File.read(fn).chomp.should == "ruby '#{@current_ruby}'" 363 | end 364 | end 365 | end 366 | -------------------------------------------------------------------------------- /spec/bundler/unit/conservative_definition_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe ConservativeDefinition do 4 | before do 5 | @bf = BundlerFixture.new 6 | ENV['BUNDLE_GEMFILE'] = File.join(@bf.dir, 'Gemfile') 7 | end 8 | 9 | after do 10 | ENV['BUNDLE_GEMFILE'] = nil 11 | @bf.clean_up 12 | end 13 | 14 | def lockfile_spec_version(gem_name) 15 | @bf.parsed_lockfile_spec(gem_name).version.to_s 16 | end 17 | 18 | context 'conservative update' do 19 | def setup_lockfile 20 | Dir.chdir(@bf.dir) do 21 | @bf.create_lockfile( 22 | gem_dependencies: [@bf.create_dependency('foo'), @bf.create_dependency('quux')], 23 | source_specs: [ 24 | @bf.create_spec('foo', '2.4.0', [['bar', '>= 1.0.4']]), 25 | @bf.create_spec('bar', '1.1.2'), 26 | @bf.create_spec('bar', '1.1.3'), 27 | @bf.create_spec('quux', '0.0.4'), 28 | ], ensure_sources: false) 29 | yield 30 | end 31 | end 32 | 33 | def test_conservative_update(gems_to_update, options, bundler_def) 34 | gem_patches = Array(gems_to_update).map do |gem_name| 35 | gem_name.is_a?(String) ? GemPatch.new(gem_name: gem_name) : gem_name 36 | end 37 | prep = DefinitionPrep.new(bundler_def, gem_patches, options).tap { |p| p.prep } 38 | prep.bundler_def.tap { |bd| bd.lock(File.join(Dir.pwd, 'Gemfile.lock')) } 39 | end 40 | 41 | it 'when updated gem has same dep req' do 42 | setup_lockfile do 43 | bundler_def = @bf.create_definition( 44 | gem_dependencies: [@bf.create_dependency('foo'), @bf.create_dependency('quux')], 45 | source_specs: [ 46 | @bf.create_spec('foo', '2.4.0', [['bar', '>= 1.0.4']]), 47 | @bf.create_spec('foo', '2.5.0', [['bar', '>= 1.0.4']]), 48 | @bf.create_spec('bar', '1.1.2'), 49 | @bf.create_spec('bar', '1.1.3'), 50 | @bf.create_spec('bar', '3.2.0'), 51 | @bf.create_spec('quux', '0.2.0'), 52 | ], ensure_sources: false, update_gems: 'foo') 53 | test_conservative_update('foo', {strict: true, minor: true}, bundler_def) 54 | 55 | lockfile_spec_version('bar').should == '1.1.3' 56 | lockfile_spec_version('foo').should == '2.5.0' 57 | lockfile_spec_version('quux').should == '0.0.4' 58 | end 59 | end 60 | 61 | it 'when updated gem has updated dep req increase major, strict and non-strict' do 62 | setup_lockfile do 63 | bundler_def = lambda { @bf.create_definition( 64 | gem_dependencies: [@bf.create_dependency('foo'), @bf.create_dependency('quux')], 65 | source_specs: [ 66 | @bf.create_spec('foo', '2.4.0', [['bar', '>= 1.0.4']]), 67 | @bf.create_spec('foo', '2.5.0', [['bar', '~> 2.0']]), 68 | @bf.create_spec('bar', '1.1.2'), 69 | @bf.create_spec('bar', '1.1.3'), 70 | @bf.create_spec('bar', '2.0.0'), 71 | @bf.create_spec('bar', '2.0.1'), 72 | @bf.create_spec('bar', '3.2.0'), 73 | @bf.create_spec('quux', '0.2.0'), 74 | ], ensure_sources: false, update_gems: 'foo') } 75 | 76 | test_conservative_update('foo', {strict: true, minor: true}, bundler_def.call) 77 | lockfile_spec_version('foo').should == '2.4.0' 78 | lockfile_spec_version('bar').should == '1.1.3' 79 | 80 | test_conservative_update('foo', {strict: false, minor: true}, bundler_def.call) 81 | lockfile_spec_version('foo').should == '2.5.0' 82 | lockfile_spec_version('bar').should == '2.0.1' 83 | end 84 | end 85 | 86 | it 'when updated gem has updated dep req increase major, not strict' do 87 | setup_lockfile do 88 | bundler_def = @bf.create_definition( 89 | gem_dependencies: [@bf.create_dependency('foo'), @bf.create_dependency('quux')], 90 | source_specs: [ 91 | @bf.create_spec('foo', '2.4.0', [['bar', '>= 1.0.4']]), 92 | @bf.create_spec('foo', '2.5.0', [['bar', '~> 2.0']]), 93 | @bf.create_specs('bar', %w(1.1.2 1.1.3 2.0.0 2.0.1 3.2.0)), 94 | @bf.create_spec('quux', '0.2.0'), 95 | ], ensure_sources: false, update_gems: 'foo') 96 | test_conservative_update('foo', {strict: false, minor: true}, bundler_def) 97 | 98 | lockfile_spec_version('foo').should == '2.5.0' 99 | lockfile_spec_version('bar').should == '2.0.1' 100 | lockfile_spec_version('quux').should == '0.0.4' 101 | end 102 | end 103 | 104 | it 'updating multiple gems with same req' do 105 | setup_lockfile do 106 | gems_to_update = ['foo', 'quux'] 107 | bundler_def = @bf.create_definition( 108 | gem_dependencies: [@bf.create_dependency('foo'), @bf.create_dependency('quux')], 109 | source_specs: [ 110 | @bf.create_spec('foo', '2.4.0', [['bar', '>= 1.0.4']]), 111 | @bf.create_spec('foo', '2.5.0', [['bar', '>= 1.0.4']]), 112 | @bf.create_spec('bar', '1.1.2'), 113 | @bf.create_spec('bar', '1.1.3'), 114 | @bf.create_spec('bar', '3.2.0'), 115 | @bf.create_spec('quux', '0.2.0'), 116 | ], ensure_sources: false, update_gems: gems_to_update) 117 | test_conservative_update(gems_to_update, {strict: true, minor: true}, bundler_def) 118 | 119 | lockfile_spec_version('bar').should == '1.1.3' 120 | lockfile_spec_version('foo').should == '2.5.0' 121 | lockfile_spec_version('quux').should == '0.2.0' 122 | end 123 | end 124 | 125 | it 'updates all conservatively' do 126 | setup_lockfile do 127 | bundler_def = @bf.create_definition( 128 | gem_dependencies: [@bf.create_dependency('foo'), @bf.create_dependency('quux')], 129 | source_specs: [ 130 | @bf.create_spec('foo', '2.4.0', [['bar', '>= 1.0.4']]), 131 | @bf.create_spec('foo', '2.5.0', [['bar', '>= 1.0.4']]), 132 | @bf.create_spec('bar', '1.1.2'), 133 | @bf.create_spec('bar', '1.1.3'), 134 | @bf.create_spec('bar', '1.1.4'), 135 | @bf.create_spec('bar', '3.2.0'), 136 | @bf.create_spec('quux', '0.2.0'), 137 | ], ensure_sources: false, update_gems: true) 138 | test_conservative_update([], {strict: true, minor: true}, bundler_def) 139 | 140 | lockfile_spec_version('bar').should == '1.1.4' 141 | lockfile_spec_version('foo').should == '2.5.0' 142 | lockfile_spec_version('quux').should == '0.2.0' 143 | end 144 | end 145 | 146 | it 'updates all conservatively when no upgrade exists' do 147 | setup_lockfile do 148 | bundler_def = @bf.create_definition( 149 | gem_dependencies: [@bf.create_dependency('foo'), @bf.create_dependency('quux')], 150 | source_specs: [ 151 | @bf.create_spec('foo', '2.4.0', [['bar', '>= 1.0.4']]), 152 | @bf.create_spec('bar', '1.1.3'), 153 | @bf.create_spec('quux', '0.0.4'), 154 | ], ensure_sources: false, update_gems: true) 155 | test_conservative_update([], {strict: true, minor: true}, bundler_def) 156 | 157 | lockfile_spec_version('bar').should == '1.1.3' 158 | lockfile_spec_version('foo').should == '2.4.0' 159 | lockfile_spec_version('quux').should == '0.0.4' 160 | end 161 | end 162 | 163 | context 'no locked_spec exists' do 164 | def with_bundler_setup 165 | # bundler has special checks to not include itself in a lot of things 166 | Dir.chdir(@bf.dir) do 167 | @bf.create_lockfile( 168 | gem_dependencies: [@bf.create_dependency('foo')], 169 | source_specs: [ 170 | @bf.create_spec('foo', '1.0.0', [['bundler', '>= 0']]), 171 | @bf.create_spec('bundler', '1.10.6'), 172 | ], ensure_sources: false) 173 | 174 | @bundler_def = @bf.create_definition( 175 | gem_dependencies: [@bf.create_dependency('foo')], 176 | source_specs: [ 177 | @bf.create_spec('foo', '1.0.0', [['bundler', '>= 0']]), 178 | @bf.create_spec('foo', '1.0.1', [['bundler', '>= 0']]), 179 | @bf.create_spec('bundler', '1.10.6'), 180 | ], ensure_sources: false, update_gems: true) 181 | yield 182 | end 183 | end 184 | 185 | it 'does not explode when strict' do 186 | with_bundler_setup do 187 | test_conservative_update([], {strict: true}, @bundler_def) 188 | end 189 | end 190 | 191 | it 'does not explode when not strict' do 192 | with_bundler_setup do 193 | test_conservative_update([], {strict: false}, @bundler_def) 194 | end 195 | end 196 | end 197 | 198 | it 'should never increment major version' do 199 | setup_lockfile do 200 | bundler_def = @bf.create_definition( 201 | gem_dependencies: [@bf.create_dependency('foo'), @bf.create_dependency('quux')], 202 | source_specs: [ 203 | @bf.create_spec('foo', '2.4.0', [['bar', '>= 1.0.4']]), 204 | @bf.create_spec('foo', '3.0.0', [['bar', '~> 2.0']]), 205 | @bf.create_spec('bar', '1.1.3'), 206 | @bf.create_spec('bar', '2.0.0'), 207 | @bf.create_spec('quux', '0.0.4'), 208 | ], ensure_sources: false, update_gems: 'foo') 209 | test_conservative_update('foo', {strict: true, minor: true}, bundler_def) 210 | 211 | lockfile_spec_version('foo').should == '2.4.0' 212 | lockfile_spec_version('bar').should == '1.1.3' 213 | lockfile_spec_version('quux').should == '0.0.4' 214 | end 215 | end 216 | 217 | it 'strict mode should still go to the most recent release version' do 218 | setup_lockfile do 219 | bundler_def = @bf.create_definition( 220 | gem_dependencies: [@bf.create_dependency('foo'), @bf.create_dependency('quux')], 221 | source_specs: [ 222 | @bf.create_spec('foo', '2.4.0', [['bar', '>= 1.0.4']]), 223 | @bf.create_spec('foo', '2.4.1', [['bar', '>= 1.0.4']]), 224 | @bf.create_spec('foo', '2.4.2', [['bar', '>= 1.0.4']]), 225 | @bf.create_spec('bar', '1.1.3'), 226 | @bf.create_spec('quux', '0.0.4'), 227 | ], ensure_sources: false, update_gems: 'foo') 228 | test_conservative_update('foo', {strict: true}, bundler_def) 229 | 230 | lockfile_spec_version('foo').should == '2.4.2' 231 | lockfile_spec_version('bar').should == '1.1.3' 232 | lockfile_spec_version('quux').should == '0.0.4' 233 | end 234 | end 235 | 236 | it 'passing major increment in new_version in gems_to_update will not force a gem it' do 237 | setup_lockfile do 238 | gems_to_update = [GemPatch.new(gem_name: 'foo'), GemPatch.new(gem_name: 'quux', new_version: '2.4.0')] 239 | bundler_def = @bf.create_definition( 240 | gem_dependencies: [@bf.create_dependency('foo'), @bf.create_dependency('quux')], 241 | source_specs: [ 242 | @bf.create_spec('foo', '2.4.0', [['bar', '>= 1.0.4']]), 243 | @bf.create_spec('foo', '2.5.0', [['bar', '>= 1.0.4']]), 244 | @bf.create_specs('bar', %w(1.1.2 1.1.3 3.2.0)), 245 | @bf.create_specs('quux', %w(0.0.4 0.2.0 2.4.0)), 246 | ], ensure_sources: false, update_gems: %w(foo quux)) 247 | test_conservative_update(gems_to_update, {strict: false, minor: true}, bundler_def) 248 | 249 | lockfile_spec_version('bar').should == '1.1.3' 250 | lockfile_spec_version('foo').should == '2.5.0' 251 | lockfile_spec_version('quux').should == '0.2.0' 252 | end 253 | end 254 | 255 | it 'fixes up empty remotes in rubygems_aggregator' do 256 | # this test doesn't fail without the fixup code, but I already 257 | # commented I don't know the underlying cause, so better than nothing. 258 | gemfile = File.join(@bf.dir, 'Gemfile') 259 | File.open(gemfile, 'w') { |f| f.puts "source 'https://rubygems.org'" } 260 | setup_lockfile do 261 | bundler_def = test_conservative_update([], {strict: false}, nil) 262 | sources = bundler_def.send(:sources) 263 | sources.rubygems_remotes.length.should_not == 0 264 | end 265 | end 266 | 267 | it 'should spec out prefer_minimal' 268 | 269 | it 'needs to pass-through all install or update bundler options' #? 270 | 271 | it 'needs to cope with frozen setting' 272 | # see bundler-1.10.6/lib/bundler/installer.rb comments for explanation of frozen 273 | 274 | it 'what happens when a new version introduces a brand new gem' #? 275 | 276 | # make sure the docs match reality 277 | context 'BUNDLER.md' do 278 | def test_it(gems: [], options: {strict: false, minor: false}) 279 | @bf.create_lockfile(gem_dependencies: @gem_deps, source_specs: @lock_source_specs, ensure_sources: false) 280 | 281 | bundler_def = @bf.create_definition(gem_dependencies: @gem_deps, source_specs: @source_specs, 282 | ensure_sources: false, update_gems: gems.empty? ? true : gems) 283 | test_conservative_update(gems, options, bundler_def) 284 | end 285 | 286 | context 'Two Gems' do 287 | before do 288 | @gem_deps = [@bf.create_dependency('foo')] 289 | @lock_source_specs = [ 290 | @bf.create_specs('foo', %w(1.4.3), [['bar', '~> 2.0']]), 291 | @bf.create_specs('bar', %w(2.0.3)), 292 | ] 293 | @source_specs = [ 294 | @bf.create_specs('foo', %w(1.4.3 1.4.4), [['bar', '~> 2.0']]), 295 | @bf.create_specs('foo', %w(1.4.5 1.5.0), [['bar', '~> 2.1']]), 296 | @bf.create_specs('foo', %w(1.5.1), [['bar', '~> 3.0']]), 297 | @bf.create_specs('bar', %w(2.0.3 2.0.4 2.1.0 2.1.1 3.0.0)), 298 | ] 299 | end 300 | 301 | it 'bundle update --patch' do 302 | Dir.chdir(@bf.dir) do 303 | test_it 304 | 305 | lockfile_spec_version('foo').should == '1.4.5' 306 | lockfile_spec_version('bar').should == '2.1.1' 307 | end 308 | end 309 | 310 | it 'bundle update --patch foo' do 311 | Dir.chdir(@bf.dir) do 312 | test_it(gems: 'foo') 313 | 314 | lockfile_spec_version('foo').should == '1.4.5' 315 | lockfile_spec_version('bar').should == '2.1.1' 316 | end 317 | end 318 | 319 | it 'bundle update --minor' do 320 | Dir.chdir(@bf.dir) do 321 | test_it(options: {minor: true}) 322 | 323 | lockfile_spec_version('foo').should == '1.5.1' 324 | lockfile_spec_version('bar').should == '3.0.0' 325 | end 326 | end 327 | 328 | it 'bundle update --minor --strict' do 329 | Dir.chdir(@bf.dir) do 330 | test_it(options: {minor: true, strict: true}) 331 | 332 | lockfile_spec_version('foo').should == '1.5.0' 333 | lockfile_spec_version('bar').should == '2.1.1' 334 | end 335 | end 336 | 337 | it 'bundle update --patch --strict' do 338 | Dir.chdir(@bf.dir) do 339 | test_it(options: {minor: false, strict: true}) 340 | 341 | lockfile_spec_version('foo').should == '1.4.4' 342 | lockfile_spec_version('bar').should == '2.0.4' 343 | end 344 | end 345 | end 346 | 347 | context 'Shared Dependencies' do 348 | context 'Cannot Move' do 349 | before do 350 | @gem_deps = [@bf.create_dependency('foo'), @bf.create_dependency('qux')] 351 | @lock_source_specs = [ 352 | @bf.create_specs('foo', %w(1.4.3), [['shared', '~> 2.0'], ['bar', '~> 2.0']]), 353 | @bf.create_specs('qux', %w(1.0.0), [['shared', '~> 2.0.0']]), 354 | @bf.create_specs('bar', %w(2.0.3)), 355 | @bf.create_specs('shared', %w(2.0.3)), 356 | ] 357 | @source_specs = [ 358 | @bf.create_specs('foo', %w(1.4.3 1.4.4), [['shared', '~> 2.0'], ['bar', '~> 2.0']]), 359 | @bf.create_specs('foo', %w(1.4.5 1.5.0), [['shared', '~> 2.1'], ['bar', '~> 2.1']]), 360 | @bf.create_specs('qux', %w(1.0.0), [['shared', '~> 2.0.0']]), 361 | @bf.create_specs('bar', %w(2.0.3 2.0.4 2.1.0 2.1.1)), 362 | @bf.create_specs('shared', %w(2.0.3 2.0.4 2.1.0 2.1.1)), 363 | ] 364 | end 365 | 366 | it 'bundle update --patch foo' do 367 | Dir.chdir(@bf.dir) do 368 | test_it(gems: ['foo']) 369 | 370 | lockfile_spec_version('foo').should == '1.4.4' #'1.4.5' 371 | lockfile_spec_version('bar').should == '2.0.3' #'2.1.1' 372 | lockfile_spec_version('qux').should == '1.0.0' #'1.0.0' 373 | lockfile_spec_version('shared').should == '2.0.3' #'2.0.3' 374 | end 375 | end 376 | 377 | it 'bundle update --patch foo bar' do 378 | Dir.chdir(@bf.dir) do 379 | test_it(gems: ['foo', 'bar']) 380 | 381 | lockfile_spec_version('foo').should == '1.4.4' #'1.4.5' 382 | lockfile_spec_version('bar').should == '2.0.4' #'2.1.1' 383 | lockfile_spec_version('qux').should == '1.0.0' #'1.0.0' 384 | lockfile_spec_version('shared').should == '2.0.3' #'2.0.3' 385 | end 386 | end 387 | 388 | it 'bundle update --patch' do 389 | Dir.chdir(@bf.dir) do 390 | test_it 391 | 392 | lockfile_spec_version('foo').should == '1.4.4' #'1.4.5' 393 | lockfile_spec_version('bar').should == '2.0.4' #'2.1.1' 394 | lockfile_spec_version('qux').should == '1.0.0' #'1.0.0' 395 | lockfile_spec_version('shared').should == '2.0.4' #'2.0.3' 396 | end 397 | end 398 | end 399 | 400 | # Almost identical, but dependency between qux and shared is more flexible 401 | context 'Can Move' do 402 | before do 403 | @gem_deps = [@bf.create_dependency('foo'), @bf.create_dependency('qux')] 404 | @lock_source_specs = [ 405 | @bf.create_specs('foo', %w(1.4.3), [['shared', '~> 2.0'], ['bar', '~> 2.0']]), 406 | @bf.create_specs('qux', %w(1.0.0), [['shared', '~> 2.0']]), 407 | @bf.create_specs('bar', %w(2.0.3)), 408 | @bf.create_specs('shared', %w(2.0.3)), 409 | ] 410 | @source_specs = [ 411 | @bf.create_specs('foo', %w(1.4.3 1.4.4), [['shared', '~> 2.0'], ['bar', '~> 2.0']]), 412 | @bf.create_specs('foo', %w(1.4.5 1.5.0), [['shared', '~> 2.1'], ['bar', '~> 2.1']]), 413 | @bf.create_specs('qux', %w(1.0.0), [['shared', '~> 2.0']]), 414 | @bf.create_specs('bar', %w(2.0.3 2.0.4 2.1.0 2.1.1)), 415 | @bf.create_specs('shared', %w(2.0.3 2.0.4 2.1.0 2.1.1)), 416 | ] 417 | end 418 | 419 | it 'bundle update --patch foo' do 420 | Dir.chdir(@bf.dir) do 421 | test_it(gems: ['foo']) 422 | 423 | lockfile_spec_version('foo').should == '1.4.5' 424 | lockfile_spec_version('bar').should == '2.1.1' 425 | lockfile_spec_version('qux').should == '1.0.0' 426 | lockfile_spec_version('shared').should == '2.1.1' 427 | end 428 | end 429 | 430 | it 'bundle update --patch foo bar' do 431 | Dir.chdir(@bf.dir) do 432 | test_it(gems: ['foo', 'bar']) 433 | 434 | lockfile_spec_version('foo').should == '1.4.5' 435 | lockfile_spec_version('bar').should == '2.1.1' 436 | lockfile_spec_version('qux').should == '1.0.0' 437 | lockfile_spec_version('shared').should == '2.1.1' 438 | end 439 | end 440 | 441 | it 'bundle update --patch' do 442 | Dir.chdir(@bf.dir) do 443 | test_it 444 | 445 | lockfile_spec_version('foo').should == '1.4.5' 446 | lockfile_spec_version('bar').should == '2.1.1' 447 | lockfile_spec_version('qux').should == '1.0.0' 448 | lockfile_spec_version('shared').should == '2.1.1' 449 | end 450 | end 451 | end 452 | end 453 | end 454 | end 455 | end 456 | --------------------------------------------------------------------------------