[rubygems/rubygems] User bundler UA when downloading gems

Gem::RemoteFetcher uses Gem::Request, which adds the RubyGems UA.
Gem::RemoteFetcher is used to download gems, as well as the full index.
We would like the bundler UA to be used whenever bundler is making
requests.

This PR also avoids unsafely mutating the headers hash on the shared
`Gem::RemoteFetcher.fetcher` instance, which could cause corruption or
incorrect headers when making parallel requests. Instead, we create one
remote fetcher per rubygems remote, which is similar to the connection
segregation bundler is already doing

https://github.com/rubygems/rubygems/commit/f0e8dacdec
This commit is contained in:
Samuel Giddins 2023-10-22 13:25:07 -07:00 committed by git
parent 536649f819
commit b69bbf588a
14 changed files with 62 additions and 41 deletions

View File

@ -844,7 +844,7 @@ module Bundler
return unless SharedHelpers.md5_available? return unless SharedHelpers.md5_available?
latest = Fetcher::CompactIndex. latest = Fetcher::CompactIndex.
new(nil, Source::Rubygems::Remote.new(Bundler::URI("https://rubygems.org")), nil). new(nil, Source::Rubygems::Remote.new(Bundler::URI("https://rubygems.org")), nil, nil).
send(:compact_index_client). send(:compact_index_client).
instance_variable_get(:@cache). instance_variable_get(:@cache).
dependencies("bundler"). dependencies("bundler").

View File

@ -204,6 +204,16 @@ module Bundler
fetchers.first.api_fetcher? fetchers.first.api_fetcher?
end end
def gem_remote_fetcher
@gem_remote_fetcher ||= begin
require_relative "fetcher/gem_remote_fetcher"
fetcher = GemRemoteFetcher.new Gem.configuration[:http_proxy]
fetcher.headers["User-Agent"] = user_agent
fetcher.headers["X-Gemfile-Source"] = @remote.original_uri.to_s if @remote.original_uri
fetcher
end
end
private private
def available_fetchers def available_fetchers
@ -218,7 +228,7 @@ module Bundler
end end
def fetchers def fetchers
@fetchers ||= available_fetchers.map {|f| f.new(downloader, @remote, uri) }.drop_while {|f| !f.available? } @fetchers ||= available_fetchers.map {|f| f.new(downloader, @remote, uri, gem_remote_fetcher) }.drop_while {|f| !f.available? }
end end
def fetch_specs(gem_names) def fetch_specs(gem_names)

View File

@ -6,12 +6,14 @@ module Bundler
attr_reader :downloader attr_reader :downloader
attr_reader :display_uri attr_reader :display_uri
attr_reader :remote attr_reader :remote
attr_reader :gem_remote_fetcher
def initialize(downloader, remote, display_uri) def initialize(downloader, remote, display_uri, gem_remote_fetcher)
raise "Abstract class" if self.class == Base raise "Abstract class" if self.class == Base
@downloader = downloader @downloader = downloader
@remote = remote @remote = remote
@display_uri = display_uri @display_uri = display_uri
@gem_remote_fetcher = gem_remote_fetcher
end end
def remote_uri def remote_uri

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
require "rubygems/remote_fetcher"
module Bundler
class Fetcher
class GemRemoteFetcher < Gem::RemoteFetcher
def request(*args)
super do |req|
req.delete("User-Agent") if headers["User-Agent"]
yield req if block_given?
end
end
end
end
end

View File

@ -6,7 +6,7 @@ module Bundler
class Fetcher class Fetcher
class Index < Base class Index < Base
def specs(_gem_names) def specs(_gem_names)
Bundler.rubygems.fetch_all_remote_specs(remote) Bundler.rubygems.fetch_all_remote_specs(remote, gem_remote_fetcher)
rescue Gem::RemoteFetcher::FetchError => e rescue Gem::RemoteFetcher::FetchError => e
case e.message case e.message
when /certificate verify failed/ when /certificate verify failed/

View File

