From 11741d3130a5ebb49b5ed9758bd65012cfe841f9 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 12 Jun 2026 13:47:39 +0900 Subject: [PATCH 1/3] Introduce Bundler::Plugin::UnloadedSource An inert placeholder source that stands in for a plugin source whose handling plugin is not loaded, so that a lockfile referencing it can still be parsed. Co-Authored-By: Claude Fable 5 --- Manifest.txt | 1 + bundler/lib/bundler/plugin.rb | 11 ++++---- bundler/lib/bundler/plugin/unloaded_source.rb | 25 +++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 bundler/lib/bundler/plugin/unloaded_source.rb diff --git a/Manifest.txt b/Manifest.txt index ddf4f6044b47..e0b66eb9619e 100644 --- a/Manifest.txt +++ b/Manifest.txt @@ -167,6 +167,7 @@ bundler/lib/bundler/plugin/installer/git.rb bundler/lib/bundler/plugin/installer/path.rb bundler/lib/bundler/plugin/installer/rubygems.rb bundler/lib/bundler/plugin/source_list.rb +bundler/lib/bundler/plugin/unloaded_source.rb bundler/lib/bundler/process_lock.rb bundler/lib/bundler/remote_specification.rb bundler/lib/bundler/resolver.rb diff --git a/bundler/lib/bundler/plugin.rb b/bundler/lib/bundler/plugin.rb index faca6bea5306..16008a9e34a9 100644 --- a/bundler/lib/bundler/plugin.rb +++ b/bundler/lib/bundler/plugin.rb @@ -4,11 +4,12 @@ module Bundler module Plugin - autoload :DSL, File.expand_path("plugin/dsl", __dir__) - autoload :Events, File.expand_path("plugin/events", __dir__) - autoload :Index, File.expand_path("plugin/index", __dir__) - autoload :Installer, File.expand_path("plugin/installer", __dir__) - autoload :SourceList, File.expand_path("plugin/source_list", __dir__) + autoload :DSL, File.expand_path("plugin/dsl", __dir__) + autoload :Events, File.expand_path("plugin/events", __dir__) + autoload :Index, File.expand_path("plugin/index", __dir__) + autoload :Installer, File.expand_path("plugin/installer", __dir__) + autoload :SourceList, File.expand_path("plugin/source_list", __dir__) + autoload :UnloadedSource, File.expand_path("plugin/unloaded_source", __dir__) class MalformattedPlugin < PluginError; end class UndefinedCommandError < PluginError; end diff --git a/bundler/lib/bundler/plugin/unloaded_source.rb b/bundler/lib/bundler/plugin/unloaded_source.rb new file mode 100644 index 000000000000..948b6c7e1e13 --- /dev/null +++ b/bundler/lib/bundler/plugin/unloaded_source.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Bundler + module Plugin + # Stands in for a source handled by a plugin that is not loaded yet, so + # that the lockfile can still be parsed during the plugin install pass, + # and by external tools reading a lockfile without the plugin installed. + class UnloadedSource + include API::Source + + # Unlike real plugin sources, where the handling class encodes the + # source type, all unloaded sources share this class, so the type must + # be compared explicitly. + def ==(other) + super && options["type"] == other.options["type"] + end + + alias_method :eql?, :== + + def hash + [super, options["type"]].hash + end + end + end +end From 7acf040cebaa429aac4e9d7fd36043e94f16ce3f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 12 Jun 2026 12:51:47 +0900 Subject: [PATCH 2/3] Don't require source plugins to be installed to parse a lockfile Third-party tools like bundler-audit read lockfiles through Bundler::LockfileParser without evaluating the Gemfile, and a PLUGIN SOURCE block whose plugin isn't installed locally aborted the whole parse with UnknownSourceError, taking even DEPENDENCIES down with it. Fall back to the inert UnloadedSource placeholder instead, which also lets bundle install converge away a plugin source that was removed from the Gemfile. https://github.com/ruby/rubygems/issues/9614 Co-Authored-By: Claude Fable 5 --- bundler/lib/bundler/plugin.rb | 7 ++++++- bundler/spec/bundler/lockfile_parser_spec.rb | 19 +++++++++++++++++++ bundler/spec/bundler/plugin_spec.rb | 9 +++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/bundler/lib/bundler/plugin.rb b/bundler/lib/bundler/plugin.rb index 16008a9e34a9..637b61251756 100644 --- a/bundler/lib/bundler/plugin.rb +++ b/bundler/lib/bundler/plugin.rb @@ -200,9 +200,14 @@ def source(name) # @return [API::Source] the instance of the class that handles the source # type passed in locked_opts def from_lock(locked_opts) + opts = locked_opts.merge("uri" => locked_opts["remote"]) + # use an inert placeholder when the plugin handling this source is not + # installed, so that the lockfile can still be parsed + return UnloadedSource.new(opts) unless source?(locked_opts["type"]) + src = source(locked_opts["type"]) - src.new(locked_opts.merge("uri" => locked_opts["remote"])) + src.new(opts) end # To be called via the API to register a hooks and corresponding block that diff --git a/bundler/spec/bundler/lockfile_parser_spec.rb b/bundler/spec/bundler/lockfile_parser_spec.rb index 4b493a37576b..7bf13ee64da3 100644 --- a/bundler/spec/bundler/lockfile_parser_spec.rb +++ b/bundler/spec/bundler/lockfile_parser_spec.rb @@ -252,6 +252,25 @@ end end + context "when a plugin source's plugin is not installed" do + let(:lockfile_contents) { <<~L + super().sub("DEPENDENCIES\n", "DEPENDENCIES\n private_gem!\n") } + PLUGIN SOURCE + remote: https://example.com/private + type: not_installed_plugin_type + specs: + private_gem (1.2.3) + + L + + it "parses dependencies and specs using a placeholder source" do + expect(subject.valid?).to be(true) + expect(subject.dependencies.keys).to include("private_gem", "peiji-san", "rake") + private_spec = subject.specs.find {|s| s.name == "private_gem" } + expect(private_spec.version).to eq(v("1.2.3")) + expect(private_spec.source).to be_a(Bundler::Plugin::UnloadedSource) + end + end + context "when CHECKSUMS has duplicate checksums in the lockfile that don't match" do let(:bad_checksum) { "sha256=c0ffee11c0ffee11c0ffee11c0ffee11c0ffee11c0ffee11c0ffee11c0ffee11" } let(:lockfile_contents) { super().split(/(?<=CHECKSUMS\n)/m).insert(1, " rake (10.3.2) #{bad_checksum}\n").join } diff --git a/bundler/spec/bundler/plugin_spec.rb b/bundler/spec/bundler/plugin_spec.rb index b379594c6f9a..27f93f8fe298 100644 --- a/bundler/spec/bundler/plugin_spec.rb +++ b/bundler/spec/bundler/plugin_spec.rb @@ -238,6 +238,15 @@ with(hash_including("type" => "l_source", "uri" => "xyz", "other" => "random")) { s_instance } expect(subject.from_lock(opts)).to be(s_instance) end + + it "returns an UnloadedSource when the plugin handling the source is not installed" do + opts = { "type" => "missing_source", "remote" => "https://example.com/private" } + allow(index).to receive(:source_plugin).with("missing_source") { nil } + + source = subject.from_lock(opts) + expect(source).to be_a(Plugin::UnloadedSource) + expect(source.uri).to eq("https://example.com/private") + end end describe "#root" do From f83855b538cb15c5fbe3101669421d18137ef75f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 12 Jun 2026 14:08:22 +0900 Subject: [PATCH 3/3] Add specs for Plugin::UnloadedSource equality Co-Authored-By: Claude Fable 5 --- .../bundler/plugin/unloaded_source_spec.rb | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 bundler/spec/bundler/plugin/unloaded_source_spec.rb diff --git a/bundler/spec/bundler/plugin/unloaded_source_spec.rb b/bundler/spec/bundler/plugin/unloaded_source_spec.rb new file mode 100644 index 000000000000..abc048866f5c --- /dev/null +++ b/bundler/spec/bundler/plugin/unloaded_source_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +RSpec.describe Bundler::Plugin::UnloadedSource do + let(:uri) { "uri://to/test" } + + def source(type) + described_class.new("uri" => uri, "type" => type) + end + + describe "equality" do + it "treats sources with the same uri and type as equal" do + a = source("type_a") + b = source("type_a") + + expect(a).to eq(b) + expect(a).to eql(b) + expect(a.hash).to eq(b.hash) + end + + it "treats sources with the same uri but different types as not equal" do + a = source("type_a") + b = source("type_b") + + expect(a).not_to eq(b) + expect(a).not_to eql(b) + expect(a.hash).not_to eq(b.hash) + end + + it "is not equal to a real plugin source with the same uri and type" do + klass = Class.new + klass.send :include, Bundler::Plugin::API::Source + + expect(source("type_a")).not_to eq(klass.new("uri" => uri, "type" => "type_a")) + end + end +end