From 9568d9600c2500d69f5e04c9f05fabdf3665ba37 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 10 Jul 2026 08:22:35 +0900 Subject: [PATCH 1/3] Open compact index cache in binary mode when appending CacheFile#append opened the temp file with mode "a", which on Windows is a text-mode stream that rewrites every "\n" as "\r\n". The corruption goes undetected because DigestIO hashes the logical bytes handed to write, not the bytes the OS actually lands on disk, so verify passes and the mangled file is committed. The damage compounds on the next update: the extra carriage returns shift file.size, so the ranged request restarts at the wrong offset and the versions index no longer matches its expected digest, forcing a full re-download every time. The info files keep failing their MD5 check against the raw index bytes, so they are refetched on every resolve. Open the append stream with "ab" so the appended range is written verbatim, matching the binary "wb" used for full writes. --- .../compact_index_client/cache_file.rb | 2 +- .../compact_index_client/cache_file.rb | 2 +- .../compact_index_client/cache_file_spec.rb | 29 +++++++++++++++++++ ...est_gem_compact_index_client_cache_file.rb | 17 +++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 spec/bundler/compact_index_client/cache_file_spec.rb diff --git a/lib/bundler/compact_index_client/cache_file.rb b/lib/bundler/compact_index_client/cache_file.rb index 299d683438b8..03659aeac19e 100644 --- a/lib/bundler/compact_index_client/cache_file.rb +++ b/lib/bundler/compact_index_client/cache_file.rb @@ -103,7 +103,7 @@ def open(write_mode = "wb", perm = @perm, &block) # Returns false without appending when no digests since appending is too error prone to do without digests. def append(data) return false unless digests? - open("a") {|f| f.write data } + open("ab") {|f| f.write data } verify && commit end diff --git a/lib/rubygems/compact_index_client/cache_file.rb b/lib/rubygems/compact_index_client/cache_file.rb index 3656645d8220..00e5f541ddf6 100644 --- a/lib/rubygems/compact_index_client/cache_file.rb +++ b/lib/rubygems/compact_index_client/cache_file.rb @@ -99,7 +99,7 @@ def open(write_mode = "wb", perm = @perm, &block) # Returns false without appending when no digests since appending is too error prone to do without digests. def append(data) return false unless digests? - open("a") {|f| f.write data } + open("ab") {|f| f.write data } verify && commit end diff --git a/spec/bundler/compact_index_client/cache_file_spec.rb b/spec/bundler/compact_index_client/cache_file_spec.rb new file mode 100644 index 000000000000..c106b2cc52ad --- /dev/null +++ b/spec/bundler/compact_index_client/cache_file_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "bundler/compact_index_client" +require "bundler/compact_index_client/cache_file" + +RSpec.describe Bundler::CompactIndexClient::CacheFile do + let(:path) { Pathname.new(Dir.mktmpdir("localpath")).join("versions") } + + def sha256(data) + { "sha-256" => Digest::SHA256.base64digest(data) } + end + + it "appends in binary mode so line endings are preserved" do + path.binwrite "created_at: 2026-06-10\n---\n" + + appended = nil + described_class.copy(path) do |file| + file.digests = sha256("created_at: 2026-06-10\n---\nrake 13.0.0\n") + appended = file.append("rake 13.0.0\n") + end + + expect(appended).to be_truthy + # On Windows a text-mode append rewrites the appended LF as CRLF while the + # digest is computed over the pre-write bytes, so verify passes but the file + # on disk is corrupted. Read raw bytes to catch any stray carriage return. + expect(path.binread).not_to include("\r") + expect(path.binread).to eq("created_at: 2026-06-10\n---\nrake 13.0.0\n") + end +end diff --git a/test/rubygems/test_gem_compact_index_client_cache_file.rb b/test/rubygems/test_gem_compact_index_client_cache_file.rb index 193ae327e9c2..f2e2633646b4 100644 --- a/test/rubygems/test_gem_compact_index_client_cache_file.rb +++ b/test/rubygems/test_gem_compact_index_client_cache_file.rb @@ -82,6 +82,23 @@ def test_append_with_matching_digests assert_equal "abcdef", @path.read end + def test_append_writes_in_binary_mode + @path.binwrite "created_at: 2026-06-10\n---\n" + + appended = nil + CacheFile.copy(@path) do |file| + file.digests = sha256("created_at: 2026-06-10\n---\nrake 13.0.0\n") + appended = file.append("rake 13.0.0\n") + end + + assert appended + # On Windows a text-mode append rewrites the appended LF as CRLF while the + # digest is computed over the pre-write bytes, so verify passes but the file + # on disk is corrupted. Read raw bytes to catch any stray carriage return. + refute_includes @path.binread, "\r" + assert_equal "created_at: 2026-06-10\n---\nrake 13.0.0\n", @path.binread + end + def test_append_with_mismatched_digests_keeps_original @path.binwrite "abc" From f9eb1c1b223b8832c1bb8bee5a3cdd500694ce85 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 15 Jul 2026 16:41:07 +0900 Subject: [PATCH 2/3] Require tmpdir in compact index cache_file spec The spec calls Dir.mktmpdir in a let block, but neither bundler nor the spec_helper loads tmpdir, so the example raised NoMethodError before running. The sibling updater_spec already requires it explicitly. --- spec/bundler/compact_index_client/cache_file_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/bundler/compact_index_client/cache_file_spec.rb b/spec/bundler/compact_index_client/cache_file_spec.rb index c106b2cc52ad..c9a87c3c2d44 100644 --- a/spec/bundler/compact_index_client/cache_file_spec.rb +++ b/spec/bundler/compact_index_client/cache_file_spec.rb @@ -2,6 +2,7 @@ require "bundler/compact_index_client" require "bundler/compact_index_client/cache_file" +require "tmpdir" RSpec.describe Bundler::CompactIndexClient::CacheFile do let(:path) { Pathname.new(Dir.mktmpdir("localpath")).join("versions") } From 537479a45ae119531196860438c395fc85d93622 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 15 Jul 2026 19:39:15 +0900 Subject: [PATCH 3/3] Assign compact index cache_file spec to a shard CI runs bundler specs with `--tag shard_`, so a spec file that is not listed in spec/support/shards.rb runs in no shard at all. Register the new cache_file_spec.rb under shard_d as the file header instructs. --- spec/support/shards.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/support/shards.rb b/spec/support/shards.rb index 5718e775b23a..8416190d000b 100644 --- a/spec/support/shards.rb +++ b/spec/support/shards.rb @@ -143,6 +143,7 @@ module Shards "spec/bundler/ci_detector_spec.rb", ], shard_d: [ + "spec/bundler/compact_index_client/cache_file_spec.rb", "spec/bundler/rubygems_ext_spec.rb", "spec/bundler/resolver/cooldown_spec.rb", "spec/install/cooldown_spec.rb",