geminabox 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4286ff9507b08e7212e2fc72813336bf169e4c86e082802bc600648ecc8a0e99
4
- data.tar.gz: ffdd59e137b59252c6a9f526652ee5e99c092cd55e6e13ef191a9e004bbeb47d
3
+ metadata.gz: 758c29c033252b87dc6fbe24ee015340f6f0c365ecc01aed02b7809e15ff3a6e
4
+ data.tar.gz: 321199cded3cc8b60cf4ecf7f9b04c2a14ec5cc5246a0f072e3d4d12307106cd
5
5
  SHA512:
6
- metadata.gz: 215aef46c5ecfe77d391368be8a119230f4b2afe11e65a31b468e67a24c3030aa427801fff384080e75bd8942ac2b9006adf917e8dda5c6857bb9888ad6838ba
7
- data.tar.gz: e29de3a2b4eedc3f603c41af8b449bb90a7f6f6ef2a75184d2febe416a86bc1680d0e2d6525591f585571f5749359b2d7c6c130eed940327f6109d9f397f1493
6
+ metadata.gz: b726d61fe2807fb6c2d0d19544c9e52d8b3cef10be0dd8d772e72f866d286017609034e54ff15ac222cb5bca6fac9394e57b964ae8cb939c7a4e34bc73431f89
7
+ data.tar.gz: 0d84ea5560b6cc1bd32de94dd0b6952256f820e98ca5fdfbda9656c47eea1fecac25c8f3177518fc6ca59ba2fc293f072f75c18154f69d1e50cb70131208c05a
data/README.md CHANGED
@@ -4,14 +4,12 @@
4
4
 