@ -467,11 +467,9 @@ module Bundler
Gem::Specification.all = specs Gem::Specification.all = specs
end end
def fetch_specs(remote, name) def fetch_specs(remote, name, fetcher)
require "rubygems/remote_fetcher" require "rubygems/remote_fetcher"
path = remote.uri.to_s + "#{name}.#{Gem.marshal_version}.gz" path = remote.uri.to_s + "#{name}.#{Gem.marshal_version}.gz"
fetcher = gem_remote_fetcher
fetcher.headers = { "X-Gemfile-Source" => remote.original_uri.to_s } if remote.original_uri
string = fetcher.fetch_path(path) string = fetcher.fetch_path(path)
specs = Bundler.safe_load_marshal(string) specs = Bundler.safe_load_marshal(string)
raise MarshalError, "Specs #{name} from #{remote} is expected to be an Array but was unexpected class #{specs.class}" unless specs.is_a?(Array) raise MarshalError, "Specs #{name} from #{remote} is expected to be an Array but was unexpected class #{specs.class}" unless specs.is_a?(Array)
@ -481,18 +479,16 @@ module Bundler
raise unless name == "prerelease_specs" raise unless name == "prerelease_specs"
end end
def fetch_all_remote_specs(remote) def fetch_all_remote_specs(remote, gem_remote_fetcher)
specs = fetch_specs(remote, "specs") specs = fetch_specs(remote, "specs", gem_remote_fetcher)
pres = fetch_specs(remote, "prerelease_specs") || [] pres = fetch_specs(remote, "prerelease_specs", gem_remote_fetcher) || []
specs.concat(pres) specs.concat(pres)
end end
def download_gem(spec, uri, cache_dir) def download_gem(spec, uri, cache_dir, fetcher)
require "rubygems/remote_fetcher" require "rubygems/remote_fetcher"
uri = Bundler.settings.mirror_for(uri) uri = Bundler.settings.mirror_for(uri)
fetcher = gem_remote_fetcher
fetcher.headers = { "X-Gemfile-Source" => spec.remote.original_uri.to_s } if spec.remote.original_uri
Bundler::Retry.new("download gem from #{uri}").attempts do Bundler::Retry.new("download gem from #{uri}").attempts do
gem_file_name = spec.file_name gem_file_name = spec.file_name
local_gem_path = File.join cache_dir, gem_file_name local_gem_path = File.join cache_dir, gem_file_name
@ -519,11 +515,6 @@ module Bundler
raise Bundler::HTTPError, "Could not download gem from #{uri} due to underlying error <#{e.message}>" raise Bundler::HTTPError, "Could not download gem from #{uri} due to underlying error <#{e.message}>"
end end
def gem_remote_fetcher
require "rubygems/remote_fetcher"
Gem::RemoteFetcher.fetcher
end
def build(spec, skip_validation = false) def build(spec, skip_validation = false)
require "rubygems/package" require "rubygems/package"
Gem::Package.build(spec, skip_validation) Gem::Package.build(spec, skip_validation)

View File

@ -255,11 +255,15 @@ module Bundler
end end
end end
def fetchers def remote_fetchers
@fetchers ||= remotes.map do |uri| @remote_fetchers ||= remotes.to_h do |uri|
remote = Source::Rubygems::Remote.new(uri) remote = Source::Rubygems::Remote.new(uri)
Bundler::Fetcher.new(remote) [remote, Bundler::Fetcher.new(remote)]
end.freeze
end end
def fetchers
@fetchers ||= remote_fetchers.values.freeze
end end
def double_check_for(unmet_dependency_names) def double_check_for(unmet_dependency_names)
@ -480,7 +484,8 @@ module Bundler
def download_gem(spec, download_cache_path, previous_spec = nil) def download_gem(spec, download_cache_path, previous_spec = nil)
uri = spec.remote.uri uri = spec.remote.uri
Bundler.ui.confirm("Fetching #{version_message(spec, previous_spec)}") Bundler.ui.confirm("Fetching #{version_message(spec, previous_spec)}")
Bundler.rubygems.download_gem(spec, uri, download_cache_path) gem_remote_fetcher = remote_fetchers.fetch(spec.remote).gem_remote_fetcher
Bundler.rubygems.download_gem(spec, uri, download_cache_path, gem_remote_fetcher)
end end
# Returns the global cache path of the calling Rubygems::Source object. # Returns the global cache path of the calling Rubygems::Source object.

View File

@ -4,15 +4,16 @@ RSpec.describe Bundler::Fetcher::Base do
let(:downloader) { double(:downloader) } let(:downloader) { double(:downloader) }
let(:remote) { double(:remote) } let(:remote) { double(:remote) }
let(:display_uri) { "http://sample_uri.com" } let(:display_uri) { "http://sample_uri.com" }
let(:gem_remote_fetcher) { nil }
class TestClass < described_class; end class TestClass < described_class; end
subject { TestClass.new(downloader, remote, display_uri) } subject { TestClass.new(downloader, remote, display_uri, gem_remote_fetcher) }
describe "#initialize" do describe "#initialize" do
context "with the abstract Base class" do context "with the abstract Base class" do
it "should raise an error" do it "should raise an error" do
expect { described_class.new(downloader, remote, display_uri) }.to raise_error(RuntimeError, "Abstract class") expect { described_class.new(downloader, remote, display_uri, gem_remote_fetcher) }.to raise_error(RuntimeError, "Abstract class")
end end
end end

