grack 0.0.2 → 0.1.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
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