5
5
  [![Ruby](https://github.com/geminabox/geminabox/actions/workflows/ruby.yml/badge.svg)](https://github.com/geminabox/geminabox/actions/workflows/ruby.yml?query=branch%3Amaster)
6
6
  [![Gem Version](https://badge.fury.io/rb/geminabox.svg)](http://badge.fury.io/rb/geminabox)
7
- [![Code Climate](https://codeclimate.com/github/geminabox/geminabox/badges/gpa.svg)](https://codeclimate.com/github/geminabox/geminabox)
8
-
9
7
  Geminabox lets you host your own gems, and push new gems to it just like with rubygems.org.
10
- The bundler dependencies API is supported out of the box.
8
+ Bundler finds your gems through the [compact index](#compact-index), with no client configuration.
11
9
  Authentication is left up to either the web server, or the Rack stack.
12
- For basic auth, try [Rack::Auth](http://www.rubydoc.info/github/rack/rack/Rack/Auth/Basic).
10
+ For basic auth, try [Rack::Auth::Basic](https://rubydoc.info/gems/rack/Rack/Auth/Basic).
13
11
 
14
- ![screen shot](http://pics.tomlea.co.uk/bbbba6/geminabox.png)
12
+ ![Geminabox web UI listing hosted gems with install commands](docs/screenshot.png)
15
13
 
16
14
  ## System Requirements
17
15
 
@@ -20,7 +18,10 @@ For basic auth, try [Rack::Auth](http://www.rubydoc.info/github/rack/rack/Rack/A
20
18
 
21
19
  ## Server Setup
22
20
 
23
- gem install geminabox
21
+ gem install geminabox rackup webrick
22
+
23
+ `rackup` and a Rack server are separate gems on Ruby 3.0+ with Rack 3.
24
+ WEBrick is used here; any Rack server works.
24
25
 
25
26
  Create a config.ru as follows:
26
27
 
@@ -39,57 +40,138 @@ Create a config.ru as follows:
39
40
 
40
41
  run Geminabox::Server
41
42
 
42
- Start your gem server with 'rackup' to run WEBrick or hook up the config.ru as you normally would ([passenger](https://www.phusionpassenger.com/), [thin](http://code.macournoyer.com/thin/), [unicorn](https://bogomips.org/unicorn/), whatever floats your boat).
43
-
44
- ## RubyGems Proxy
45
-
46
- Geminabox can be configured to pull gems, it does not currently have, from rubygems.org. To enable this mode you can either:
47
-
48
- Set RUBYGEM_PROXY to true in the environment:
43
+ Start your gem server with `rackup`, or hook up the config.ru as you normally would ([passenger](https://www.phusionpassenger.com/), [puma](https://puma.io/), [unicorn](https://yhbt.net/unicorn/), whatever floats your boat).
49
44
 
50
- RUBYGEMS_PROXY=true rackup
45
+ ## Using Geminabox alongside rubygems.org
51
46
 
52
- Or in config.ru (before the run command), set:
47
+ Geminabox serves only the gems you push to it. To use it together with
48
+ rubygems.org, declare it as a scoped source in your Gemfile so that every gem
49
+ is pinned to exactly one source:
53
50
 
54
- Geminabox.rubygems_proxy = true
55
-
56
- If you want Geminabox to carry on providing gems when rubygems.org is unavailable, add this to config.ru:
57
-
58
- Geminabox.allow_remote_failure = true
59
-
60
- ## HTTP adapter
51
+ ```ruby
52
+ source "https://rubygems.org"
61
53
 
62
- Geminabox uses the HTTPClient gem to manage its connections to remote resources.
63
- The relationship is managed via Geminabox::HttpClientAdapter.
54
+ source "https://gems.example.com" do
55
+ gem "internal-widgets"
56
+ gem "internal-tools"
57
+ end
58
+ ```
64
59
 
65
- To configure options of HTTPClient, pass your own HTTPClient object in config.ru as:
60
+ This pinning is Bundler's protection against dependency confusion: a gem name
61
+ that exists on both servers can never silently resolve to the wrong one.
62
+
63
+ ### RubyGems proxy (removed in 4.0)
64
+
65
+ Earlier versions of Geminabox could proxy rubygems.org and serve remote and
66
+ local gems from one merged namespace. The feature was deprecated in 3.1.0 and
67
+ removed in 4.0:
68
+
69
+ - It relied on the RubyGems.org Dependency API
70
+ (`/api/v1/dependencies`), which was
71
+ [sunset on 2023-05-24](https://blog.rubygems.org/2023/02/22/dependency-api-deprecation.html),
72
+ so the proxy had not worked for years.
73
+ - Serving remote and local gems from one namespace invites dependency
74
+ confusion attacks and defeats Bundler's source pinning described above.
75
+
76
+ When upgrading, remove `Geminabox.rubygems_proxy` and its related settings
77
+ (`rubygems_proxy_merge_strategy`, `allow_remote_failure`, `ruby_gems_url`,
78
+ `bundler_ruby_gems_url`) from your config.ru, and point your Gemfile at
79
+ rubygems.org directly with a scoped source block as shown above. Leftover
80
+ settings won't break your server: 4.0 warns about each one and ignores it,
81
+ and the `RUBYGEMS_PROXY` and `RUBYGEMS_PROXY_MERGE_STRATEGY` environment
82
+ variables get the same startup warning. These shims go away in 5.0.
83
+
84
+ If you need a caching or mirroring proxy for rubygems.org (air-gapped
85
+ networks, bandwidth, protection against upstream yanks), use
86
+ [gemstash](https://github.com/rubygems/gemstash), maintained by the RubyGems
87
+ organization.
88
+
89
+ ## Compact index
90
+
91
+ Geminabox serves the [compact index API](https://guides.rubygems.org/rubygems-org-compact-index-api/)
92
+ (`/versions`, `/info/GEMNAME`, `/names`). Bundler 1.12+ detects and uses it
93
+ automatically; no client configuration is needed. Responses support
94
+ `If-None-Match` and ranged requests, so `bundle install` only downloads
95
+ index data that changed since the last run.
96
+
97
+ The materialized index lives in `data/compact_index/` and is updated
98
+ whenever gems are added or removed. Deleting that directory (or hitting
99
+ `/reindex`) is safe; it is rebuilt from the stored gems on the next request.
100
+
101
+ On installs upgraded from an earlier version with many stored gems, the
102
+ first request to `/versions` builds the index and can take a while as it
103
+ checksums every stored gem, so hitting `/reindex` or `/versions` right
104
+ after upgrading avoids surprising the first `bundle install`.
105
+
106
+ ### Running behind a reverse proxy
107
+
108
+ A stock nginx or Passenger deployment needs no special configuration for the
109
+ compact index. gzip is safe to leave on, even for `text/plain`: Bundler sends
110
+ its ranged requests without `Accept-Encoding`, and nginx never compresses 206
111
+ responses, so 304 revalidation and ranged tail appends keep working.
112
+
113
+ Two things do interfere:
114
+
115
+ - Proxy-level caching of `/versions`, `/info/*`, or `/names` (nginx
116
+ `proxy_cache`, or a CDN). The files reference each other by checksum, so a
117
+ cache serving one fresh and another stale makes Bundler report checksum
118
+ mismatches. Bundler already caches and revalidates client-side; leave these
119
+ paths uncached.
120
+ - Middleware or middleboxes that strip or rewrite headers. Removing `ETag`
121
+ disables 304 revalidation; removing `Repr-Digest`/`Digest` makes Bundler
122
+ refuse to append partial responses and re-download the full file after
123
+ every change.
124
+
125
+ ### Replacing a published version
126
+
127
+ `gem inabox -o` (and the `allow_replace` server option) overwrites a stored
128
+ gem in place: same version number, different contents. Geminabox updates the
129
+ compact index to match, including the new checksum. Bundler, however, guards
130
+ against a version's bytes changing.
131
+
132
+ Since 2.5, Bundler records each gem's checksum in the `CHECKSUMS` section of
133
+ `Gemfile.lock`. On a later resolve it compares the checksum the server now
134
+ advertises against the locked one, and if they differ for the same name and
135
+ version it aborts with `Bundler::ChecksumMismatchError`, treating the change as
136
+ a possible supply-chain swap. Nothing on the server can override this; the
137
+ conflict is between the client's lockfile and the replaced contents.
138
+
139
+ An overwrite is therefore transparent only to consumers who have not locked
140
+ that version yet. Anyone whose `Gemfile.lock` already pins it hits the error on
141
+ their next `bundle install` or `bundle update`. Their options are to remove
142
+ that gem's line from `CHECKSUMS`, delete the lockfile and re-resolve, or turn
143
+ off the check with `bundle config set --local disable_checksum_validation true`.
144
+ The clean fix is to bump the version instead of replacing it; treat `-o` as a
145
+ convenience for a private box whose gems nobody has locked yet.
146
+
147
+ ## HTTP client
148
+
149
+ The Geminabox server makes no outbound HTTP requests. The `gem inabox`
150
+ client uploads gems with the [HTTPClient](https://github.com/nahi/httpclient)
151
+ gem, which honors the `http_proxy` / `HTTP_PROXY` environment variables.
152
+
153
+ If you drive `GeminaboxClient` from Ruby (a Rake task, say), you can configure
154
+ its HTTP layer through `Geminabox.http_adapter` before creating the client:
66
155
 
67
156
  ```ruby
157
+ require "geminabox"
158
+ require "geminabox_client"
159
+
68
160
  # Geminabox.http_adapter = Geminabox::HttpClientAdapter.new # default
69
- Geminabox.http_adapter.http_client = HTTPClient.new(ENV['http_proxy']).tap do |http_client|
70
- http_client.transparent_gzip_decompression = true
161
+ Geminabox.http_adapter.http_client = HTTPClient.new.tap do |http_client|
71
162
  http_client.keep_alive_timeout = 32 # sec
72
- http_client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE
73
- http_client.send_timeout = 0
74
- http_client.receive_timeout = 0
75
163
  end
164
+
165
+ GeminaboxClient.new("https://gems.example.com").push("pkg/my-gem-1.0.0.gem")
76
166
  ```
77
167
 
78
- If you would like to use an alternative HTTP gem, create your own adapter
79
- and specify it in config.ru:
168
+ To use a different HTTP library, subclass `Geminabox::HttpAdapter` and
169
+ implement `post` and `set_auth` (the client's upload path), plus `get` and
170
+ `get_content` for completeness. `Geminabox::TemplateFaradayAdapter` is a
171
+ worked example.
80
172
 
81
173
  Geminabox.http_adapter = YourHttpAdapter.new
82
174
 
83
- It is recommend (but not essential) that your adapter inherits from HttpAdapter.
84
- The adapter will need to replace HttpAdapter's methods with those specific to
85
- the alternative HTTP gem. It should also be able to handle HTTP proxy
86
- settings.
87
-
88
- Defining your own adapter also allows you to configure Geminabox to use the
89
- local systems SSL certificates.
90
-
91
- TemplateFaradayAdapter is provided as an example of an alternative HTTPAdapter.
92
-
93
175
  ## Hooks
94
176
 
95
177
  You can add a hook (anything callable) which will be called when a gem is
@@ -102,15 +184,15 @@ end
102
184
  ```
103
185
 
104
186
  Typically you might use this to push a notification to your team chat. Any
105
- exceptions which occur within the hook is silently ignored, so please ensure they
106
- are handled properly if this is not desirable.
187
+ exceptions raised within the hook are silently ignored, so handle them
188
+ yourself if that is not what you want.
107
189
 
108
190
  Also, please note that this hook blocks `POST /upload` and `POST /api/v1/gems` APIs processing.
109
- Hook authors are responsible to perform any action non-blocking/async to avoid HTTP timeout.
191
+ Hook authors are responsible for making slow work non-blocking/async to avoid HTTP timeouts.
110
192
 
111
193
  ## Client Usage
112
194
 
113
- Since version 0.10, Geminabox supports the standard gemcutter push API:
195
+ Geminabox supports the standard gemcutter push API:
114
196
 
115
197
  gem push pkg/my-awesome-gem-1.0.gem --host HOST
116
198
 
@@ -120,7 +202,7 @@ You can also use the gem plugin:
120
202
 
121
203
  gem inabox pkg/my-awesome-gem-1.0.gem
122
204
 
123
- And since version 1.2.0, Geminabox supports the standard gemcutter yank API:
205
+ And the standard gemcutter yank API:
124
206
 
125
207
  gem yank my-awesome-gem -v 1.0 --host HOST
126
208
 
@@ -142,15 +224,18 @@ Simples!
142
224
  -c, --configure Configure GemInABox
143
225
  -g, --host HOST Host to upload to.
144
226
  -o, --overwrite Overwrite Gem.
227
+ -p, --port Sets port
145
228
 
146
229
 
147
230
  Common Options:
148
231
  -h, --help Get help on this command
149
232
  -V, --[no-]verbose Set the verbose level of output
150
- -q, --quiet Silence commands
233
+ -q, --quiet Silence command progress meter
234
+ --silent Silence RubyGems output
151
235
  --config-file FILE Use this config file instead of default
152
236
  --backtrace Show stack backtrace on errors
153
237
  --debug Turn on Ruby debugging
238
+ --norc Avoid loading any .gemrc file
154
239
 
155
240
 
156
241
  Arguments:
@@ -169,17 +254,27 @@ Using Gem in a Box is really simple with the Dockerfile. Move this Dockerfile i
169
254
  That directory only needs to contain:
170
255
 
171
256
  ```
172
- config.ru (explained above)
257
+ config.ru
173
258
  Gemfile
174
259
  Gemfile.lock
175
260
  ```
176
261
 
262
+ Use the config.ru from [Server Setup](#server-setup), with the data directory
263
+ set to the path the image prepares for it (the container runs as a non-root
264
+ user and cannot create directories elsewhere):
265
+
266
+ ```ruby
267
+ Geminabox.data = "/usr/src/app/data"
268
+ ```
269
+
177
270
  Your Gemfile only needs:
178
271
 
179
272
  ```ruby
180
273
  source 'https://rubygems.org'
181
274
 
182
275
  gem 'geminabox'
276
+ gem 'rackup'
277
+ gem 'webrick' # or other server you prefer
183
278
  ```
184
279
 
185
280
  From there
@@ -189,15 +284,19 @@ docker build -t geminabox .
189
284
  ```
190
285
 
191
286
  ```
192
- docker run -d -p 9292:9292 geminabox:latest
287
+ docker run -d -p 9292:9292 -v geminabox-data:/usr/src/app/data geminabox:latest
193
288
  ```
194
289
 
195
- Your server should now be running!
290
+ Your server should now be running! The `geminabox-data` volume keeps your
291
+ gems when the container is replaced; without it they are lost.
196
292
 
197
293
 
198
294
  ## Running the tests
199
295
 
200
- Running `rake` will run the complete test suite.
296
+ Running `rake` runs the unit, request, and integration tests.
297
+ `rake test:conformance` runs the RubyGems compact-index conformance suite
298
+ separately. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full set of checks
299
+ CI runs.
201
300
 
202
301
  The test suite uses
203
302
  [minitest-reporters](https://github.com/minitest-reporters/minitest-reporters)
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Geminabox
6
+ # Sinatra helpers implementing the compact index HTTP semantics:
7
+ # - ETag is the quoted MD5 of the full body (Bundler <= 2.4 verifies
8
+ # exactly that after reassembling ranged fetches).
9
+ # - A single byte range is honored in all three forms (bytes=N-, bytes=N-M,
10
+ # bytes=-N); Bundler only ever sends the open-ended tail. Multipart,
11
+ # malformed, and unsatisfiable ranges get a full 200 rather than a 416,
12
+ # which clients must tolerate (RFC 9110 permits ignoring Range).
13
+ # - Repr-Digest/Digest carry sha-256 of the FULL file even on 206 —
14
+ # without them Bundler >= 2.5 refuses to append partial responses.
15
+ #
16
+ # Requires the including class to provide #dependency_cache.
17
+ module CompactIndexApi
18
+ def serve_compact_file(path)
19
+ halt 404 unless File.file?(path)
20
+ stat = File.stat(path)
21
+ contents = File.binread(path)
22
+ md5, sha256 = compact_file_digests(path, stat, contents)
23
+ etag = %("#{md5}")
24
+ headers "ETag" => etag,
25
+ "Accept-Ranges" => "bytes",
26
+ "Repr-Digest" => "sha-256=:#{sha256}:",
27
+ "Digest" => "sha-256=#{sha256}",
28
+ "Cache-Control" => "max-age=60"
29
+ content_type "text/plain; charset=utf-8"
30
+ halt 304 if request.env["HTTP_IF_NONE_MATCH"] == etag
31
+ range = byte_range(request.env["HTTP_RANGE"], contents.bytesize)
32
+ if range
33
+ status 206
34
+ headers "Content-Range" =>
35
+ "bytes #{range.begin}-#{range.end}/#{contents.bytesize}"
36
+ contents.byteslice(range)
37
+ else
38
+ contents
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # Inclusive byte range to serve, or nil to serve the whole body.
45
+ def byte_range(header, size)
46
+ match = /\Abytes=(\d*)-(\d*)\z/.match(header.to_s)
47
+ return if match.nil? || size.zero?
48
+
49
+ first, last = match.captures
50
+ return suffix_range(last, size) if first.empty?
51
+
52
+ first_byte = Integer(first)
53
+ return if first_byte >= size
54
+
55
+ last_byte = last.empty? ? size - 1 : [Integer(last), size - 1].min
56
+ first_byte..last_byte unless last_byte < first_byte
57
+ end
58
+
59
+ # bytes=-N asks for the final N bytes; N == 0 is unsatisfiable.
60
+ def suffix_range(last, size)
61
+ return if last.empty?
62
+
63
+ length = Integer(last)
64
+ return if length.zero?
65
+
66
+ [size - length, 0].max..(size - 1)
67
+ end
68
+
69
+ def compact_file_digests(path, stat, contents)
70
+ key = "compact_digests:#{path}:#{stat.mtime.to_f}:#{stat.size}"
71
+ dependency_cache.marshal_cache(key) do
72
+ [Digest::MD5.hexdigest(contents),
73
+ [Digest::SHA256.digest(contents)].pack("m0")]
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'compact_index'
4
+ require 'digest'
5
+ require 'time'
6
+ require 'tempfile'
7
+ require 'tmpdir'
8
+ require 'fileutils'
9
+ require 'rubygems/util'
10
+
11
+ module Geminabox
12
+ # Materializes the compact index API bodies (/versions, /info/NAME, /names)
13
+ # under data/compact_index/, from the gems recorded in the legacy Marshal
14
+ # specs indexes. Callers must hold the repository lock while writing.
15
+ #
16
+ # Invariant: the MD5 in every versions.list line is the MD5 of the exact
17
+ # bytes of the info/NAME file current when the line was written.
18
+ class CompactIndexer
19
+ def initialize(data_dir = Geminabox.data)
20
+ @data_dir = data_dir
21
+ end
22
+
23
+ def compact_index_dir
24
+ File.join(@data_dir, "compact_index")
25
+ end
26
+
27
+ def versions_path
28
+ File.join(compact_index_dir, "versions.list")
29
+ end
30
+
31
+ def names_path
32
+ File.join(compact_index_dir, "names")
33
+ end
34
+
35
+ def info_path(name)
36
+ File.join(compact_index_dir, "info", name)
37
+ end
38
+
39
+ def reindex
40
+ FileUtils.mkdir_p(File.join(compact_index_dir, "info"))
41
+ known = known_versions
42
+ current = current_versions
43
+ if known
44
+ reconcile(known, current)
45
+ else
46
+ full_build(current)
47
+ end
48
+ end
49
+
50
+ # True when versions.list exists, parses, and records NAME at all --
51
+ # including a fully-yanked name whose live versions net to zero. Reads
52
+ # only versions.list and takes no lock, so callers can gate a read-path
53
+ # heal on it without letting arbitrary /info probes force index writes.
54
+ def ledger_lists?(name)
55
+ state, = known_versions
56
+ !state.nil? && state.key?(name)
57
+ end
58
+
59
+ # Rebuild the single info/NAME file the read path found missing while
60
+ # versions.list still lists the gem, so a partially corrupted index does
61
+ # not 404 a gem Bundler was told exists. Renders from the current specs
62
+ # index, so the bytes -- and their MD5 -- match what a full reconcile
63
+ # would write; a name with no live versions restores the empty body
64
+ # reconcile left at yank time. Returns whether it wrote the file; a name
65
+ # the ledger does not list is left to 404. Callers must hold the
66
+ # repository lock.
67
+ def heal_info(name)
68
+ return false unless ledger_lists?(name)
69
+
70
+ versions = current_versions[name]
71
+ if versions
72
+ write_info(name, versions)
73
+ else
74
+ atomic_write(info_path(name), CompactIndex.info([]))
75
+ end
76
+ true
77
+ end
78
+
79
+ private
80
+
81
+ # Cumulative state recorded in versions.list. Returns nil when the file
82
+ # is missing or unparseable, which triggers a from-scratch build.
83
+ # Otherwise returns [state, checksums]:
84
+ # state => name => array of version_and_platform strings still
85
+ # live (yanks subtracted).
86
+ # checksums => name => the last MD5 recorded for that name, so a
87
+ # content-only change can be detected against it.
88
+ def known_versions
89
+ return nil unless File.exist?(versions_path)
90
+
91
+ lines = File.read(versions_path).split("\n")
92
+ separator = lines.index("---")
93
+ return nil unless separator
94
+
95
+ state = Hash.new { |hash, key| hash[key] = [] }
96
+ checksums = {}
97
+ lines.drop(separator + 1).each do |line|
98
+ name, versions, checksum = line.split
99
+ return nil unless name && versions && checksum
100
+
101
+ versions.split(",").each do |entry|
102
+ if entry.start_with?("-")
103
+ state[name].delete(entry[1..])
104
+ else
105
+ state[name] << entry
106
+ end
107
+ end
108
+ checksums[name] = checksum
109
+ end
110
+ [state, checksums]
111
+ end
112
+
113
+ def reconcile(known, current)
114
+ known_state, known_checksums = known
115
+ additions = +""
116
+
117
+ current.each do |name, versions|
118
+ current_ids = versions.map(&:number_and_platform)
119
+ added = current_ids - known_state.fetch(name, [])
120
+ removed = known_state.fetch(name, []) - current_ids
121
+ if added.empty? && removed.empty?
122
+ line = refresh_line(name, versions, current_ids, known_checksums[name])
123
+ additions << line if line
124
+ next
125
+ end
126
+ info_body = write_info(name, versions)
127
+ entries = added + removed.map { |id| "-#{id}" }
128
+ additions << version_line(name, entries, info_body)
129
+ end
130
+
131
+ (known_state.keys - current.keys).each do |name|
132
+ removed = known_state[name]
133
+ next if removed.empty?
134
+
135
+ info_body = CompactIndex.info([])
136
+ atomic_write(info_path(name), info_body)
137
+ additions << version_line(name, removed.map { |id| "-#{id}" }, info_body)
138
+ end
139
+
140
+ if additions.empty?
141
+ # Self-heal a names file deleted out from under an intact
142
+ # versions.list, so /names does not 404 forever.
143
+ write_names(current.keys) unless File.exist?(names_path)
144
+ return
145
+ end
146
+ atomic_write(versions_path, File.read(versions_path) + additions)
147
+ write_names(current.keys)
148
+ end
149
+
150
+ # A same-version replacement (allow_replace / `gem inabox -o`) changes the
151
+ # .gem contents without changing the version identity set, so the identity
152
+ # diff is empty. Detect that here and, when the freshly rendered info body
153
+ # differs from what versions.list last recorded, emit a "touch" line that
154
+ # yanks and re-adds every current version in one entry. Bundler processes
155
+ # entries in order, so delete-then-add leaves the version set intact while
156
+ # the trailing MD5 (last checksum wins) points at the new info bytes.
157
+ # Returns nil when nothing needs to change, keeping reconcile idempotent.
158
+ def refresh_line(name, versions, current_ids, known_checksum)
159
+ return unless dirty?(name, versions)
160
+
161
+ info_body = write_info(name, versions)
162
+ return if Digest::MD5.hexdigest(info_body) == known_checksum
163
+
164
+ entries = current_ids.map { |id| "-#{id}" } + current_ids
165
+ version_line(name, entries, info_body)
166
+ end
167
+
168
+ # A gem is content-dirty when its info file is missing, or when any of its
169
+ # stored .gem files is at least as new as the info file. The >= tolerance
170
+ # matches Geminabox::Indexer.updated_gemspecs.
171
+ def dirty?(name, versions)
172
+ info = info_path(name)
173
+ return true unless File.exist?(info)
174
+
175
+ info_mtime = File.stat(info).mtime
176
+ versions.any? do |version|
177
+ gem_file = File.join(@data_dir, "gems", "#{version.gemfile_name}.gem")
178
+ File.exist?(gem_file) && File.stat(gem_file).mtime >= info_mtime
179
+ end
180
+ end
181
+
182
+ def version_line(name, entries, info_body)
183
+ "#{name} #{entries.join(',')} #{Digest::MD5.hexdigest(info_body)}\n"
184
+ end
185
+
186
+ # name => GemVersionCollection, name-sorted; versions version-sorted.
187
+ def current_versions
188
+ GemVersionCollection.from_specs_index(@data_dir).by_name.to_h
189
+ end
190
+
191
+ def full_build(current)
192
+ gems = current.map do |name, versions|
193
+ info_body = write_info(name, versions)
194
+ light_versions = versions.map do |version|
195
+ CompactIndex::GemVersion.new(version.number.to_s, version.platform,
196
+ nil, Digest::MD5.hexdigest(info_body))
197
+ end
198
+ CompactIndex::Gem.new(name, light_versions)
199
+ end
200
+ write_versions_list(gems)
201
+ write_names(current.keys)
202
+ end
203
+
204
+ def write_versions_list(gems)
205
+ # VersionsFile#create writes in place; render to a temp path and
206
+ # publish atomically like every other file we serve.
207
+ Dir.mktmpdir("compact", @data_dir) do |dir|
208
+ tmp_path = File.join(dir, "versions.list")
209
+ CompactIndex::VersionsFile.new(tmp_path).create(gems, Time.now.utc.iso8601)
210
+ atomic_write(versions_path, File.read(tmp_path))
211
+ end
212
+ end
213
+
214
+ # Renders and writes info/NAME; returns the exact bytes written so
215
+ # callers can hash them for the corresponding versions.list line.
216
+ def write_info(name, versions)
217
+ body = CompactIndex.info(info_versions(versions))
218
+ atomic_write(info_path(name), body)
219
+ body
220
+ end
221
+
222
+ def write_names(names)
223
+ atomic_write(names_path, CompactIndex.names(names.sort))
224
+ end
225
+
226
+ def info_versions(versions)
227
+ versions.map do |version|
228
+ spec = load_spec(version)
229
+ next unless spec
230
+
231
+ CompactIndex::GemVersion.new(
232
+ version.number.to_s,
233
+ version.platform,
234
+ gem_checksum(version),
235
+ nil,
236
+ info_dependencies(spec),
237
+ requirement_string(spec.required_ruby_version),
238
+ requirement_string(spec.required_rubygems_version)
239
+ )
240
+ end.compact
241
+ end
242
+
243
+ def info_dependencies(spec)
244
+ spec.dependencies
245
+ .select { |dep| dep.type == :runtime }
246
+ .map { |dep| [dep.name.is_a?(Array) ? dep.name.first : dep.name, dep.requirement.to_s] }
247
+ .sort_by(&:first)
248
+ .map { |name, requirement| CompactIndex::Dependency.new(name, requirement, nil, nil) }
249
+ end
250
+
251
+ def requirement_string(requirement)
252
+ requirement&.to_s
253
+ end
254
+
255
+ def load_spec(version)
256
+ spec_file = File.join(@data_dir, "quick", "Marshal.#{Gem.marshal_version}",
257
+ "#{version.gemfile_name}.gemspec.rz")
258
+ return unless File.exist?(spec_file)
259
+
260
+ Marshal.load(Gem::Util.inflate(Gem.read_binary(spec_file)))
261
+ end
262
+
263
+ def gem_checksum(version)
264
+ Digest::SHA256.file(File.join(@data_dir, "gems", "#{version.gemfile_name}.gem")).hexdigest
265
+ end
266
+
267
+ def atomic_write(file_name, contents)
268
+ temp_file = Tempfile.new(".compact", compact_index_dir)
269
+ temp_file.binmode
270
+ temp_file.write(contents)
271
+ # Tempfile is created 0600; publish the served files world-readable
272
+ # (subject to the process umask) like Gem::Indexer's own output.
273
+ # chmod while the handle is still open -- Tempfile#chmod delegates to
274
+ # the underlying File, which raises once closed.
275
+ temp_file.chmod(0o644 & ~File.umask)
276
+ temp_file.close
277
+ File.rename(temp_file.path, file_name)
278
+ end
279
+ end
280
+ end
@@ -37,6 +37,14 @@ module Geminabox
37
37
  included_platform = ruby? ? nil : platform
38
38
  [name, number, included_platform].compact.join('-')
39
39
  end
40
+
41
+ def number_and_platform
42
+ if platform.nil? || ruby?
43
+ number.to_s
44
+ else
45
+ "#{number}-#{platform}"
46
+ end
47
+ end
40
48
  end
41
49
 
42
50
  end