Skip to content

Commit 8fb09fa

Browse files
committed
Centralize content addressing semantics in Gem::ContentAddress and route every eligibility, naming, lockfile, plugin directory, and spec construction decision through its shared predicates
Assisted-By: devx/1070d399-4c30-4b1e-b5e5-dd5f191f26cf
1 parent 6c5a946 commit 8fb09fa

26 files changed

Lines changed: 594 additions & 106 deletions

lib/bundler/endpoint_specification.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def initialize(name, version, suffix, spec_fetcher, dependencies, metadata = nil
2525

2626
parse_metadata(metadata)
2727

28-
if Gem::ContentAddress.match?(suffix) && @required_platform
28+
if Gem::ContentAddress.content_addressed_row?(suffix, @required_platform, @required_ruby_version)
2929
@content_address = suffix
3030
@platform = @required_platform
3131
@required_rubygems_version ||= Gem::Requirement.default

lib/bundler/lazy_specification.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def source_changed?
6666
end
6767

6868
def full_name
69-
@full_name ||= if Gem::ContentAddress.match?(@content_address) && platform != Gem::Platform::RUBY
69+
@full_name ||= if Gem::ContentAddress.content_addressed?(self, validate_ruby_abi: false)
7070
"#{@name}-#{@version}-#{@content_address}"
7171
elsif platform == Gem::Platform::RUBY
7272
"#{@name}-#{@version}"

lib/bundler/lockfile_generator.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def add_dependencies
6969

7070
def add_content_addresses
7171
content_addresses = definition.resolve.filter_map do |spec|
72-
next unless Gem::ContentAddress.match?(spec.content_address)
72+
next unless Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false)
7373

7474
line = "#{spec.lock_name} #{spec.content_address}"
7575

@@ -89,7 +89,7 @@ def add_checksums
8989
checksums = definition.resolve.filter_map do |spec|
9090
line = spec.source.checksum_store.to_lock(spec)
9191

92-
next if line == spec.lock_name && Gem::ContentAddress.match?(spec.content_address)
92+
next if line == spec.lock_name && Gem::ContentAddress.content_addressed?(spec, validate_ruby_abi: false)
9393

9494
line
9595
end

lib/bundler/match_platform.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def self.select_all_content_address_match(specs, content_address)
2727
end
2828

2929
def self.prefer_content_addressable(matching)
30-
addressable, non_addressable = matching.partition {|s| Gem::ContentAddress.match?(s.content_address) }
30+
addressable, non_addressable = matching.partition {|s| Gem::ContentAddress.content_addressed?(s, validate_ruby_abi: false) }
3131
return matching if addressable.empty?
3232

3333
compatible = addressable.select(&:matches_current_metadata?)

lib/bundler/remote_specification.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def fetch_platform
3636
end
3737

3838
def full_name
39-
@full_name ||= if Gem::ContentAddress.match?(@content_address) && @platform != Gem::Platform::RUBY
39+
@full_name ||= if Gem::ContentAddress.content_addressed?(self, validate_ruby_abi: false)
4040
"#{@name}-#{@version}-#{@content_address}"
4141
elsif @platform == Gem::Platform::RUBY
4242
"#{@name}-#{@version}"

lib/bundler/rubygems_ext.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,15 @@ def self.match?(token)
3131
false
3232
end
3333

34-
def self.applicable?(spec)
34+
def self.eligible?(spec, validate_ruby_abi: true)
3535
false
3636
end
3737

38-
def self.content_addressed?(spec)
38+
def self.content_addressed?(spec, validate_ruby_abi: true)
39+
false
40+
end
41+
42+
def self.content_addressed_row?(suffix, platform, required_ruby_version = nil, validate_ruby_abi: true)
3943
false
4044
end
4145
end

lib/rubygems/commands/push_command.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def platform_matches?(spec)
165165
def ruby_matches?(spec)
166166
return true unless options[:ruby_abi]
167167

168-
Gem::ContentAddress.applicable?(spec) && spec.ruby_abi == options[:ruby_abi]
168+
Gem::ContentAddress.eligible?(spec) && spec.ruby_abi == options[:ruby_abi]
169169
end
170170

171171
def send_push_request(name, args)

lib/rubygems/content_address.rb

Lines changed: 157 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,179 @@
11
# frozen_string_literal: true
22

