Skip to content

Commit c2611a4

Browse files
committed
Write gem files atomically
This change updates `write_binary` to use a new class, `AtomicFile.write` to write the gem's files. This implementation is borrowed from Active Support's [`atomic_write`](https://github.com/rails/rails/blob/main/activesupport/lib/active_support/core_ext/file/atomic.rb). Atomic write will write the files to a temporary file and then once created, sets permissions and renames the file. If the file is corrupted - ie on failed download, an error occurs, or for some other reason, the real file will not be created. The changes made here make `verify_gz` obsolete, we don't need to verify it if we have successfully created the file atomically. If it exists, it is not corrupt. If it is corrupt, the file won't exist on disk. While writing tests for this functionality I replaced the `RemoteFetcher` stub with `FakeFetcher` except for where we really do need to overwrite the `RemoteFetcher`. The new test implementation is much clearer on what it's trying to accomplish versus the prior test implementation.
1 parent 14ebde1 commit c2611a4

File tree

5 files changed

+148
-90
lines changed

5 files changed

+148
-90
lines changed

Manifest.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,7 @@ lib/rubygems/uri.rb
529529
lib/rubygems/uri_formatter.rb
530530
lib/rubygems/user_interaction.rb
531531
lib/rubygems/util.rb
532+
lib/rubygems/util/atomic_file.rb
532533
lib/rubygems/util/licenses.rb
533534
lib/rubygems/util/list.rb
534535
lib/rubygems/validator.rb

lib/rubygems.rb

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ module Gem
1717
require_relative "rubygems/errors"
1818
require_relative "rubygems/target_rbconfig"
1919
require_relative "rubygems/win_platform"
20+
require_relative "rubygems/util/atomic_file"
2021

2122
##
2223
# RubyGems is the Ruby standard for publishing and managing third party
@@ -833,14 +834,12 @@ def self.read_binary(path)
833834
end
834835

835836
##
836-
# Safely write a file in binary mode on all platforms.
837+
# Atomically write a file in binary mode on all platforms.
837838

838839
def self.write_binary(path, data)
839-
File.binwrite(path, data)
840-
rescue Errno::ENOSPC
841-
# If we ran out of space but the file exists, it's *guaranteed* to be corrupted.
842-
File.delete(path) if File.exist?(path)
843-
raise
840+
Gem::AtomicFile.write(path) do |file|
841+
file.write(data)
842+
end
844843
end
845844

846845
##

lib/rubygems/util/atomic_file.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# frozen_string_literal: true
2+
3+
module Gem
4+
class AtomicFile
5+
##
6+
# Write to a file atomically. Useful for situations where you don't
7+
# want other processes or threads to see half-written files.
8+
9+
def self.write(file_name)
10+
temp_dir = File.dirname(file_name)
11+
require "tempfile" unless defined?(Tempfile)
12+
13+
Tempfile.open(".#{File.basename(file_name)}", temp_dir) do |temp_file|
14+
temp_file.binmode
15+
return_file = yield temp_file
16+
temp_file.close
17+
18+
original_permissions = if File.exist?(file_name)
19+
File.stat(file_name)
20+
else
21+
# If not possible, probe which are the default permissions in the
22+
# destination directory.
23+
probe_permissions_in(File.dirname(file_name))
24+
end
25+
26+
# Set correct permissions on new file
27+
if original_permissions
28+
begin
29+
File.chown(original_permissions.uid, original_permissions.gid, temp_file.path)
30+
File.chmod(original_permissions.mode, temp_file.path)
31+
rescue Errno::EPERM, Errno::EACCES
32+
# Changing file ownership failed, moving on.
33+
end
34+
end
35+
36+
# Overwrite original file with temp file
37+
File.rename(temp_file.path, file_name)
38+
return_file
39+
end
40+
end
41+
42+
def self.probe_permissions_in(dir) # :nodoc:
43+
require "fileutils" unless defined?(FileUtils)
44+
45+
basename = [
46+
".permissions_check",
47+
Thread.current.object_id,
48+
Process.pid,
49+
rand(1000000)
50+
].join(".")
51+
52+
file_name = File.join(dir, basename)
53+
FileUtils.touch(file_name)
54+
File.stat(file_name)
55+
rescue Errno::ENOENT
56+
file_name = nil
57+
ensure
58+
FileUtils.rm_f(file_name) if file_name
59+
end
60+
end
61+
end

test/rubygems/test_gem_installer.rb

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2385,25 +2385,31 @@ def test_leaves_no_empty_cached_spec_when_no_more_disk_space
23852385
installer = Gem::Installer.for_spec @spec
23862386
installer.gem_home = @gemhome
23872387

2388-
File.singleton_class.class_eval do
2389-
alias_method :original_binwrite, :binwrite
2390-
2391-
def binwrite(path, data)
2388+
assert_raise(Errno::ENOSPC) do
2389+
Gem::AtomicFile.write(@spec.spec_file) do
23922390
raise Errno::ENOSPC
23932391
end
23942392
end
23952393

2396-
assert_raise Errno::ENOSPC do
2397-
installer.write_spec
2398-
end
2399-
24002394
assert_path_not_exist @spec.spec_file
2401-
ensure
2402-
File.singleton_class.class_eval do
2403-
remove_method :binwrite
2404-
alias_method :binwrite, :original_binwrite
2405-
remove_method :original_binwrite
2406-
end
2395+
end
2396+
2397+
def test_write_default_spec
2398+
@spec = setup_base_spec
2399+
@spec.files = %w[a.rb b.rb c.rb]
2400+
2401+
installer = Gem::Installer.for_spec @spec
2402+
installer.gem_home = @gemhome
2403+
2404+
installer.write_default_spec
2405+
2406+
assert_path_exist installer.default_spec_file
2407+
2408+
loaded = Gem::Specification.load installer.default_spec_file
2409+
2410+
assert_equal @spec.files, loaded.files
2411+
assert_equal @spec.name, loaded.name
2412+
assert_equal @spec.version, loaded.version
24072413
end
24082414

24092415
def test_dir

test/rubygems/test_gem_remote_fetcher.rb

Lines changed: 61 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def test_cache_update_path
6060
uri = Gem::URI "http://example/file"
6161
path = File.join @tempdir, "file"
6262

63-
fetcher = util_fuck_with_fetcher "hello"
63+
fetcher = fake_fetcher(uri.to_s, "hello")
6464

6565
data = fetcher.cache_update_path uri, path
6666

@@ -75,7 +75,7 @@ def test_cache_update_path_with_utf8_internal_encoding
7575
path = File.join @tempdir, "file"
7676
data = String.new("\xC8").force_encoding(Encoding::BINARY)
7777

78-
fetcher = util_fuck_with_fetcher data
78+
fetcher = fake_fetcher(uri.to_s, data)
7979

8080
written_data = fetcher.cache_update_path uri, path
8181

@@ -88,7 +88,7 @@ def test_cache_update_path_no_update
8888
uri = Gem::URI "http://example/file"
8989
path = File.join @tempdir, "file"
9090

91-
fetcher = util_fuck_with_fetcher "hello"
91+
fetcher = fake_fetcher(uri.to_s, "hello")
9292

9393
data = fetcher.cache_update_path uri, path, false
9494

@@ -97,103 +97,79 @@ def test_cache_update_path_no_update
9797
assert_path_not_exist path
9898
end
9999

100-
def util_fuck_with_fetcher(data, blow = false)
101-
fetcher = Gem::RemoteFetcher.fetcher
102-
fetcher.instance_variable_set :@test_data, data
103-
104-
if blow
105-
def fetcher.fetch_path(arg, *rest)
106-
# OMG I'm such an ass
107-
class << self; remove_method :fetch_path; end
108-
def self.fetch_path(arg, *rest)
109-
@test_arg = arg
110-
@test_data
111-
end
100+
def test_cache_update_path_overwrites_existing_file
101+
uri = Gem::URI "http://example/file"
102+
path = File.join @tempdir, "file"
112103

113-
raise Gem::RemoteFetcher::FetchError.new("haha!", "")
114-
end
115-
else
116-
def fetcher.fetch_path(arg, *rest)
117-
@test_arg = arg
118-
@test_data
119-
end
120-
end
104+
# Create existing file with old content
105+
File.write(path, "old content")
106+
assert_equal "old content", File.read(path)
121107

122-
fetcher
108+
fetcher = fake_fetcher(uri.to_s, "new content")
109+
110+
data = fetcher.cache_update_path uri, path
111+
112+
assert_equal "new content", data
113+
assert_equal "new content", File.read(path)
123114
end
124115

125116
def test_download
126-
a1_data = nil
127-
File.open @a1_gem, "rb" do |fp|
128-
a1_data = fp.read
129-
end
117+
a1_data = File.open @a1_gem, "rb", &:read
118+
a1_url = "http://gems.example.com/gems/a-1.gem"
130119

131-
fetcher = util_fuck_with_fetcher a1_data
120+
fetcher = fake_fetcher(a1_url, a1_data)
132121

133122
a1_cache_gem = @a1.cache_file
134123
assert_equal a1_cache_gem, fetcher.download(@a1, "http://gems.example.com")
135-
assert_equal("http://gems.example.com/gems/a-1.gem",
136-
fetcher.instance_variable_get(:@test_arg).to_s)
124+
assert_equal a1_url, fetcher.paths.last
137125
assert File.exist?(a1_cache_gem)
138126
end
139127

140128
def test_download_with_auth
141-
a1_data = nil
142-
File.open @a1_gem, "rb" do |fp|
143-
a1_data = fp.read
144-
end
129+
a1_data = File.open @a1_gem, "rb", &:read
130+
a1_url = "http://user:[email protected]/gems/a-1.gem"
145131

146-
fetcher = util_fuck_with_fetcher a1_data
132+
fetcher = fake_fetcher(a1_url, a1_data)
147133

148134
a1_cache_gem = @a1.cache_file
149135
assert_equal a1_cache_gem, fetcher.download(@a1, "http://user:[email protected]")
150-
assert_equal("http://user:[email protected]/gems/a-1.gem",
151-
fetcher.instance_variable_get(:@test_arg).to_s)
136+
assert_equal a1_url, fetcher.paths.last
152137
assert File.exist?(a1_cache_gem)
153138
end
154139

155140
def test_download_with_token
156-
a1_data = nil
157-
File.open @a1_gem, "rb" do |fp|
158-
a1_data = fp.read
159-
end
141+
a1_data = File.open @a1_gem, "rb", &:read
142+
a1_url = "http://[email protected]/gems/a-1.gem"
160143

161-
fetcher = util_fuck_with_fetcher a1_data
144+
fetcher = fake_fetcher(a1_url, a1_data)
162145

163146
a1_cache_gem = @a1.cache_file
164147
assert_equal a1_cache_gem, fetcher.download(@a1, "http://[email protected]")
165-
assert_equal("http://[email protected]/gems/a-1.gem",
166-
fetcher.instance_variable_get(:@test_arg).to_s)
148+
assert_equal a1_url, fetcher.paths.last
167149
assert File.exist?(a1_cache_gem)
168150
end
169151

170152
def test_download_with_x_oauth_basic
171-
a1_data = nil
172-
File.open @a1_gem, "rb" do |fp|
173-
a1_data = fp.read
174-
end
153+
a1_data = File.open @a1_gem, "rb", &:read
154+
a1_url = "http://token:[email protected]/gems/a-1.gem"
175155

176-
fetcher = util_fuck_with_fetcher a1_data
156+
fetcher = fake_fetcher(a1_url, a1_data)
177157

178158
a1_cache_gem = @a1.cache_file
179159
assert_equal a1_cache_gem, fetcher.download(@a1, "http://token:[email protected]")
180-
assert_equal("http://token:[email protected]/gems/a-1.gem",
181-
fetcher.instance_variable_get(:@test_arg).to_s)
160+
assert_equal a1_url, fetcher.paths.last
182161
assert File.exist?(a1_cache_gem)
183162
end
184163

185164
def test_download_with_encoded_auth
186-
a1_data = nil
187-
File.open @a1_gem, "rb" do |fp|
188-
a1_data = fp.read
189-
end
165+
a1_data = File.open @a1_gem, "rb", &:read
166+
a1_url = "http://user:%25pas%[email protected]/gems/a-1.gem"
190167

191-
fetcher = util_fuck_with_fetcher a1_data
168+
fetcher = fake_fetcher(a1_url, a1_data)
192169

193170
a1_cache_gem = @a1.cache_file
194171
assert_equal a1_cache_gem, fetcher.download(@a1, "http://user:%25pas%[email protected]")
195-
assert_equal("http://user:%25pas%[email protected]/gems/a-1.gem",
196-
fetcher.instance_variable_get(:@test_arg).to_s)
172+
assert_equal a1_url, fetcher.paths.last
197173
assert File.exist?(a1_cache_gem)
198174
end
199175

@@ -235,8 +211,9 @@ def test_download_local_space
235211

236212
def test_download_install_dir
237213
a1_data = File.open @a1_gem, "rb", &:read
214+
a1_url = "http://gems.example.com/gems/a-1.gem"
238215

239-
fetcher = util_fuck_with_fetcher a1_data
216+
fetcher = fake_fetcher(a1_url, a1_data)
240217

241218
install_dir = File.join @tempdir, "more_gems"
242219

@@ -245,8 +222,7 @@ def test_download_install_dir
245222
actual = fetcher.download(@a1, "http://gems.example.com", install_dir)
246223

247224
assert_equal a1_cache_gem, actual
248-
assert_equal("http://gems.example.com/gems/a-1.gem",
249-
fetcher.instance_variable_get(:@test_arg).to_s)
225+
assert_equal a1_url, fetcher.paths.last
250226

251227
assert File.exist?(a1_cache_gem)
252228
end
@@ -282,7 +258,12 @@ def test_download_read_only
282258
FileUtils.chmod 0o555, @a1.cache_dir
283259
FileUtils.chmod 0o555, @gemhome
284260

285-
fetcher = util_fuck_with_fetcher File.read(@a1_gem)
261+
fetcher = Gem::RemoteFetcher.fetcher
262+
def fetcher.fetch_path(uri, *rest)
263+
File.read File.join(@test_gem_dir, "a-1.gem")
264+
end
265+
fetcher.instance_variable_set(:@test_gem_dir, File.dirname(@a1_gem))
266+
286267
fetcher.download(@a1, "http://gems.example.com")
287268
a1_cache_gem = File.join Gem.user_dir, "cache", @a1.file_name
288269
assert File.exist? a1_cache_gem
@@ -301,19 +282,21 @@ def test_download_platform_legacy
301282
end
302283
e1.loaded_from = File.join(@gemhome, "specifications", e1.full_name)
303284

304-
e1_data = nil
305-
File.open e1_gem, "rb" do |fp|
306-
e1_data = fp.read
307-
end
285+
e1_data = File.open e1_gem, "rb", &:read
308286

309-
fetcher = util_fuck_with_fetcher e1_data, :blow_chunks
287+
fetcher = Gem::RemoteFetcher.fetcher
288+
def fetcher.fetch_path(uri, *rest)
289+
@call_count ||= 0
290+
@call_count += 1
291+
raise Gem::RemoteFetcher::FetchError.new("error", uri) if @call_count == 1
292+
@test_data
293+
end
294+
fetcher.instance_variable_set(:@test_data, e1_data)
310295

311296
e1_cache_gem = e1.cache_file
312297

313298
assert_equal e1_cache_gem, fetcher.download(e1, "http://gems.example.com")
314299

315-
assert_equal("http://gems.example.com/gems/#{e1.original_name}.gem",
316-
fetcher.instance_variable_get(:@test_arg).to_s)
317300
assert File.exist?(e1_cache_gem)
318301
end
319302

@@ -592,6 +575,8 @@ def test_yaml_error_on_size
592575
end
593576
end
594577

578+
private
579+
595580
def assert_error(exception_class = Exception)
596581
got_exception = false
597582

@@ -603,4 +588,10 @@ def assert_error(exception_class = Exception)
603588

604589
assert got_exception, "Expected exception conforming to #{exception_class}"
605590
end
591+
592+
def fake_fetcher(url, data)
593+
fetcher = Gem::FakeFetcher.new
594+
fetcher.data[url] = data
595+
Gem::RemoteFetcher.fetcher = fetcher
596+
end
606597
end

0 commit comments

Comments
 (0)