grack 0.0.2 → 0.1.0.pre1

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.travis.yml +7 -0
  3. data/.yardopts +1 -0
  4. data/LICENSE +22 -0
  5. data/NEWS.md +19 -0
  6. data/README.md +211 -0
  7. data/Rakefile +222 -0
  8. data/lib/git_adapter.rb +1 -0
  9. data/lib/grack.rb +1 -0
  10. data/lib/grack/app.rb +482 -0
  11. data/lib/grack/compatible_git_adapter.rb +99 -0
  12. data/lib/grack/file_streamer.rb +41 -0
  13. data/lib/grack/git_adapter.rb +142 -0
  14. data/lib/grack/io_streamer.rb +45 -0
  15. data/tests/app_test.rb +534 -0
  16. data/tests/compatible_git_adapter_test.rb +137 -0
  17. data/tests/example/_git/COMMIT_EDITMSG +1 -0
  18. data/tests/example/_git/HEAD +1 -0
  19. data/tests/example/_git/config +6 -0
  20. data/tests/example/_git/description +1 -0
  21. data/tests/example/_git/hooks/applypatch-msg.sample +15 -0
  22. data/tests/example/_git/hooks/commit-msg.sample +24 -0
  23. data/tests/example/_git/hooks/post-commit.sample +8 -0
  24. data/tests/example/_git/hooks/post-receive.sample +15 -0
  25. data/tests/example/_git/hooks/post-update.sample +8 -0
  26. data/tests/example/_git/hooks/pre-applypatch.sample +14 -0
  27. data/tests/example/_git/hooks/pre-commit.sample +50 -0
  28. data/tests/example/_git/hooks/pre-rebase.sample +169 -0
  29. data/tests/example/_git/hooks/prepare-commit-msg.sample +36 -0
  30. data/tests/example/_git/hooks/update.sample +128 -0
  31. data/tests/example/_git/index +0 -0
  32. data/tests/example/_git/info/exclude +6 -0
  33. data/tests/example/_git/info/refs +1 -0
  34. data/tests/example/_git/logs/HEAD +1 -0
  35. data/tests/example/_git/logs/refs/heads/master +1 -0
  36. data/tests/example/_git/objects/31/d73eb4914a8ddb6cb0e4adf250777161118f90 +0 -0
  37. data/tests/example/_git/objects/cb/067e06bdf6e34d4abebf6cf2de85d65a52c65e +0 -0
  38. data/tests/example/_git/objects/ce/013625030ba8dba906f756967f9e9ca394464a +0 -0
  39. data/tests/example/_git/objects/info/packs +2 -0
  40. data/tests/example/_git/objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.idx +0 -0
  41. data/tests/example/_git/objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.pack +0 -0
  42. data/tests/example/_git/refs/heads/master +1 -0
  43. data/tests/file_streamer_test.rb +37 -0
  44. data/tests/git_adapter_test.rb +104 -0
  45. data/tests/io_streamer_test.rb +36 -0
  46. data/tests/test_helper.rb +36 -0
  47. metadata +292 -19
  48. data/lib/git_http.rb +0 -304