33
##
4-
# Gem::ContentAddress encapsulates the pattern for recognizing
5-
# content-addressable gem file names.
6-
4+
# Gem::ContentAddress is the single home for content-addressing semantics:
5+
# what an address and a Ruby ABI look like, which specs are eligible, how
6+
# addresses are generated and verified against gem files.
77
module Gem::ContentAddress
8-
# :nodoc:
8+
##
9+
# A content address is 8 to 64 lowercase hexadecimal characters -- a
10+
# prefix of the SHA256 digest of the gem file contents.
11+
912
PATTERN = /\A[0-9a-f]{8,64}\z/
1013

1114
##
12-
# Whether +spec+ is eligible for content addressing. A gem must
13-
# pin a required_ruby_version and declare a non-RUBY platform to be
14-
# content addressed.
15+
# A Ruby ABI is a major and minor version pair ("X.Y").
1516

16-
def self.applicable?(spec)
17-
required_ruby_version = spec.required_ruby_version
18-
!required_ruby_version.nil? && !required_ruby_version.none? &&
19-
!spec.platform.nil? && spec.platform != Gem::Platform::RUBY
20-
end
17+
RUBY_ABI_PATTERN = /\A\d+\.\d+\z/
18+
19+
private_constant :PATTERN, :RUBY_ABI_PATTERN
2120

2221
##
23-
# Whether +spec+ is content-addressed: it is eligible for content
24-
# addressing and has a valid content address set.
22+
# Default number of hexadecimal characters in a generated content address.
2523

26-
def self.content_addressed?(spec)
27-
applicable?(spec) && match?(spec.content_address)
28-
end
24+
DEFAULT_LENGTH = 8
2925

3026
##
3127
# Whether +value+ is a valid content address (a string of 8-64
32-
# lowercase hexadecimal characters).
28+
# lowercase hexadecimal characters). This only checks the shape of a
29+
# string: use content_addressed? to ask whether a spec is actually
30+
# content addressed, and file_name_claim to ask whether a file name
31+
# claims an address.
3332

3433
def self.match?(value)
3534
value.is_a?(String) && PATTERN.match?(value)
3635
end
3736