View File

@ -7,7 +7,8 @@ RSpec.describe Bundler::Fetcher::CompactIndex do
let(:downloader) { double(:downloader) } let(:downloader) { double(:downloader) }
let(:display_uri) { Bundler::URI("http://sampleuri.com") } let(:display_uri) { Bundler::URI("http://sampleuri.com") }
let(:remote) { double(:remote, :cache_slug => "lsjdf", :uri => display_uri) } let(:remote) { double(:remote, :cache_slug => "lsjdf", :uri => display_uri) }
let(:compact_index) { described_class.new(downloader, remote, display_uri) } let(:gem_remote_fetcher) { nil }
let(:compact_index) { described_class.new(downloader, remote, display_uri, gem_remote_fetcher) }
before do before do
allow(compact_index).to receive(:log_specs) {} allow(compact_index).to receive(:log_specs) {}

View File

@ -4,8 +4,9 @@ RSpec.describe Bundler::Fetcher::Dependency do
let(:downloader) { double(:downloader) } let(:downloader) { double(:downloader) }
let(:remote) { double(:remote, :uri => Bundler::URI("http://localhost:5000")) } let(:remote) { double(:remote, :uri => Bundler::URI("http://localhost:5000")) }
let(:display_uri) { "http://sample_uri.com" } let(:display_uri) { "http://sample_uri.com" }
let(:gem_remote_fetcher) { nil }
subject { described_class.new(downloader, remote, display_uri) } subject { described_class.new(downloader, remote, display_uri, gem_remote_fetcher) }
describe "#available?" do describe "#available?" do
let(:dependency_api_uri) { double(:dependency_api_uri) } let(:dependency_api_uri) { double(:dependency_api_uri) }

View File

@ -8,8 +8,9 @@ RSpec.describe Bundler::Fetcher::Index do
let(:display_uri) { "http://sample_uri.com" } let(:display_uri) { "http://sample_uri.com" }
let(:rubygems) { double(:rubygems) } let(:rubygems) { double(:rubygems) }
let(:gem_names) { %w[foo bar] } let(:gem_names) { %w[foo bar] }
let(:gem_remote_fetcher) { nil }
subject { described_class.new(downloader, remote, display_uri) } subject { described_class.new(downloader, remote, display_uri, gem_remote_fetcher) }
before { allow(Bundler).to receive(:rubygems).and_return(rubygems) } before { allow(Bundler).to receive(:rubygems).and_return(rubygems) }

View File

