gem_server_conformance 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cc996f39ef2b36f10b6888858981fff19bb5a06e547981643d3a8486a24b2aeb
4
+ data.tar.gz: da7ab819c31d6e1d1c6915f91cc2b31148a7cd19747ad45d9fa0d4905bfef38f
5
+ SHA512:
6
+ metadata.gz: 1ce259e3b57721291668320ecf6f547809aceb5f41d7f5dce41624724d38fad03011c839e749cc4b369630a435683d65e91d0d3b07e277a7a9c2dc4806930ea4
7
+ data.tar.gz: 14ba296d864bebcdb5b4d0d5f857def2d33d3285b76f74816e2736afd828a1117660829e87bd5a8fc11917c58e4223d5062b5667c18b8592da94b786bc1f3d24
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,65 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.0
3
+ NewCops: enable
4
+
5
+ require:
6
+ - rubocop-performance
7
+ - rubocop-rake
8
+ - rubocop-rspec
9
+
10
+ Metrics:
11
+ Enabled: false
12
+
13
+ Style/MultilineBlockChain:
14
+ Enabled: false
15
+
16
+ Style/StringLiterals:
17
+ EnforcedStyle: double_quotes
18
+
19
+ Style/StringLiteralsInInterpolation:
20
+ EnforcedStyle: double_quotes
21
+
22
+ Style/SpecialGlobalVars:
23
+ Enabled: false
24
+
25
+ Style/Documentation:
26
+ Enabled: false
27
+
28
+ Style/StringConcatenation:
29
+ Enabled: false
30
+
31
+ Naming/VariableNumber:
32
+ EnforcedStyle: snake_case
33
+ AllowedIdentifiers:
34
+ - sha256
35
+
36
+ RSpec/ImplicitSubject:
37
+ Enabled: false
38
+
39
+ RSpec/RepeatedExample:
40
+ Enabled: false
41
+
42
+ Security/MarshalLoad:
43
+ Enabled: false
44
+
45
+ RSpec/ExampleLength:
46
+ Enabled: false
47
+
48
+ RSpec/MultipleExpectations:
49
+ Enabled: false
50
+
51
+ RSpec/InstanceVariable:
52
+ Enabled: false
53
+
54
+ RSpec/BeforeAfterAll:
55
+ Enabled: false
56
+
57
+ RSpec/ExpectInHook:
58
+ Enabled: false
59
+
60
+ RSpec/ContextWording:
61
+ Prefixes:
62
+ - after
63
+
64
+ RSpec/NestedGroups:
65
+ Enabled: false
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.3.3
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-07-09
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Samuel Giddins
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # GemServerConformance
2
+
3
+ A conformance test suite for RubyGems servers.
4
+
5
+ ## Usage
6
+
7
+ Run `gem_server_conformance` to run the test suite against a server. The endpoint to test can be specified with the `UPSTREAM` environment variable. Make sure to set the `GEM_HOST_API_KEY` environment variable to an API key with push/yank permissions if authentication is required.
8
+
9
+ In addition to the standard interface,
10
+ you will also need to define the following endpoints:
11
+
12
+ - `POST /set_time` - Set the server's time to the value of the body's iso8601 value.
13
+ - `POST /rebuild_versions_list` - Rebuild the base /versions file.
14
+
15
+ ## Development
16
+
17
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
18
+
19
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
20
+
21
+ ## Contributing
22
+
23
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rubygems/gem_server_conformance.
24
+
25
+ ## License
26
+
27
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rspec"
5
+
6
+ root = File.expand_path("..", __dir__)
7
+ Dir.chdir(root)
8
+ exit RSpec::Core::Runner.run(ARGV + [
9
+ File.join(root, "spec/")
10
+ ])
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/gem_server_conformance/server"
5
+ require "rackup"
6
+
7
+ GemServerConformance::Server::Application.run!
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "compact_index"
4
+ require "rubygems/package"
5
+ require "sinatra/base"
6
+
7
+ module GemServerConformance
8
+ class Server
9
+ class Application < Sinatra::Base
10
+ Sinatra::ShowExceptions.class_eval do
11
+ undef_method :prefers_plain_text?
12
+ def prefers_plain_text?(_)
13
+ true
14
+ end
15
+ end
16
+ def initialize(server = Server.new)
17
+ @server = server
18
+ super()
19
+ end
20
+
21
+ set :lock, true
22
+ set :raise_errors, true
23
+
24
+ post "/api/v1/gems" do
25
+ @server.push(request.body.read)
26
+ end
27
+
28
+ delete "/api/v1/gems/yank" do
29
+ @server.yank(params["gem_name"], params["version"], params["platform"])
30
+ end
31
+
32
+ after(%r{/(?:names|versions|info/[^/]+)}) do
33
+ headers "Content-Type" => "text/plain; charset=utf-8"
34
+ break unless response.status == 200
35
+
36
+ headers "Accept-Ranges" => "bytes"
37
+ etag Digest::MD5.hexdigest(response.body.join)
38
+ sha256 = Digest::SHA256.base64digest(response.body.join)
39
+ headers "Digest" => "sha-256=#{sha256}", "Repr-Digest" => "sha-256=:#{sha256}:"
40
+ end
41
+
42
+ get "/versions" do
43
+ @server.versions
44
+ end
45
+
46
+ get "/info/:name" do
47
+ @server.info(params[:name])
48
+ end
49
+
50
+ get "/names" do
51
+ @server.names
52
+ end
53
+
54
+ get "/quick/Marshal.4.8/:id.gemspec.rz" do
55
+ @server.quick_spec(params[:id])
56
+ end
57
+
58
+ get "/specs.4.8.gz" do
59
+ @server.specs_gz
60
+ end
61
+
62
+ get "/prerelease_specs.4.8.gz" do
63
+ @server.specs_gz("prerelease")
64
+ end
65
+
66
+ get "/latest_specs.4.8.gz" do
67
+ @server.specs_gz("latest")
68
+ end
69
+
70
+ get "/gems/:id.gem" do
71
+ @server.gem(params[:id])
72
+ end
73
+
74
+ # This is not part of the Gemstash API, but is used to set the time for the server
75
+
76
+ post "/set_time" do
77
+ @server.set_time Time.iso8601(request.body.read)
78
+ end
79
+
80
+ post "/rebuild_versions_list" do
81
+ @server.rebuild_versions_list
82
+ end
83
+ end
84
+
85
+ Version = Struct.new(:rubygem_name, :number, :platform, :info_checksum, :indexed, :prerelease, :sha256, :yanked_at,
86
+ :yanked_info_checksum,
87
+ :pushed_at, :package,
88
+ :position, :latest)
89
+
90
+ def initialize
91
+ @log = []
92
+ @versions = []
93
+ @versions_tempfile = Tempfile.create("versions.list")
94
+ @versions_file = CompactIndex::VersionsFile.new(@versions_tempfile.path)
95
+ @time = Time.at(0).utc
96
+ end
97
+
98
+ def reorder_versions
99
+ @versions.group_by(&:rubygem_name).each_value do |versions|
100
+ numbers = versions.map(&:number).sort.reverse
101
+
102
+ versions.each do |version|
103
+ version.position = numbers.index(version.number)
104
+ version.latest = false
105
+ end
106
+
107
+ versions.select(&:indexed).reject(&:prerelease).group_by(&:platform).transform_values do |platform_versions|
108
+ platform_versions.max_by(&:number).latest = true
109
+ end
110
+ end
111
+ end
112
+
113
+ def push(gem)
114
+ package = Gem::Package.new(StringIO.new(gem))
115
+ log "Pushed #{package.spec.full_name}"
116
+ if @versions.any? { |v| v.package.spec.full_name == package.spec.full_name }
117
+ return [409, {}, ["Conflict: #{package.spec.full_name} already exists"]]
118
+ end
119
+
120
+ version = Version.new(package.spec.name, package.spec.version.to_s, package.spec.platform, nil,
121
+ true, package.spec.version.prerelease?, Digest::SHA256.hexdigest(gem), nil, nil,
122
+ @time, package)
123
+ @versions << version
124
+ reorder_versions
125
+ version.info_checksum = Digest::MD5.hexdigest(info(version.rubygem_name).last.join)
126
+
127
+ [200, {}, [@log.last]]
128
+ end
129
+
130
+ def yank(name, version, platform)
131
+ full_name = [name, version, platform].compact.-(["ruby"]).join("-")
132
+ yank = @versions.find do |v|
133
+ v.package.spec.full_name == full_name && v.indexed
134
+ end
135
+ return [404, {}, [""]] unless yank
136
+
137
+ log "Yanked #{yank.package.spec.full_name}"
138
+
139
+ yank.indexed = false
140
+ reorder_versions
141
+ yank.yanked_at = @time
142
+ yank.yanked_info_checksum = Digest::MD5.hexdigest(info(yank.rubygem_name).last.join)
143
+
144
+ [200, {}, [""]]
145
+ end
146
+
147
+ def set_time(time) # rubocop:disable Naming/AccessorMethodName
148
+ @time = time
149
+ log "Time set to #{time}"
150
+ [200, {}, [@log.last]]
151
+ end
152
+
153
+ def rebuild_versions_list
154
+ gems = compact_index_gem_versions(separate_yanks: true,
155
+ before: @time).group_by(&:name).transform_values do |gems_with_name|
156
+ versions = gems_with_name.flat_map(&:versions)
157
+ info_checksum = versions.last.info_checksum
158
+ versions.reject! { _1.number.start_with?("-") }
159
+ versions.each { |version| version.info_checksum = info_checksum }
160
+ end.map do |name, versions|
161
+ CompactIndex::Gem.new(name, versions)
162
+ end.sort_by(&:name)
163
+ @versions_file.create(gems, @time.iso8601)
164
+ @log << "Rebuilt versions list"
165
+ [200, {}, [@log.last]]
166
+ end
167
+
168
+ def versions
169
+ gems = compact_index_gem_versions(separate_yanks: true, after: @versions_file.updated_at.to_time)
170
+ [200, {},
171
+ # calculate_info_checksums: true breaks with yanks
172
+ [@versions_file.contents(gems)]]
173
+ end
174
+
175
+ def info(name)
176
+ versions = compact_index_gem_versions
177
+ .select { _1.name == name }
178
+ .flat_map(&:versions)
179
+ if versions.empty?
180
+ return [404, { "Content-Type" => "text/plain; charset=utf-8" },
181
+ ["This gem could not be found"]]
182
+ end
183
+
184
+ versions.reject! { _1.number.start_with?("-") }
185
+
186
+ [200, {}, [CompactIndex.info(versions)]]
187
+ end
188
+
189
+ def names
190
+ names = @versions.select(&:indexed).map!(&:rubygem_name).tap(&:sort!).tap(&:uniq!)
191
+ [200, {}, [CompactIndex.names(names)]]
192
+ end
193
+
194
+ class Asc
195
+ def initialize(obj)
196
+ @obj = obj
197
+ end
198
+
199
+ attr_reader :obj
200
+ protected :obj
201
+
202
+ def <=>(other)
203
+ return other.obj <=> obj if other.is_a?(self.class)
204
+
205
+ other <=> obj
206
+ end
207
+ end
208
+
209
+ def specs_gz(name = nil)
210
+ case name
211
+ when nil
212
+ specs = @versions.select(&:indexed).reject(&:prerelease)
213
+ when "latest"
214
+ specs = @versions.select(&:indexed).select(&:latest)
215
+ when "prerelease"
216
+ specs = @versions.select(&:indexed).select(&:prerelease)
217
+ else
218
+ return [404, { "Content-Type" => "text/plain" }, ["File not found: /specs.4.8.gz\n"]]
219
+ end
220
+
221
+ specs.sort_by! do |v|
222
+ [v.rubygem_name, Asc.new(v.position), Asc.new(v.platform.to_s)]
223
+ end
224
+ specs.map! do |v|
225
+ [v.rubygem_name, Gem::Version.new(v.number), v.platform.to_s]
226
+ end
227
+
228
+ [200, { "Content-Type" => "application/octet-stream" }, [Zlib.gzip(Marshal.dump(specs))]]
229
+ end
230
+
231
+ def quick_spec(original_name)
232
+ version = @versions.find do |v|
233
+ v.indexed && v.package.spec.original_name == original_name
234
+ end
235
+
236
+ if version
237
+ spec = version.package.spec.dup
238
+ spec.abbreviate
239
+ spec.sanitize
240
+ # TODO
241
+ if ENV["UPSTREAM"]
242
+ [200, { "Content-Type" => "text/plain" }, [Gem.deflate(Marshal.dump(spec))]]
243
+ else
244
+ [200, { "Content-Type" => "application/octet-stream" }, [Gem.deflate(Marshal.dump(spec))]]
245
+ end
246
+ else
247
+ [404, { "Content-Type" => "text/plain" }, ["File not found: /quick/Marshal.4.8/#{original_name}.gemspec.rz\n"]]
248
+ end
249
+ end
250
+
251
+ def gem(full_name)
252
+ version = @versions.find do |v|
253
+ v.indexed && v.package.spec.full_name == full_name
254
+ end
255
+
256
+ if version
257
+ contents = version.package.gem.io.string
258
+ [200, { "Content-Type" => "application/octet-stream" }, [contents]]
259
+ else
260
+ [404, { "Content-Type" => "text/plain" }, ["File not found: /gems/#{full_name}.gem\n"]]
261
+ end
262
+ end
263
+
264
+ private
265
+
266
+ def log(message)
267
+ @log << "[#{@time}] #{message}"
268
+ # warn @log.last
269
+ end
270
+
271
+ def compact_index_gem_versions(separate_yanks: false, before: nil, after: nil)
272
+ raise ArgumentError, "before and after cannot be used together" if before && after
273
+
274
+ if separate_yanks
275
+ yanks = @versions.select(&:yanked_at)
276
+ yanks.select! { _1.yanked_at > after } if after
277
+ yanks.reject! { _1.yanked_at <= before } if before
278
+
279
+ yanks.map! do |version|
280
+ version = version.dup
281
+ version.indexed = true
282
+ version.yanked_at = nil
283
+ version.yanked_info_checksum = nil
284
+ version
285
+ end
286
+ else
287
+ yanks = []
288
+ end
289
+
290
+ all_versions = @versions + yanks
291
+ all_versions.select! { (_1.yanked_at || _1.pushed_at) <= before } if before
292
+ all_versions.select! { _1.pushed_at > after || (_1.yanked_at && _1.yanked_at > after) } if after
293
+
294
+ all_versions
295
+ # use index to keep order stable
296
+ .sort_by.with_index { |version, idx| [version.yanked_at || version.pushed_at, idx] }
297
+ .map do |version|
298
+ spec = version.package.spec
299
+ CompactIndex::Gem.new(
300
+ version.rubygem_name,
301
+ [
302
+ CompactIndex::GemVersion.new(
303
+ version.number.then do |n|
304
+ version.indexed ? n : "-#{n}"
305
+ end,
306
+ version.platform,
307
+ version.sha256,
308
+ version.yanked_info_checksum || version.info_checksum,
309
+ spec.runtime_dependencies.map do |dep|
310
+ CompactIndex::Dependency.new(dep.name, dep.requirement.to_s)
311
+ end,
312
+ spec.required_ruby_version&.to_s, spec.required_rubygems_version&.to_s
313
+ )
314
+ ]
315
+ )
316
+ end
317
+ end
318
+ end
319
+ end
320
+
321
+ if __FILE__ == $0
322
+ require "rackup"
323
+ GemServerConformance::Server::Application.run!
324
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GemServerConformance
4
+ VERSION = "0.1.0"
5
+ end