Skip to content

Commit af7b1b5

Browse files
committed
Install content-addressable gemspecs under specifications/<ruby_abi>/
A content-addressable gemspec in the flat specifications/ directory poisons a shared GEM_HOME for older RubyGems: the stub line carries the content address where the platform used to be, so an older StubLine parses it as platform unknown and derives a gem_dir that does not exist. gem list shows the gem while gem "name" raises MissingSpecError. Older RubyGems globs specifications/*.gemspec non-recursively (Gem::Util.glob_files_in_dir), so a subdirectory is invisible to it by construction: nothing to parse, nothing to misinterpret. specifications/default/ and plugins/<ruby_abi>/ are the existing precedents. The read side only adds the running Ruby's ABI subdirectory, so a CA build for another ABI sharing the GEM_HOME is never an activation candidate. This also fixes two CA builds of one name-version having identical sort_obj values, where Dependency#to_spec could activate a ~> X.Y.0 build on a different Ruby with no warning. Layout: GEM_HOME/ specifications/ rake-13.3.1.gemspec # ordinary gems, unchanged 3.4/ nokogiri-1.19.4-86e5e59f.gemspec # CA gems, ABI-scoped gems/ nokogiri-1.19.4-86e5e59f/ # payload location unchanged Gem::SpecificationRecord is the single home for layout knowledge: - dirs_from expands each path to the flat specifications directory plus specifications/<Gem.ruby_abi> (read side, running Ruby) - abi_scoped_spec_dir? classifies a directory as ABI-scoped only when its basename is ABI-shaped AND its parent is named specifications, so unrelated paths like /tmp/3.4 are never misclassified - specification_dir_for maps a spec to its one directory (write side, keyed by the spec's pinned ABI, mirroring plugin_stub_dir_for) Installer#spec_file delegates to specification_dir_for; write_spec creates the ABI directory lazily via ensure_writable_dir and clamps it to --dir-mode only after the gemspec is written, matching the gem_dir/build_info_dir two-step (and surviving read-only dir modes). Specification#base_dir strips the extra path level for specs loaded from an ABI-scoped directory (like default_gem?), and #spec_dir points back at it, so uninstall and pristine work unchanged. Installer#installed_specs and RequestSet#specs_in glob through dirs_from so already-installed CA gems are recognized. Out of scope, tracked as follow-ups: gem doctor, bundle clean, gem contents --spec-dir, and migration of legacy flat CA gemspecs written by earlier 4.1.0.dev builds. Assisted-By: devx/8371356f-3924-46a0-be8e-70db09fa3352
1 parent 415026f commit af7b1b5

19 files changed

Lines changed: 451 additions & 32 deletions

lib/bundler/endpoint_specification.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ def _remote_specification
161161
end
162162

163163
def local_specification_path
164-
"#{base_dir}/specifications/#{full_name}.gemspec"
164+
File.join(Gem::SpecificationRecord.specification_dir_for(self, base_dir),
165+
"#{full_name}.gemspec")
165166
end
166167

167168
def parse_metadata(data)

lib/bundler/rubygems_ext.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@ def self.content_addressed_row?(suffix, platform, required_ruby_version = nil, v
4545
end
4646
end
4747

48+
# Can be removed once RubyGems 4.0.0 support is dropped
49+
class SpecificationRecord
50+
unless respond_to?(:specification_dir_for)
51+
def self.specification_dir_for(spec, base_dir)
52+
File.join(base_dir, "specifications")
53+
end
54+
end
55+
56+
unless respond_to?(:dirs_from)
57+
def self.dirs_from(paths)
58+
paths.map {|path| File.join(path, "specifications") }
59+
end
60+
end
61+
end
62+
4863
# Can be removed once RubyGems 3.5.11 support is dropped
4964
unless Gem.respond_to?(:freebsd_platform?)
5065
def self.freebsd_platform?

lib/bundler/rubygems_integration.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def gem_cache
111111

112112
def spec_cache_dirs
113113
@spec_cache_dirs ||= begin
114-
dirs = gem_path.map {|dir| File.join(dir, "specifications") }
114+
dirs = gem_path.flat_map {|dir| Gem::SpecificationRecord.dirs_from([dir]) }
115115
dirs << Gem.spec_cache_dir
116116
dirs.uniq.select {|dir| File.directory? dir }
117117
end

lib/rubygems/commands/lock_command.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ def execute
100100
end
101101

102102
def spec_path(gem_full_name)
103-
gemspecs = Gem.path.map do |path|
104-
File.join path, "specifications", "#{gem_full_name}.gemspec"
103+
gemspecs = Gem::SpecificationRecord.dirs_from(Gem.path).map do |spec_dir|
104+
File.join spec_dir, "#{gem_full_name}.gemspec"
105105
end
106106

107107
gemspecs.find {|path| File.exist? path }

lib/rubygems/content_address.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,11 @@ def self.ruby_abi_for(required_ruby_version)
5353
return nil if requirements.size != 1
5454

5555
op, version = requirements.first
56-
return nil if op != "~>" || version.segments.size != 3 || version.segments[2] != 0
56+
segments = version.segments
57+
return nil if op != "~>" || segments.size != 3 || segments[2] != 0
58+
return nil unless segments[0].is_a?(Integer) && segments[1].is_a?(Integer)
5759

58-
version.segments[0..1].join(".")
60+
segments[0..1].join(".")
5961
end
6062

6163
##

lib/rubygems/installer.rb

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,11 @@ def install
308308

309309
say clean_text(spec.post_install_message.to_s) if options[:post_install_message] && !spec.post_install_message.nil?
310310

311-
Gem::Specification.add_spec(spec) unless @install_dir
311+
if incompatible_abi_install?
312+
say "#{spec.full_name} is scoped to Ruby ABI #{spec.ruby_abi} and will not be visible to the running Ruby (ABI #{Gem.ruby_abi})"
313+
else
314+
Gem::Specification.add_spec(spec) unless @install_dir
315+
end
312316

313317
load_plugin unless options[:install_plugin] == false
314318

@@ -357,9 +361,11 @@ def installed_specs
357361
@installed_specs ||= begin
358362
specs = []
359363

360-
Gem::Util.glob_files_in_dir("*.gemspec", File.join(gem_home, "specifications")).each do |path|
361-
spec = Gem::Specification.load path
362-
specs << spec if spec
364+
Gem::SpecificationRecord.dirs_from([gem_home]).each do |dir|
365+
Gem::Util.glob_files_in_dir("*.gemspec", dir).each do |path|
366+
spec = Gem::Specification.load path
367+
specs << spec if spec
368+
end
363369
end
364370

365371
specs
@@ -395,7 +401,7 @@ def installation_satisfies_dependency?(dependency)
395401
#
396402

397403
def spec_file
398-
File.join gem_home, "specifications", "#{spec.full_name}.gemspec"
404+
File.join Gem::SpecificationRecord.specification_dir_for(spec, gem_home), "#{spec.full_name}.gemspec"
399405
end
400406

401407
def default_spec_dir
@@ -419,7 +425,24 @@ def default_spec_file
419425
def write_spec
420426
spec.installed_by_version = Gem.rubygems_version
421427

422-
Gem.write_binary(spec_file, spec.to_ruby_for_cache)
428+
spec_file = self.spec_file
429+
spec_dir = File.dirname spec_file
430+
dir_mode = options[:dir_mode]
431+
content_addressed = Gem::ContentAddress.content_addressed?(spec)
432+
433+
if File.directory? spec_dir
434+
if content_addressed && dir_mode && !File.writable?(spec_dir)
435+
File.chmod(0o755, spec_dir)
436+
end
437+
else
438+
ensure_writable_dir spec_dir
439+
end
440+
441+
begin
442+
Gem.write_binary(spec_file, spec.to_ruby_for_cache)
443+
ensure
444+
File.chmod(dir_mode, spec_dir) if dir_mode && content_addressed
445+
end
423446
end
424447

425448
##
@@ -973,6 +996,10 @@ def ensure_writable_dir(dir) # :nodoc:
973996

974997
private
975998

999+
def incompatible_abi_install?
1000+
Gem::ContentAddress.content_addressed?(spec) && spec.ruby_abi != Gem.ruby_abi
1001+
end
1002+
9761003
def assign_content_address
9771004
address = @package.content_address
9781005
expected = options[:content_address]

lib/rubygems/package.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ def extract_tar_gz(io, destination_dir, pattern = "*") # :nodoc:
556556
end
557557
end
558558

559-
if dir_mode
559+
if dir_mode && !directories.empty?
560560
File.chmod(dir_mode, *directories)
561561
end
562562
end

lib/rubygems/request_set.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -518,8 +518,10 @@ def specs
518518
end
519519

520520
def specs_in(dir)
521-
Gem::Util.glob_files_in_dir("*.gemspec", File.join(dir, "specifications")).map do |g|
522-
Gem::Specification.load g
521+
Gem::SpecificationRecord.dirs_from([dir]).flat_map do |spec_dir|
522+
Gem::Util.glob_files_in_dir("*.gemspec", spec_dir).map do |g|
523+
Gem::Specification.load g
524+
end
523525
end
524526
end
525527

lib/rubygems/resolver/lock_specification.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ def initialize(set, name, version, sources, platform)
3030
def install(options = {})
3131
destination = options[:install_dir] || Gem.dir
3232

33-
if File.exist? File.join(destination, "specifications", spec.spec_name)
33+
installed = Gem::SpecificationRecord.dirs_from([destination]).any? do |spec_dir|
34+
File.exist? File.join(spec_dir, spec.spec_name)
35+
end
36+
37+
if installed
3438
yield nil
3539
return
3640
end

lib/rubygems/specification.rb

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,7 +1263,11 @@ def self.reset
12631263
# Keeps track of all currently known specifications
12641264

12651265
def self.specification_record
1266-
@specification_record ||= Gem::SpecificationRecord.new(dirs)
1266+
@specification_record ||= Gem::SpecificationRecord.new(
1267+
Gem::SpecificationRecord.dirs_with_abi(
1268+
(defined?(@@dirs) ? @@dirs : nil) || Gem::SpecificationRecord.dirs_from(gem_path)
1269+
)
1270+
)
12671271
end
12681272

12691273
# DOC: This method needs documented or nodoc'd
@@ -2026,13 +2030,19 @@ def initialize_copy(other_spec)
20262030

20272031
def base_dir
20282032
return Gem.dir unless loaded_from
2029-
@base_dir ||= if default_gem?
2033+
@base_dir ||= if default_gem? || loaded_from_abi_scoped_spec_dir?
20302034
File.dirname File.dirname File.dirname loaded_from
20312035
else
20322036
File.dirname File.dirname loaded_from
20332037
end
20342038
end
20352039

2040+
def loaded_from_abi_scoped_spec_dir?
2041+
!loaded_from.nil? &&
2042+
Gem::SpecificationRecord.abi_scoped_spec_dir?(File.dirname(loaded_from))
2043+
end
2044+
private :loaded_from_abi_scoped_spec_dir?
2045+
20362046
def inspect # :nodoc:
20372047
if $DEBUG
20382048
super
@@ -2330,9 +2340,16 @@ def source # :nodoc:
23302340
##
23312341
# Returns the full path to the directory containing this spec's
23322342
# gemspec file. eg: /usr/local/lib/ruby/gems/1.8/specifications
2343+
#
2344+
# Content-addressable gemspecs live in the ABI-scoped subdirectory,
2345+
# eg: /usr/local/lib/ruby/gems/1.8/specifications/3.4
23332346

23342347
def spec_dir
2335-
@spec_dir ||= File.join base_dir, "specifications"
2348+
@spec_dir ||= if loaded_from && Gem::ContentAddress.content_addressed?(self)
2349+
File.dirname loaded_from
2350+
else
2351+
Gem::SpecificationRecord.specification_dir_for(self, base_dir)
2352+
end
23362353
end
23372354

23382355
##

0 commit comments

Comments
 (0)