|
1 | 1 | # frozen_string_literal: true |
2 | 2 |
|
3 | 3 | ## |
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. |
7 | 7 | 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 | + |
9 | 12 | PATTERN = /\A[0-9a-f]{8,64}\z/ |
10 | 13 |
|
11 | 14 | ## |
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"). |
15 | 16 |
|
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 |
21 | 20 |
|
22 | 21 | ## |
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. |
25 | 23 |
|
26 | | - def self.content_addressed?(spec) |
27 | | - applicable?(spec) && match?(spec.content_address) |
28 | | - end |
| 24 | + DEFAULT_LENGTH = 8 |
29 | 25 |
|
30 | 26 | ## |
31 | 27 | # 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. |
33 | 32 |
|
34 | 33 | def self.match?(value) |
35 | 34 | value.is_a?(String) && PATTERN.match?(value) |
36 | 35 | end |
37 | 36 |
|
| 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 | + |
38 | 177 | ## |
39 | 178 | # Ranks +spec+ for candidate selection against +ruby_version+: a |
40 | 179 | # content-addressed spec built for that Ruby ranks first (0), any |
|
0 commit comments