37+
##
38+
# Whether +value+ is a well-formed Ruby ABI ("X.Y").
39+
40+
def self.valid_ruby_abi?(value)
41+
value.is_a?(String) && RUBY_ABI_PATTERN.match?(value)
42+
end
43+
44+
##
45+
# Derives the Ruby ABI ("X.Y") from +required_ruby_version+. Only a
46+
# single pessimistic requirement with three segments ending in zero
47+
# ("~> X.Y.0") pins an ABI. Returns nil for any other shape.
48+
49+
def self.ruby_abi_for(required_ruby_version)
50+
return nil if required_ruby_version.nil?
51+
52+
requirements = required_ruby_version.requirements
53+
return nil if requirements.size != 1
54+
55+
op, version = requirements.first
56+
return nil if op != "~>" || version.segments.size != 3 || version.segments[2] != 0
57+
58+
version.segments[0..1].join(".")
59+
end
60+
61+
##
62+
# The required_ruby_version that pins +ruby_abi+ ("X.Y" to "~> X.Y.0").
63+
# Inverse of +ruby_abi_for+.
64+
65+
def self.ruby_abi_requirement(ruby_abi)
66+
Gem::Requirement.new("~> #{ruby_abi}.0")
67+
end
68+
69+
##
70+
# Whether +platform+ is eligible for content addressing: present and
71+
# not the generic RUBY platform.
72+
73+
def self.platform_eligible?(platform)
74+
!platform.nil? && platform != Gem::Platform::RUBY
75+
end
76+
77+
##
78+
# Whether +spec+ is eligible for content addressing. A gem must pin
79+
# its required_ruby_version to a single Ruby ABI ("~> X.Y.0") and
80+
# declare a non-RUBY platform to be content addressed. This makes
81+
# `content_addressed? implies ruby_abi present` structural: no spec
82+
# can count as content addressed without an ABI to scope it by.
83+
84+
def self.eligible?(spec, validate_ruby_abi: true)
85+
return false unless platform_eligible?(spec.platform)
86+
return true unless validate_ruby_abi
87+
88+
!ruby_abi_for(spec.required_ruby_version).nil?
89+
end
90+
91+
##
92+
# Whether +spec+ is content-addressed: it is eligible for content
93+
# addressing and has a valid content address set. See eligible? for
94+
# when to pass <tt>validate_ruby_abi: false</tt>.
95+
96+
def self.content_addressed?(spec, validate_ruby_abi: true)
97+
eligible?(spec, validate_ruby_abi: validate_ruby_abi) && match?(spec.content_address)
98+
end
99+
100+
##
101+
# Whether an index row describes a content-addressed gem: an
102+
# address-shaped +suffix+, a pinned +platform+, and a
103+
# +required_ruby_version+ pinning a single Ruby ABI. Rows missing any
104+
# of these must not assign a content address, so specs cannot be
105+
# constructed half content-addressed. See eligible? for when to pass
106+
# <tt>validate_ruby_abi: false</tt>.
107+
108+
def self.content_addressed_row?(suffix, platform, required_ruby_version = nil, validate_ruby_abi: true)
109+
return false unless match?(suffix) && platform_eligible?(platform)
110+
return true unless validate_ruby_abi
111+
112+
!ruby_abi_for(required_ruby_version).nil?
113+
end
114+
115+
##
116+
# Whether +spec+'s required_ruby_version permits building for +ruby_abi+:
117+
# an unset or default requirement can still be pinned to the ABI, and
118+
# anything else must already pin exactly that ABI. Used at build time,
119+
# before the requirement is injected, where eligible? would be
120+
# premature.
121+
122+
def self.ruby_abi_compatible?(spec, ruby_abi)
123+
required_ruby_version = spec.required_ruby_version
124+
return true if required_ruby_version.nil? || required_ruby_version.none?
125+
126+
ruby_abi_for(required_ruby_version) == ruby_abi
127+
end
128+
129+
##
130+
# Generates the content address for +bytes+: the first +length+
131+
# characters of the hexadecimal SHA256 digest of the contents.
132+
133+
def self.address_for(bytes, length: DEFAULT_LENGTH)
134+
require "digest"
135+
Digest::SHA256.hexdigest(bytes)[0, length]
136+
end
137+
138+
##
139+
# The content address claimed by a gem file name, or nil when the name
140+
# makes no claim. +filename+ is the file's base name without the ".gem"
141+
# extension ("name-version[-suffix]"). A name claims an address when its
142+
# suffix is address-shaped and is not just +spec+'s own platform: a
143+
# platform string that happens to look like hexadecimal (both
144+
# normalized and original spellings) is a platform name, not a claim.
145+
146+
def self.file_name_claim(filename, spec)
147+
suffix = filename.delete_prefix("#{spec.name}-#{spec.version}-")
148+
return nil if suffix == filename
149+
return nil unless match?(suffix)
150+
return nil if [spec.platform.to_s, spec.original_platform.to_s].include?(suffix)
151+
152+
suffix
153+
end
154+
155+
##
156+
# Verifies the content address claimed by the gem file at +path+ against
157+
# the SHA256 digest of its contents. Returns the verified address, or nil
158+
# when the file name makes no claim. Raises Gem::InstallError when the
159+
# contents do not match the claim, regardless of whether the packaged
160+
# +spec+ is eligible, so swapped contents cannot hide behind an
161+
# ineligible specification.
162+
163+
def self.verified_file_name_claim(path, spec)
164+
basename = File.basename(path, ".gem")
165+
address = file_name_claim(basename, spec)
166+
return nil unless address
167+
168+
require "digest"
169+
digest = Digest::SHA256.file(path).hexdigest
170+
unless digest.start_with?(address)
171+
raise Gem::InstallError, "content address mismatch for #{File.basename(path)}"
172+
end
173+
174+
address
175+
end
176+
38177
##
39178
# Ranks +spec+ for candidate selection against +ruby_version+: a
40179
# content-addressed spec built for that Ruby ranks first (0), any

lib/rubygems/installer_uninstaller_utils.rb

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,15 @@ def remove_plugins_for(spec, plugins_dir)
3434
private
3535

3636
def plugin_stub_dir_for(spec, plugins_dir)
37-
ruby_abi = spec.to_spec.ruby_abi if Gem::ContentAddress.match?(spec.content_address)
38-
ruby_abi ? File.join(plugins_dir, ruby_abi) : plugins_dir
37+
full_spec = spec.to_spec
38+
return plugins_dir unless Gem::ContentAddress.content_addressed?(full_spec)
39+
40+
File.join plugins_dir, full_spec.ruby_abi
3941
end
4042