@ -46,14 +46,12 @@ RSpec.describe Bundler::RubygemsIntegration do
let(:fetcher) { double("gem_remote_fetcher") } let(:fetcher) { double("gem_remote_fetcher") }
it "successfully downloads gem with retries" do it "successfully downloads gem with retries" do
expect(Bundler.rubygems).to receive(:gem_remote_fetcher).and_return(fetcher)
expect(fetcher).to receive(:headers=).with({ "X-Gemfile-Source" => "https://foo.bar" })
expect(Bundler::Retry).to receive(:new).with("download gem from #{uri}/"). expect(Bundler::Retry).to receive(:new).with("download gem from #{uri}/").
and_return(bundler_retry) and_return(bundler_retry)
expect(bundler_retry).to receive(:attempts).and_yield expect(bundler_retry).to receive(:attempts).and_yield
expect(fetcher).to receive(:cache_update_path) expect(fetcher).to receive(:cache_update_path)
Bundler.rubygems.download_gem(spec, uri, cache_dir) Bundler.rubygems.download_gem(spec, uri, cache_dir, fetcher)
end end
end end
@ -68,11 +66,9 @@ RSpec.describe Bundler::RubygemsIntegration do
let(:remote_with_mirror) { double("remote", :uri => uri, :original_uri => orig_uri) } let(:remote_with_mirror) { double("remote", :uri => uri, :original_uri => orig_uri) }
it "sets the 'X-Gemfile-Source' header containing the original source" do it "sets the 'X-Gemfile-Source' header containing the original source" do
expect(Bundler.rubygems).to receive(:gem_remote_fetcher).twice.and_return(fetcher)
expect(fetcher).to receive(:headers=).with({ "X-Gemfile-Source" => "http://zombo.com" }).twice
expect(fetcher).to receive(:fetch_path).with(uri + "specs.4.8.gz").and_return(specs_response) expect(fetcher).to receive(:fetch_path).with(uri + "specs.4.8.gz").and_return(specs_response)
expect(fetcher).to receive(:fetch_path).with(uri + "prerelease_specs.4.8.gz").and_return(prerelease_specs_response) expect(fetcher).to receive(:fetch_path).with(uri + "prerelease_specs.4.8.gz").and_return(prerelease_specs_response)
result = Bundler.rubygems.fetch_all_remote_specs(remote_with_mirror) result = Bundler.rubygems.fetch_all_remote_specs(remote_with_mirror, fetcher)
expect(result).to eq(%w[specs prerelease_specs]) expect(result).to eq(%w[specs prerelease_specs])
end end
end end
@ -81,11 +77,9 @@ RSpec.describe Bundler::RubygemsIntegration do
let(:remote_no_mirror) { double("remote", :uri => uri, :original_uri => nil) } let(:remote_no_mirror) { double("remote", :uri => uri, :original_uri => nil) }
it "does not set the 'X-Gemfile-Source' header" do it "does not set the 'X-Gemfile-Source' header" do
expect(Bundler.rubygems).to receive(:gem_remote_fetcher).twice.and_return(fetcher)
expect(fetcher).to_not receive(:headers=)
expect(fetcher).to receive(:fetch_path).with(uri + "specs.4.8.gz").and_return(specs_response) expect(fetcher).to receive(:fetch_path).with(uri + "specs.4.8.gz").and_return(specs_response)
expect(fetcher).to receive(:fetch_path).with(uri + "prerelease_specs.4.8.gz").and_return(prerelease_specs_response) expect(fetcher).to receive(:fetch_path).with(uri + "prerelease_specs.4.8.gz").and_return(prerelease_specs_response)
result = Bundler.rubygems.fetch_all_remote_specs(remote_no_mirror) result = Bundler.rubygems.fetch_all_remote_specs(remote_no_mirror, fetcher)
expect(result).to eq(%w[specs prerelease_specs]) expect(result).to eq(%w[specs prerelease_specs])
end end
end end
@ -95,9 +89,8 @@ RSpec.describe Bundler::RubygemsIntegration do
let(:unexpected_specs_response) { Marshal.dump(3) } let(:unexpected_specs_response) { Marshal.dump(3) }
it "raises a MarshalError error" do it "raises a MarshalError error" do
expect(Bundler.rubygems).to receive(:gem_remote_fetcher).once.and_return(fetcher)
expect(fetcher).to receive(:fetch_path).with(uri + "specs.4.8.gz").and_return(unexpected_specs_response) expect(fetcher).to receive(:fetch_path).with(uri + "specs.4.8.gz").and_return(unexpected_specs_response)
expect { Bundler.rubygems.fetch_all_remote_specs(remote_no_mirror) }.to raise_error(Bundler::MarshalError, /unexpected class/i) expect { Bundler.rubygems.fetch_all_remote_specs(remote_no_mirror, fetcher) }.to raise_error(Bundler::MarshalError, /unexpected class/i)
end end
end end
end end

View File

@ -17,7 +17,7 @@ RSpec.describe "fetching dependencies with a mirrored source", :realworld => tru
@t.join @t.join
end end
it "sets the 'X-Gemfile-Source' header and bundles successfully" do it "sets the 'X-Gemfile-Source' and 'User-Agent' headers and bundles successfully" do
gemfile <<-G gemfile <<-G
source "#{mirror}" source "#{mirror}"
gem 'weakling' gem 'weakling'

View File

@ -4,7 +4,7 @@ require_relative "helpers/endpoint"
class EndpointMirrorSource < Endpoint class EndpointMirrorSource < Endpoint
get "/gems/:id" do get "/gems/:id" do
if request.env["HTTP_X_GEMFILE_SOURCE"] == "https://server.example.org/" if request.env["HTTP_X_GEMFILE_SOURCE"] == "https://server.example.org/" && request.env["HTTP_USER_AGENT"].start_with?("bundler")
File.binread("#{gem_repo1}/gems/#{params[:id]}") File.binread("#{gem_repo1}/gems/#{params[:id]}")
else else
halt 500 halt 500