From 6de9b36049ed1d40a9c7f30fde2d0a4beec8cdbe Mon Sep 17 00:00:00 2001 From: Matthias Zirnstein Date: Wed, 5 Nov 2025 10:51:55 +0100 Subject: [PATCH 1/3] fix: Ensure trailing slash is added to source URIs added via gem sources GitHub's private gem registry expects the first path segment after the host to represent the namespace, typically the organization or user name. [1] When adding a source with ``` gem sources --add https://user:password@rubygems.pkg.github.com/my-org ``` without a trailing slash, the last path segment ("my-org") is interpreted as a file and removed during relative path resolution. This causes the resulting URI to become ``` https://user:password@rubygems.pkg.github.com/gems/foo.gem ``` instead of the correct ``` https://user:password@rubygems.pkg.github.com/my-org/gems/foo.gem. [2] ``` Example error: ``` gem source -a https://user:password@rubygems.pkg.github.com/my-org gem install -rf foo.gem rubygems/remote_fetcher.rb:238:in `fetch_http': bad response Not Found 404 (https://user:REDACTED@rubygems.pkg.github.com/gems/foo-0.7.1.gem) (Gem::RemoteFetcher::FetchError) ``` Although this behavior complies with RFC 2396, it's incompatible with GitHub's gem registry requirements. The remote fetcher is just append a relative path without using ./ [3] To address this, we automatically append a trailing slash when adding new gem sources. As illustrated in [4] and [5], given the base URI ``` http://a/b/c/d;p?q ``` and a relative path ``` g/f ``` the resolution process replaces "d;p?q" and yields ``` http://a/b/c/g/f ``` [1] https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-rubygems-registry#authenticating-with-a-personal-access-token [2] https://github.com/ruby/rubygems/blob/master/lib/rubygems/vendor/uri/lib/uri/generic.rb#L1053 [3] https://github.com/ruby/rubygems/blob/master/lib/rubygems/remote_fetcher.rb#L148 [4] https://www.rfc-editor.org/rfc/rfc2396#section-5.2 [5] https://www.rfc-editor.org/rfc/rfc2396#appendix-C --- lib/rubygems/commands/sources_command.rb | 16 ++++ .../test_gem_commands_sources_command.rb | 84 ++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/lib/rubygems/commands/sources_command.rb b/lib/rubygems/commands/sources_command.rb index 7e5c2a2465e6..95be9d334b33 100644 --- a/lib/rubygems/commands/sources_command.rb +++ b/lib/rubygems/commands/sources_command.rb @@ -50,6 +50,7 @@ def initialize end def add_source(source_uri) # :nodoc: + source_uri = add_trailing_slash(source_uri) check_rubygems_https source_uri source = Gem::Source.new source_uri @@ -76,6 +77,7 @@ def add_source(source_uri) # :nodoc: end def append_source(source_uri) # :nodoc: + source_uri = add_trailing_slash(source_uri) check_rubygems_https source_uri source = Gem::Source.new source_uri @@ -103,6 +105,7 @@ def append_source(source_uri) # :nodoc: end def prepend_source(source_uri) # :nodoc: + source_uri = add_trailing_slash(source_uri) check_rubygems_https source_uri source = Gem::Source.new source_uri @@ -141,6 +144,19 @@ def check_typo_squatting(source) end end + def add_trailing_slash(source_uri) # :nodoc: + # Ensure the source URI has a trailing slash for proper RFC 2396 path merging + # Without a trailing slash, the last path segment is treated as a file and removed + # during relative path resolution (e.g., "/blish" + "gems/foo.gem" = "/gems/foo.gem") + # With a trailing slash, it's treated as a directory (e.g., "/blish/" + "gems/foo.gem" = "/blish/gems/foo.gem") + uri = Gem::URI.parse(source_uri) + uri.path = uri.path.gsub(%r{/+$}, "") + "/" if uri.path && !uri.path.empty? + uri.to_s + rescue Gem::URI::Error + # If parsing fails, return the original URI and let later validation handle it + source_uri + end + def check_rubygems_https(source_uri) # :nodoc: uri = Gem::URI source_uri diff --git a/test/rubygems/test_gem_commands_sources_command.rb b/test/rubygems/test_gem_commands_sources_command.rb index 00eb9239940e..62795e5665b9 100644 --- a/test/rubygems/test_gem_commands_sources_command.rb +++ b/test/rubygems/test_gem_commands_sources_command.rb @@ -60,6 +60,81 @@ def test_execute_add assert_equal "", @ui.error end + def test_execute_add_without_trailing_slash + setup_fake_source('https://rubygems.pkg.github.com/my-org') + + @cmd.handle_options %W[--add https://rubygems.pkg.github.com/my-org] + + use_ui @ui do + @cmd.execute + end + + assert_equal [@gem_repo, 'https://rubygems.pkg.github.com/my-org/'], Gem.sources + + expected = <<-EOF +https://rubygems.pkg.github.com/my-org/ added to sources + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + def test_execute_add_multiple_trailing_slash + setup_fake_source('https://rubygems.pkg.github.com/my-org/') + + @cmd.handle_options %W[--add https://rubygems.pkg.github.com/my-org///] + + use_ui @ui do + @cmd.execute + end + + assert_equal [@gem_repo, 'https://rubygems.pkg.github.com/my-org/'], Gem.sources + + expected = <<-EOF +https://rubygems.pkg.github.com/my-org/ added to sources + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + + def test_execute_append_without_trailing_slash + setup_fake_source('https://rubygems.pkg.github.com/my-org') + + @cmd.handle_options %W[--append https://rubygems.pkg.github.com/my-org] + + use_ui @ui do + @cmd.execute + end + + assert_equal [@gem_repo, 'https://rubygems.pkg.github.com/my-org/'], Gem.sources + + expected = <<-EOF +https://rubygems.pkg.github.com/my-org/ added to sources + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + + def test_execute_prepend_without_trailing_slash + setup_fake_source('https://rubygems.pkg.github.com/my-org') + + @cmd.handle_options %W[--prepend https://rubygems.pkg.github.com/my-org] + + use_ui @ui do + @cmd.execute + end + + assert_equal [@gem_repo, 'https://rubygems.pkg.github.com/my-org/'], Gem.sources + + expected = <<-EOF +https://rubygems.pkg.github.com/my-org/ added to sources + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end + def test_execute_append setup_fake_source(@new_repo) @@ -583,7 +658,7 @@ def test_execute_add_bad_uri assert_equal [@gem_repo], Gem.sources expected = <<-EOF -beta-gems.example.com is not a URI +beta-gems.example.com/ is not a URI EOF assert_equal expected, @ui.output @@ -602,7 +677,12 @@ def test_execute_append_bad_uri assert_equal [@gem_repo], Gem.sources expected = <<-EOF -beta-gems.example.com is not a URI +beta-gems.example.com/ is not a URI + EOF + + assert_equal expected, @ui.output + assert_equal "", @ui.error + end EOF assert_equal expected, @ui.output From ade17551b323a182a540300cc5233cd764e60f95 Mon Sep 17 00:00:00 2001 From: Matthias Zirnstein Date: Wed, 5 Nov 2025 10:52:21 +0100 Subject: [PATCH 2/3] spec: Add missing bad uri test for prepend for gem sources --- test/rubygems/test_gem_commands_sources_command.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/rubygems/test_gem_commands_sources_command.rb b/test/rubygems/test_gem_commands_sources_command.rb index 62795e5665b9..60df6d9a8055 100644 --- a/test/rubygems/test_gem_commands_sources_command.rb +++ b/test/rubygems/test_gem_commands_sources_command.rb @@ -683,6 +683,20 @@ def test_execute_append_bad_uri assert_equal expected, @ui.output assert_equal "", @ui.error end + + def test_execute_prepend_bad_uri + @cmd.handle_options %w[--prepend beta-gems.example.com] + + use_ui @ui do + assert_raise Gem::MockGemUi::TermError do + @cmd.execute + end + end + + assert_equal [@gem_repo], Gem.sources + + expected = <<-EOF +beta-gems.example.com/ is not a URI EOF assert_equal expected, @ui.output From a9fbf4c44766fc29b8a4847dfd7bb31799ab3fc5 Mon Sep 17 00:00:00 2001 From: Matthias Zirnstein Date: Sun, 9 Nov 2025 08:54:51 +0100 Subject: [PATCH 3/3] Update test/rubygems/test_gem_commands_sources_command.rb Co-authored-by: Sutou Kouhei --- test/rubygems/test_gem_commands_sources_command.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/rubygems/test_gem_commands_sources_command.rb b/test/rubygems/test_gem_commands_sources_command.rb index 60df6d9a8055..a0f11bf04ccd 100644 --- a/test/rubygems/test_gem_commands_sources_command.rb +++ b/test/rubygems/test_gem_commands_sources_command.rb @@ -78,6 +78,7 @@ def test_execute_add_without_trailing_slash assert_equal expected, @ui.output assert_equal "", @ui.error end + def test_execute_add_multiple_trailing_slash setup_fake_source('https://rubygems.pkg.github.com/my-org/')