geminabox 3.1.1 → 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: 4da5997cf6f07bf5f5c18308b50ddabae4e3acad3c59b5e725555632c94338ec
4
- data.tar.gz: 27a3b1392251204e9f210b9efd1b2dbb0e081e851381fce830385f1eb9db4c28
3
+ metadata.gz: 758c29c033252b87dc6fbe24ee015340f6f0c365ecc01aed02b7809e15ff3a6e
4
+ data.tar.gz: 321199cded3cc8b60cf4ecf7f9b04c2a14ec5cc5246a0f072e3d4d12307106cd
5
5
  SHA512:
6
- metadata.gz: 74b98e66ccc6112d37fb694189c216297cbdfedaeaf1570ce2c0682db24722cb59c1ea5f513f7068743beb7b607969c6eca404f47d72aac822d99a066e9c07be
7
- data.tar.gz: 4c818745e2edf7e128e4c6849d07a01f54b29cc851eaa57d5ffb9f101076b28db02f2c45ec49cc8c2bb25500f8e0ad1c67930b54b851122b4041e0f42fac8591
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,63 +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
- > **Deprecated.** The RubyGems proxy is unmaintained and **will be removed in
47
- > Geminabox 4.0.** It depends on the RubyGems.org Dependency API
48
- > (`/api/v1/dependencies`), which was [sunset on 2023-05-24](https://blog.rubygems.org/2023/02/22/dependency-api-deprecation.html)
49
- > in favour of the Compact Index API, so proxy mode no longer functions.
50
- > Enabling it now emits a deprecation warning. Do not rely on this feature.
51
-
52
- Geminabox can be configured to pull gems, it does not currently have, from rubygems.org. To enable this mode you can either:
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).
53
44
 
54
- Set RUBYGEM_PROXY to true in the environment:
45
+ ## Using Geminabox alongside rubygems.org
55
46
 
56
- RUBYGEMS_PROXY=true rackup
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:
57
50
 
58
- Or in config.ru (before the run command), set:
59
-
60
- Geminabox.rubygems_proxy = true
61
-
62
- If you want Geminabox to carry on providing gems when rubygems.org is unavailable, add this to config.ru:
63
-
64
- Geminabox.allow_remote_failure = true
65
-
66
- ## HTTP adapter
51
+ ```ruby
52
+ source "https://rubygems.org"
67
53
 
68
- Geminabox uses the HTTPClient gem to manage its connections to remote resources.
69
- 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
+ ```
70
59
 
71
- 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:
72
155
 
73
156
  ```ruby
157
+ require "geminabox"
158
+ require "geminabox_client"
159
+
74
160
  # Geminabox.http_adapter = Geminabox::HttpClientAdapter.new # default
75
- Geminabox.http_adapter.http_client = HTTPClient.new(ENV['http_proxy']).tap do |http_client|
76
- http_client.transparent_gzip_decompression = true
161
+ Geminabox.http_adapter.http_client = HTTPClient.new.tap do |http_client|
77
162
  http_client.keep_alive_timeout = 32 # sec
78
- http_client.ssl_config.verify_mode = OpenSSL::SSL::VERIFY_NONE
79
- http_client.send_timeout = 0
80
- http_client.receive_timeout = 0
81
163
  end
164
+
165
+ GeminaboxClient.new("https://gems.example.com").push("pkg/my-gem-1.0.0.gem")
82
166
  ```
83
167
 
84
- If you would like to use an alternative HTTP gem, create your own adapter
85
- 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.
86
172
 
87
173
  Geminabox.http_adapter = YourHttpAdapter.new
88
174
 
89
- It is recommend (but not essential) that your adapter inherits from HttpAdapter.
90
- The adapter will need to replace HttpAdapter's methods with those specific to
91
- the alternative HTTP gem. It should also be able to handle HTTP proxy
92
- settings.
93
-
94
- Defining your own adapter also allows you to configure Geminabox to use the
95
- local systems SSL certificates.
96
-
97
- TemplateFaradayAdapter is provided as an example of an alternative HTTPAdapter.
98
-
99
175
  ## Hooks
100
176
 
101
177
  You can add a hook (anything callable) which will be called when a gem is
@@ -108,15 +184,15 @@ end
108
184
  ```
109
185
 
110
186
  Typically you might use this to push a notification to your team chat. Any
111
- exceptions which occur within the hook is silently ignored, so please ensure they
112
- 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.
113
189
 
114
190
  Also, please note that this hook blocks `POST /upload` and `POST /api/v1/gems` APIs processing.
115
- 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.
116
192
 
117
193
  ## Client Usage
118
194
 
119
- Since version 0.10, Geminabox supports the standard gemcutter push API:
195
+ Geminabox supports the standard gemcutter push API:
120
196
 
121
197
  gem push pkg/my-awesome-gem-1.0.gem --host HOST
122
198
 
@@ -126,7 +202,7 @@ You can also use the gem plugin:
126
202
 
127
203
  gem inabox pkg/my-awesome-gem-1.0.gem
128
204
 
129
- And since version 1.2.0, Geminabox supports the standard gemcutter yank API:
205
+ And the standard gemcutter yank API:
130
206
 
131
207
  gem yank my-awesome-gem -v 1.0 --host HOST
132
208
 
@@ -148,15 +224,18 @@ Simples!
148
224
  -c, --configure Configure GemInABox
149
225
  -g, --host HOST Host to upload to.
150
226
  -o, --overwrite Overwrite Gem.
227
+ -p, --port Sets port
151
228
 
152
229
 
153
230
  Common Options:
154
231
  -h, --help Get help on this command
155
232
  -V, --[no-]verbose Set the verbose level of output
156
- -q, --quiet Silence commands
233
+ -q, --quiet Silence command progress meter
234
+ --silent Silence RubyGems output
157
235
  --config-file FILE Use this config file instead of default
158
236
  --backtrace Show stack backtrace on errors
159
237
  --debug Turn on Ruby debugging
238
+ --norc Avoid loading any .gemrc file
160
239
 
161
240
 
162
241
  Arguments:
@@ -175,11 +254,19 @@ Using Gem in a Box is really simple with the Dockerfile. Move this Dockerfile i
175
254
  That directory only needs to contain:
176
255
 
177
256
  ```
178
- config.ru (explained above)
257
+ config.ru
179
258
  Gemfile
180
259
  Gemfile.lock
181
260
  ```
182
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
+
183
270
  Your Gemfile only needs:
184
271
 
185
272
  ```ruby
@@ -197,15 +284,19 @@ docker build -t geminabox .
197
284
  ```
198
285
 
199
286
  ```
200
- docker run -d -p 9292:9292 geminabox:latest
287
+ docker run -d -p 9292:9292 -v geminabox-data:/usr/src/app/data geminabox:latest
201
288
  ```
202
289
 
203
- 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.
204
292
 
205
293
 
206
294
  ## Running the tests
207
295
 
208
- 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.
209
300
 
210
301
  The test suite uses
211
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