@@ -0,0 +1 @@
1
+ require 'grack/git_adapter'
@@ -0,0 +1 @@
1
+ require 'grack/app'
@@ -0,0 +1,482 @@
1
+ require 'pathname'
2
+ require 'rack/request'
3
+ require 'rack/response'
4
+ require 'time'
5
+ require 'zlib'
6
+
7
+ require 'grack/git_adapter'
8
+
9
+ ##
10
+ # A namespace for all Grack functionality.
11
+ module Grack
12
+ ##
13
+ # A Rack application for serving Git repositories over HTTP.
14
+ class App
15
+ ##
16
+ # A list of supported pack service types.
17
+ VALID_SERVICE_TYPES = %w{git-upload-pack git-receive-pack}
18
+
19
+ ##
20
+ # Route mappings from URIs to valid verbs and handler functions.
21
+ ROUTES = [
22
+ [%r'/(.*?)/(git-(?:upload|receive)-pack)$', 'POST', :handle_pack],
23
+ [%r'/(.*?)/info/refs$', 'GET', :info_refs],
24
+ [%r'/(.*?)/(HEAD)$', 'GET', :text_file],
25
+ [%r'/(.*?)/(objects/info/alternates)$', 'GET', :text_file],
26
+ [%r'/(.*?)/(objects/info/http-alternates)$', 'GET', :text_file],
27
+ [%r'/(.*?)/(objects/info/packs)$', 'GET', :info_packs],
28
+ [%r'/(.*?)/(objects/info/[^/]+)$', 'GET', :text_file],
29
+ [%r'/(.*?)/(objects/[0-9a-f]{2}/[0-9a-f]{38})$', 'GET', :loose_object],
30
+ [%r'/(.*?)/(objects/pack/pack-[0-9a-f]{40}\.pack)$', 'GET', :pack_file],
31
+ [%r'/(.*?)/(objects/pack/pack-[0-9a-f]{40}\.idx)$', 'GET', :idx_file],
32
+ ]
33
+
34
+ ##
35
+ # Creates a new instance of this application with the configuration provided
36
+ # by _opts_.
37
+ #
38
+ # @param [Hash] opts a hash of supported options.
39
+ # @option opts [String] :root (Dir.pwd) a directory path containing 1 or
40
+ # more Git repositories.
41
+ # @option opts [Boolean, nil] :allow_push (nil) determines whether or not to
42
+ # allow pushes into the repositories. +nil+ means to defer to the
43
+ # requested repository.
44
+ # @option opts [Boolean, nil] :allow_pull (nil) determines whether or not to
45
+ # allow fetches/pulls from the repositories. +nil+ means to defer to the
46
+ # requested repository.
47
+ # @option opts [#call] :git_adapter_factory (->{ GitAdapter.new }) a
48
+ # call-able object that creates Git adapter instances per request.
49
+ def initialize(opts = {})
50
+ opts = convert_old_opts(opts)
51
+
52
+ @root = Pathname.new(opts.fetch(:root, '.')).expand_path
53
+ @allow_push = opts.fetch(:allow_push, nil)
54
+ @allow_pull = opts.fetch(:allow_pull, nil)
55
+ @git_adapter_factory =
56
+ opts.fetch(:git_adapter_factory, ->{ GitAdapter.new })
57
+ end
58
+
59
+ ##
60
+ # The Rack handler entry point for this application. This duplicates the
61
+ # object and uses the duplicate to perform the work in order to enable
62
+ # thread safe request handling.
63
+ #
64
+ # @param [Hash] env a Rack request hash.
65
+ #
66
+ # @return a Rack response object.
67
+ def call(env)
68
+ dup._call(env)
69
+ end
70
+
71
+ protected
72
+
73
+ ##
74
+ # The real request handler.
75
+ #
76
+ # @param [Hash] env a Rack request hash.
77
+ #
78
+ # @return a Rack response object.
79
+ def _call(env)
80
+ @git = @git_adapter_factory.call
81
+ @env = env
82
+ @request = Rack::Request.new(env)
83
+ route
84
+ end
85
+
86
+ private
87
+
88
+ ##
89
+ # The Rack request hash.
90
+ attr_reader :env
91
+
92
+ ##
93
+ # The request object built from the request hash.
94
+ attr_reader :request
95
+
96
+ ##
97
+ # The Git adapter instance for the requested repository.
98
+ attr_reader :git
99
+
100
+ ##
101
+ # The path containing 1 or more Git repositories which may be requested.
102
+ attr_reader :root
103
+
104
+ ##
105
+ # The path to the repository.
106
+ attr_reader :repository_uri
107
+
108
+ ##
109
+ # The HTTP verb of the request.
110
+ attr_reader :request_verb
111
+
112
+ ##
113
+ # The requested pack type. Will be +nil+ for requests that do no involve
114
+ # pack RPCs.
115
+ attr_reader :pack_type
116
+
117
+ ##
118
+ # @return [Boolean] +true+ if the request is authorized; otherwise, +false+.
119
+ def authorized?
120
+ return allow_pull? if need_read?
121
+ return allow_push?
122
+ end
123
+
124
+ ##
125
+ # @return [Boolean] +true+ if read permissions are needed; otherwise,
126
+ # +false+.
127
+ def need_read?
128
+ (request_verb == 'GET' && pack_type != 'git-receive-pack') ||
129
+ request_verb == 'POST' && pack_type == 'git-upload-pack'
130
+ end
131
+
132
+ ##
133
+ # Determines whether or not pushes into the requested repository are
134
+ # allowed.
135
+ #
136
+ # @return [Boolean] +true+ if pushes are allowed, +false+ otherwise.
137
+ def allow_push?
138
+ @allow_push || (@allow_push.nil? && git.allow_push?)
139
+ end
140
+
141
+ ##
142
+ # Determines whether or not fetches/pulls from the requested repository are
143
+ # allowed.
144
+ #
145
+ # @return [Boolean] +true+ if fetches are allowed, +false+ otherwise.
146
+ def allow_pull?
147
+ @allow_pull || (@allow_pull.nil? && git.allow_pull?)
148
+ end
149
+
150
+ ##
151
+ # Routes requests to appropriate handlers. Performs request path cleanup
152
+ # and several sanity checks prior to attempting to handle the request.
153
+ #
154
+ # @return a Rack response object.
155
+ def route
156
+ # Sanitize the URI:
157
+ # * Unescape escaped characters
158
+ # * Replace runs of / with a single /
159
+ path_info = Rack::Utils.unescape(request.path_info).gsub(%r{/+}, '/')
160
+
161
+ ROUTES.each do |path_matcher, verb, handler|
162
+ path_info.match(path_matcher) do |match|
163
+ @repository_uri = match[1]
164
+ @request_verb = verb
165
+
166
+ return method_not_allowed unless verb == request.request_method
167
+ return bad_request if bad_uri?(@repository_uri)
168
+
169
+ git.repository_path = root + @repository_uri
170
+ return not_found unless git.exist?
171
+
172
+ return send(handler, *match[2..-1])
173
+ end
174
+ end
175
+ not_found
176
+ end
177
+
178
+ ##
179
+ # Processes pack file exchange requests for both push and pull. Ensures
180
+ # that the request is allowed and properly formatted.
181
+ #
182
+ # @param [String] pack_type the type of pack exchange to perform per the
183
+ # request.
184
+ #
185
+ # @return a Rack response object.
186
+ def handle_pack(pack_type)
187
+ @pack_type = pack_type
188
+ unless request.content_type == "application/x-#{@pack_type}-request" &&
189
+ valid_pack_type? && authorized?
190
+ return no_access
191
+ end
192
+
193
+ headers = {'Content-Type' => "application/x-#{@pack_type}-result"}
194
+ exchange_pack(headers, request_io_in)
195
+ end
196
+
197
+ ##
198
+ # Processes requests for the list of refs for the requested repository.
199
+ #
200
+ # This works for both Smart HTTP clients and basic ones. For basic clients,
201
+ # the Git adapter is used to update the +info/refs+ file which is then
202
+ # served to the clients. For Smart HTTP clients, the more efficient pack
203
+ # file exchange mechanism is used.
204
+ #
205
+ # @return a Rack response object.
206
+ def info_refs
207
+ @pack_type = request.params['service']
208
+ return no_access unless authorized?
209
+
210
+ if @pack_type.nil?
211
+ git.update_server_info
212
+ send_file(
213
+ git.file('info/refs'), 'text/plain; charset=utf-8', hdr_nocache
214
+ )
215
+ elsif valid_pack_type?
216
+ headers = hdr_nocache
217
+ headers['Content-Type'] = "application/x-#{@pack_type}-advertisement"
218
+ exchange_pack(headers, nil, {:advertise_refs => true})
219
+ else
220
+ not_found
221
+ end
222
+ end
223
+
224
+ ##
225
+ # Processes requests for info packs for the requested repository.
226
+ #
227
+ # @param [String] path the path to an info pack file within a Git
228
+ # repository.
229
+ #
230
+ # @return a Rack response object.
231
+ def info_packs(path)
232
+ return no_access unless authorized?
233
+ send_file(git.file(path), 'text/plain; charset=utf-8', hdr_nocache)
234
+ end
235
+
236
+ ##
237
+ # Processes a request for a loose object at _path_ for the selected
238
+ # repository. If the file is located, the content type is set to
239
+ # +application/x-git-loose-object+ and permanent caching is enabled.
240
+ #
241
+ # @param [String] path the path to a loose object file within a Git
242
+ # repository, such as +objects/31/d73eb4914a8ddb6cb0e4adf250777161118f90+.
243
+ #
244
+ # @return a Rack response object.
245
+ def loose_object(path)
246
+ return no_access unless authorized?
247
+ send_file(
248
+ git.file(path), 'application/x-git-loose-object', hdr_cache_forever
249
+ )
250
+ end
251
+
252
+ ##
253
+ # Process a request for a pack file located at _path_ for the selected
254
+ # repository. If the file is located, the content type is set to
255
+ # +application/x-git-packed-objects+ and permanent caching is enabled.
256
+ #
257
+ # @param [String] path the path to a pack file within a Git repository such
258
+ # as +pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.pack+.
259
+ #
260
+ # @return a Rack response object.
261
+ def pack_file(path)
262
+ return no_access unless authorized?
263
+ send_file(
264
+ git.file(path), 'application/x-git-packed-objects', hdr_cache_forever
265
+ )
266
+ end
267
+
268
+ ##
269
+ # Process a request for a pack index file located at _path_ for the selected
270
+ # repository. If the file is located, the content type is set to
271
+ # +application/x-git-packed-objects-toc+ and permanent caching is enabled.
272
+ #
273
+ # @param [String] path the path to a pack index file within a Git
274
+ # repository, such as
275
+ # +pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.idx+.
276
+ #
277
+ # @return a Rack response object.
278
+ def idx_file(path)
279
+ return no_access unless authorized?
280
+ send_file(
281
+ git.file(path),
282
+ 'application/x-git-packed-objects-toc',
283
+ hdr_cache_forever
284
+ )
285
+ end
286
+
287
+ ##
288
+ # Process a request for a generic file located at _path_ for the selected
289
+ # repository. If the file is located, the content type is set to
290
+ # +text/plain+ and caching is disabled.
291
+ #
292
+ # @param [String] path the path to a file within a Git repository, such as
293
+ # +HEAD+.
294
+ #
295
+ # @return a Rack response object.
296
+ def text_file(path)
297
+ return no_access unless authorized?
298
+ send_file(git.file(path), 'text/plain', hdr_nocache)
299
+ end
300
+
301
+ ##
302
+ # Produces a Rack response that wraps the output from the Git adapter.
303
+ #
304
+ # A 404 response is produced if _streamer_ is +nil+. Otherwise a 200
305
+ # response is produced with _streamer_ as the response body.
306
+ #
307
+ # @param [FileStreamer,IOStreamer] streamer a provider of content for the
308
+ # response body.
309
+ # @param [String] content_type the MIME type of the content.
310
+ # @param [Hash] headers additional headers to include in the response.
311
+ #
312
+ # @return a Rack response object.
313
+ def send_file(streamer, content_type, headers = {})
314
+ return not_found if streamer.nil?
315
+
316
+ headers['Content-Type'] = content_type
317
+ headers['Last-Modified'] = streamer.mtime.httpdate
318
+
319
+ [200, headers, streamer]
320
+ end
321
+
322
+ ##
323
+ # Opens a tunnel for the pack file exchange protocol between the client and
324
+ # the Git adapter.
325
+ #
326
+ # @param [Hash] headers headers to provide in the Rack response.
327
+ # @param [#read] io_in a readable, IO-like object providing client input
328
+ # data.
329
+ # @param [Hash] opts options to pass to the Git adapter's #handle_pack
330
+ # method.
331
+ #
332
+ # @return a Rack response object.
333
+ def exchange_pack(headers, io_in, opts = {})
334
+ Rack::Response.new([], 200, headers).finish do |response|
335
+ git.handle_pack(pack_type, io_in, response, opts)
336
+ end
337
+ end
338
+
339
+ ##
340
+ # Transparently ensures that the request body is not compressed.
341
+ #
342
+ # @return [#read] a +read+-able object that yields uncompressed data from
343
+ # the request body.
344
+ def request_io_in
345
+ return request.body unless env['HTTP_CONTENT_ENCODING'] =~ /gzip/
346
+ Zlib::GzipReader.new(request.body)
347
+ end
348
+
349
+ ##
350
+ # Determines whether or not the requested pack type is valid.
351
+ #
352
+ # @return [Boolean] +true+ if the pack type is valid; otherwise, +false+.
353
+ def valid_pack_type?
354
+ VALID_SERVICE_TYPES.include?(pack_type)
355
+ end
356
+
357
+ ##
358
+ # Determines whether or not _path_ is an acceptable URI.
359
+ #
360
+ # @param [String] path the path part of the request URI.
361
+ #
362
+ # @return [Boolean] +true+ if the requested path is considered invalid;
363
+ # otherwise, +false+.
364
+ def bad_uri?(path)
365
+ invalid_segments = %w{. ..}
366
+ path.split('/').any? { |segment| invalid_segments.include?(segment) }
367
+ end
368
+
369
+ # --------------------------------------
370
+ # HTTP error response handling functions
371
+ # --------------------------------------
372
+
373
+ ##
374
+ # A shorthand for specifying a text content type for the Rack response.
375
+ PLAIN_TYPE = {'Content-Type' => 'text/plain'}
376
+
377
+ ##
378
+ # Returns a Rack response appropriate for requests that use invalid verbs
379
+ # for the requested resources.
380
+ #
381
+ # For HTTP 1.1 requests, a 405 code is returned. For other versions, the
382
+ # value from #bad_request is returned.
383
+ #
384
+ # @return a Rack response appropriate for requests that use invalid verbs
385
+ # for the requested resources.
386
+ def method_not_allowed
387
+ if env['SERVER_PROTOCOL'] == 'HTTP/1.1'
388
+ [405, PLAIN_TYPE, ['Method Not Allowed']]
389
+ else
390
+ bad_request
391
+ end
392
+ end
393
+
394
+ ##
395
+ # @return a Rack response for generally bad requests.
396
+ def bad_request
397
+ [400, PLAIN_TYPE, ['Bad Request']]
398
+ end
399
+
400
+ ##
401
+ # @return a Rack response for unlocatable resources.
402
+ def not_found
403
+ [404, PLAIN_TYPE, ['Not Found']]
404
+ end
405
+
406
+ ##
407
+ # @return a Rack response for forbidden resources.
408
+ def no_access
409
+ [403, PLAIN_TYPE, ['Forbidden']]
410
+ end
411
+
412
+
413
+ # ------------------------
414
+ # header writing functions
415
+ # ------------------------
416
+
417
+ ##
418
+ # NOTE: This should probably be converted to a constant.
419
+ #
420
+ # @return a hash of headers that should prevent caching of a Rack response.
421
+ def hdr_nocache
422
+ {
423
+ 'Expires' => 'Fri, 01 Jan 1980 00:00:00 GMT',
424
+ 'Pragma' => 'no-cache',
425
+ 'Cache-Control' => 'no-cache, max-age=0, must-revalidate'
426
+ }
427
+ end
428
+
429
+ ##
430
+ # @return a hash of headers that should trigger caches permanent caching.
431
+ def hdr_cache_forever
432
+ now = Time.now().to_i
433
+ {
434
+ 'Date' => now.to_s,
435
+ 'Expires' => (now + 31536000).to_s,
436
+ 'Cache-Control' => 'public, max-age=31536000'
437
+ }
438
+ end
439
+
440
+ ##
441
+ # Converts old configuration settings to current ones.
442
+ #
443
+ # @param [Hash] opts an options hash to convert.
444
+ # @option opts [String] :project_root a directory path containing 1 or more
445
+ # Git repositories.
446
+ # @option opts [Boolean, nil] :receivepack determines whether or not to
447
+ # allow pushes into the repositories. +nil+ means to defer to the
448
+ # requested repository.
449
+ # @option opts [Boolean, nil] :uploadpack determines whether or not to
450
+ # allow fetches/pulls from the repositories. +nil+ means to defer to the
451
+ # requested repository.
452
+ # @option opts [#create] :adapter a class that provides an interface for
453
+ # interacting with Git repositories.
454
+ #
455
+ # @return an options hash with current options set based on old ones.
456
+ def convert_old_opts(opts)
457
+ opts = opts.dup
458
+
459
+ if opts.key?(:project_root) && ! opts.key?(:root)
460
+ opts[:root] = opts.fetch(:project_root)
461
+ end
462
+ if opts.key?(:upload_pack) && ! opts.key?(:allow_pull)
463
+ opts[:allow_pull] = opts.fetch(:upload_pack)
464
+ end
465
+ if opts.key?(:receive_pack) && ! opts.key?(:allow_push)
466
+ opts[:allow_push] = opts.fetch(:receive_pack)
467
+ end
468
+ if opts.key?(:adapter) && ! opts.key?(:git_adapter_factory)
469
+ adapter = opts.fetch(:adapter)
470
+ opts[:git_adapter_factory] =
471
+ if GitAdapter == adapter
472
+ ->{ GitAdapter.new(opts.fetch(:git_path, 'git')) }
473
+ else
474
+ require 'grack/compatible_git_adapter'
475
+ ->{ CompatibleGitAdapter.new(adapter.new) }
476
+ end
477
+ end
478
+
479
+ opts
480
+ end
481
+ end
482
+ end