Skip to content

Commit 4fc3dd7

Browse files
committed
Resolve and install content-addressable gems in Bundler
Encode content-addressable gems in the v1 compact index (no v2 endpoint): the per-gem info line carries the content address in the version token's platform slot and the real platform in a platform: requirement token, gated by a rubygems:>= requirement so old clients ignore the rows: 1.0.0-<sha> |checksum:<sha256>,ruby:<req>,rubygems:>= 4.1.0.dev,platform:= <real> - endpoint_specification.rb: parse the platform: token into platform_requirement; content_addressable?; installable_on_platform? matches on the real platform (since @platform holds the sha); version_suffix reconstructs the download name. - match_platform.rb: when a skinny binary compatible with the running Ruby exists, prefer it exclusively; otherwise fall back to the fat/ruby variant so a usable binary is never stranded. Per-minor ~> ranges are disjoint, so at most one skinny qualifies (no skinny-vs-skinny tie-break needed). - lazy_specification.rb: carry platform_requirement/version_suffix; reconstruct full_name as name-version-<sha>; on a --local exact-match miss, retry by name+version so the portable lockfile entry (name-version-platform) resolves to the on-disk name-version-<sha> gem. - stub_specification.rb: delegate full_name/version_suffix to the RubyGems stub. Old clients drop the skinny rows because their RubyGems version doesn't satisfy the rubygems:>= gate (and the <sha> token doesn't match a real platform anyway), so they fall back to the fat binary. Assisted-By: devx/4ab7951d-76be-4b93-8ffb-c3581711ac1f
1 parent 8f38ae7 commit 4fc3dd7

4 files changed

Lines changed: 100 additions & 3 deletions

File tree

bundler/lib/bundler/endpoint_specification.rb

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module Bundler
55
class EndpointSpecification < Gem::Specification
66
include MatchRemoteMetadata
77

8-
attr_reader :name, :version, :platform, :checksum, :created_at
8+
attr_reader :name, :version, :platform, :checksum, :created_at, :platform_requirement
99
attr_writer :dependencies
1010
attr_accessor :remote, :locked_platform
1111

@@ -21,11 +21,37 @@ def initialize(name, version, platform, spec_fetcher, dependencies, metadata = n
2121
@loaded_from = nil
2222
@remote_specification = nil
2323
@locked_platform = nil
24+
@platform_requirement = nil
2425

2526
parse_metadata(metadata)
2627
end
2728

29+
# A content-addressable ("skinny") binary: the version token carries a SHA
30+
# prefix instead of a platform, and the real platform is supplied via the
31+
# compact index `platform:` requirement.
32+
def content_addressable?
33+
!@platform_requirement.nil?
34+
end
35+
36+
# The version suffix (SHA prefix) carried in the version token's platform
37+
# slot, e.g. "9f3c1a2b". Used to reconstruct the download/full name. nil for
38+
# ordinary gems, whose @platform is a real Gem::Platform or the "ruby" string.
39+
def version_suffix
40+
@platform.version_suffix if @platform.is_a?(Gem::Platform)
41+
end
42+
43+
# For content-addressable specs, platform matching must use the platform
44+
# requirement from metadata, not the SHA prefix stored in @platform.
45+
def installable_on_platform?(target_platform)
46+
return super unless content_addressable?
47+
48+
target_platform.nil? ||
49+
target_platform == Gem::Platform::RUBY ||
50+
@platform_requirement === Gem::Platform.new(target_platform)
51+
end
52+
2853
def insecurely_materialized?
54+
return false if content_addressable?
2955
@locked_platform.to_s != @platform.to_s
3056
end
3157

@@ -162,6 +188,10 @@ def parse_metadata(data)
162188
@required_rubygems_version = Gem::Requirement.new(v)
163189
when "ruby"
164190
@required_ruby_version = Gem::Requirement.new(v)
191+
when "platform"
192+
# e.g. v == ["= x86_64-linux-musl"]; strip the requirement operator.
193+
platform_string = Array(v).last.to_s.sub(/\A\s*=?\s*/, "")
194+
@platform_requirement = Gem::Platform.new(platform_string) unless platform_string.empty?
165195
when "created_at"
166196
value = v.is_a?(Array) ? v.last : v
167197
if value.is_a?(String)

bundler/lib/bundler/lazy_specification.rb

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class LazySpecification
99
include ForcePlatform
1010

1111
attr_reader :name, :version, :platform, :materialization
12-
attr_accessor :source, :remote, :force_ruby_platform, :dependencies, :required_ruby_version, :required_rubygems_version, :overrides
12+
attr_accessor :source, :remote, :force_ruby_platform, :dependencies, :required_ruby_version, :required_rubygems_version, :overrides, :platform_requirement, :version_suffix
1313

1414
#
1515
# For backwards compatibility with existing lockfiles, if the most specific
@@ -31,9 +31,25 @@ def self.from_spec(s)
3131
lazy_spec.required_ruby_version = s.required_ruby_version
3232
lazy_spec.required_rubygems_version = s.required_rubygems_version
3333
lazy_spec.overrides = s.overrides if s.is_a?(LazySpecification)
34+
lazy_spec.platform_requirement = s.platform_requirement if s.respond_to?(:platform_requirement)
35+
lazy_spec.version_suffix = s.version_suffix if s.respond_to?(:version_suffix)
3436
lazy_spec
3537
end
3638

39+
def content_addressable?
40+
!@platform_requirement.nil?
41+
end
42+
43+
# Match using the platform requirement from metadata when content-addressable,
44+
# since @platform holds the SHA prefix rather than a real platform.
45+
def installable_on_platform?(target_platform)
46+
return super unless content_addressable?
47+
48+
target_platform.nil? ||
49+
target_platform == Gem::Platform::RUBY ||
50+
@platform_requirement === Gem::Platform.new(target_platform)
51+
end
52+
3753
def initialize(name, version, platform, source = nil, **materialization_options)
3854
@name = name
3955
@version = version
@@ -64,7 +80,9 @@ def source_changed?
6480
end
6581

6682
def full_name
67-
@full_name ||= if platform == Gem::Platform::RUBY
83+
@full_name ||= if @version_suffix
84+
"#{@name}-#{@version}-#{@version_suffix}"
85+
elsif platform == Gem::Platform::RUBY
6886
"#{@name}-#{@version}"
6987
else
7088
"#{@name}-#{@version}-#{platform}"
@@ -140,6 +158,17 @@ def materialize_for_installation
140158

141159
if use_exact_resolved_specifications?
142160
spec = materialize(self) {|specs| choose_compatible(specs, fallback_to_non_installable: false) }
161+
162+
# The exact locked full_name wasn't found in the index (materialize returns
163+
# self). This happens for content-addressable gems: the installed full_name
164+
# is name-version-<sha>, while the lockfile pins the portable
165+
# name-version-platform. Retry by name+version and let platform/ruby
166+
# selection pick the right binary.
167+
if spec.equal?(self)
168+
by_name = materialize([name, version]) {|specs| resolve_best_platform(specs) }
169+
return by_name unless by_name.nil? || by_name.equal?(self)
170+
end
171+
143172
return spec if spec
144173

145174
# Exact spec is incompatible; in frozen mode, try to find a compatible platform variant

bundler/lib/bundler/match_platform.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,34 @@ def self.select_best_local_platform_match(specs, force_ruby: false)
2222
Gem::Platform.sort_best_platform_match(matching, local)
2323
end
2424

25+
def self.content_addressable?(spec)
26+
spec.respond_to?(:content_addressable?) && spec.content_addressable?
27+
end
28+
29+
# A skinny binary targets exactly one Ruby minor (e.g. `~> 3.2.0`). Those
30+
# ranges are disjoint, so at most one skinny variant per (gem, version,
31+
# platform) is compatible with the running Ruby. Only a Ruby-compatible
32+
# skinny may displace the fat/ruby fallback.
33+
def self.usable_skinny?(spec)
34+
return false unless content_addressable?(spec)
35+
36+
req = spec.required_ruby_version
37+
req.nil? || req.none? || req.satisfied_by?(Gem.ruby_version)
38+
end
39+
2540
def self.select_all_platform_match(specs, platform, force_ruby: false, prefer_locked: false)
2641
matching = specs.select {|spec| spec.installable_on_platform?(force_ruby ? Gem::Platform::RUBY : platform) }
2742

43+
# Prefer the skinny (content-addressable) binary: when a skinny variant
44+
# compatible with the running Ruby exists, choose it exclusively. If no
45+
# skinny matches this Ruby, keep the fat/ruby variants as a fallback so we
46+
# never strand a usable binary. Because per-minor `~>` ranges are disjoint,
47+
# at most one skinny qualifies -- no skinny-vs-skinny tie-break is needed.
48+
unless force_ruby
49+
skinny = matching.select {|spec| usable_skinny?(spec) }
50+
matching = skinny if skinny.any?
51+
end
52+
2853
specs.each(&:force_ruby_platform!) if force_ruby
2954

3055
if prefer_locked

bundler/lib/bundler/stub_specification.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ def insecurely_materialized?
1313
false
1414
end
1515

16+
# Delegate to the underlying RubyGems stub so content-addressable binaries
17+
# use their content-addressed name (name-version-<sha>), matching the install
18+
# directory, rather than recomputing name-version-platform.
19+
def full_name
20+
stub.full_name
21+
end
22+
23+
# The version suffix lives on the stub line, not in the gemspec body, so read
24+
# it from the RubyGems stub rather than method_missing'ing to the full spec.
25+
def version_suffix
26+
stub.version_suffix
27+
end
28+
1629
attr_reader :checksum
1730
attr_accessor :stub, :ignored
1831

0 commit comments

Comments
 (0)