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 +4 -4
- data/README.md +149 -58
- data/lib/geminabox/compact_index_api.rb +77 -0
- data/lib/geminabox/compact_indexer.rb +280 -0
- data/lib/geminabox/gem_version.rb +8 -0
- data/lib/geminabox/gem_version_collection.rb +12 -0
- data/lib/geminabox/hostess.rb +5 -5
- data/lib/geminabox/incoming_gem.rb +1 -5
- data/lib/geminabox/server.rb +66 -56
- data/lib/geminabox/version.rb +1 -1
- data/lib/geminabox.rb +28 -24
- data/public/master.css +377 -107
- data/public/master.js +133 -10
- data/views/_version_row.erb +16 -0
- data/views/gem.erb +14 -27
- data/views/index.erb +43 -48
- data/views/layout.erb +16 -0
- data/views/upload.erb +3 -5
- metadata +34 -11
- data/lib/geminabox/gem_list_merge.rb +0 -40
- data/lib/geminabox/proxy/copier.rb +0 -37
- data/lib/geminabox/proxy/file_handler.rb +0 -102
- data/lib/geminabox/proxy/hostess.rb +0 -85
- data/lib/geminabox/proxy/splicer.rb +0 -85
- data/lib/geminabox/proxy.rb +0 -14
- data/lib/geminabox/rubygems_dependency.rb +0 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 758c29c033252b87dc6fbe24ee015340f6f0c365ecc01aed02b7809e15ff3a6e
|
|
4
|
+
data.tar.gz: 321199cded3cc8b60cf4ecf7f9b04c2a14ec5cc5246a0f072e3d4d12307106cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b726d61fe2807fb6c2d0d19544c9e52d8b3cef10be0dd8d772e72f866d286017609034e54ff15ac222cb5bca6fac9394e57b964ae8cb939c7a4e34bc73431f89
|
|
7
|
+
data.tar.gz: 0d84ea5560b6cc1bd32de94dd0b6952256f820e98ca5fdfbda9656c47eea1fecac25c8f3177518fc6ca59ba2fc293f072f75c18154f69d1e50cb70131208c05a
|
data/README.md
CHANGED
|
@@ -4,14 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/geminabox/geminabox/actions/workflows/ruby.yml?query=branch%3Amaster)
|
|
6
6
|
[](http://badge.fury.io/rb/geminabox)
|
|
7
|
-
[](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
|
-
|
|
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](
|
|
10
|
+
For basic auth, try [Rack::Auth::Basic](https://rubydoc.info/gems/rack/Rack/Auth/Basic).
|
|
13
11
|
|
|
14
|
-

|
|
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
|
|
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
|
-
|
|
45
|
+
## Using Geminabox alongside rubygems.org
|
|
55
46
|
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
54
|
+
source "https://gems.example.com" do
|
|
55
|
+
gem "internal-widgets"
|
|
56
|
+
gem "internal-tools"
|
|
57
|
+
end
|
|
58
|
+
```
|
|
70
59
|
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
85
|
-
and
|
|
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
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|