4143
def ruby_abi_plugin_dir_for(spec, plugins_dir)
42-
ruby_abi = spec.to_spec.ruby_abi if Gem::ContentAddress.match?(spec.content_address)
43-
File.join plugins_dir, ruby_abi || Gem.ruby_abi
44+
full_spec = spec.to_spec
45+
ruby_abi = Gem::ContentAddress.content_addressed?(full_spec) ? full_spec.ruby_abi : Gem.ruby_abi
46+
File.join plugins_dir, ruby_abi
4447
end
4548
end

lib/rubygems/package.rb

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,6 @@ class TarInvalidError < Error; end
129129
# Permission for other files
130130
attr_accessor :data_mode
131131

132-
##
133-
# The number of characters of the SHA-256 digest of the gem contents used
134-
# in a content-addressable gem file name.
135-
136-
DEFAULT_CONTENT_ADDRESS_LENGTH = 8
137-
138132
##
139133
# The minimum RubyGems version that can install content-addressable gems.
140134
# Built into +required_rubygems_version+ so older clients reject skinny
@@ -262,18 +256,11 @@ def content_address
262256
path = @gem&.path
263257
return unless path
264258

265-
return nil unless Gem::ContentAddress.applicable?(spec)
266-
267-
filename = File.basename(path, ".gem")
268-
base = "#{spec.name}-#{spec.version}"
269-
suffix = filename.delete_prefix("#{base}-")
270-
return nil if suffix == filename
271-
return nil unless Gem::ContentAddress.match?(suffix)
259+
address = Gem::ContentAddress.verified_file_name_claim(path, spec)
260+
return nil unless address
261+
return nil unless Gem::ContentAddress.eligible?(spec)
272262

273-
require "digest"
274-
digest = Digest::SHA256.file(path).hexdigest
275-
raise Gem::InstallError, "content address mismatch for #{File.basename(path)}" unless digest.start_with?(suffix)
276-
suffix
263+
address
277264
end
278265

279266
##
@@ -401,12 +388,12 @@ def build(skip_validation = false, strict_validation = false)
401388
def build_content_addressable_file(ruby_abi, skip_validation = false, strict_validation = false)
402389
validate_ruby_abi ruby_abi
403390
@spec.required_rubygems_version = normalized_required_rubygems_version(ruby_abi)
404-
@spec.required_ruby_version = Gem::Requirement.new("~> #{ruby_abi}.0")
391+
@spec.required_ruby_version = Gem::ContentAddress.ruby_abi_requirement(ruby_abi)
405392

406393
build skip_validation, strict_validation
407394

408395
bytes = @gem.with_read_io(&:read)
409-
gem_file = "#{@spec.name}-#{@spec.version}-#{Digest::SHA256.hexdigest(bytes)[0, DEFAULT_CONTENT_ADDRESS_LENGTH]}.gem"
396+
gem_file = "#{@spec.name}-#{@spec.version}-#{Gem::ContentAddress.address_for(bytes)}.gem"
410397
File.binwrite(gem_file, bytes)
411398

412399
say " File: #{gem_file}"
@@ -809,11 +796,11 @@ def satisfies_rubygems_floor?(requirement, floor)
809796
# the ABI.
810797

811798
def validate_ruby_abi(ruby_abi)
812-
if !/\A\d+\.\d+\z/.match?(ruby_abi)
799+
if !Gem::ContentAddress.valid_ruby_abi?(ruby_abi)
813800
raise ArgumentError, "Ruby ABI must be in X.Y format"
814-
elsif @spec.platform.nil? || @spec.platform == Gem::Platform::RUBY
801+
elsif !Gem::ContentAddress.platform_eligible?(@spec.platform)
815802
raise ArgumentError, "Cannot build a gem scoped to a single Ruby ABI as no platform or a Ruby platform has been set"
816-
elsif @spec.required_ruby_version && @spec.required_ruby_version != Gem::Requirement.default && @spec.ruby_abi != ruby_abi
803+
elsif !Gem::ContentAddress.ruby_abi_compatible?(@spec, ruby_abi)
817804
raise ArgumentError, "Cannot build gem for Ruby ABI #{ruby_abi} because required_ruby_version is set to #{@spec.required_ruby_version}. Please set required_ruby_version to \"~> #{ruby_abi}.0\"."
818805
end
819806
end

0 commit comments

Comments
 (0)