From 9568d9600c2500d69f5e04c9f05fabdf3665ba37 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 10 Jul 2026 08:22:35 +0900 Subject: [PATCH] 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"