├── lib ├── grack.rb ├── git_adapter.rb └── grack │ ├── file_streamer.rb │ ├── io_streamer.rb │ ├── git_adapter.rb │ └── app.rb ├── tests ├── example │ └── _git │ │ ├── COMMIT_EDITMSG │ │ ├── HEAD │ │ ├── refs │ │ └── heads │ │ │ └── master │ │ ├── info │ │ ├── refs │ │ └── exclude │ │ ├── index │ │ ├── objects │ │ ├── 31 │ │ │ └── d73eb4914a8ddb6cb0e4adf250777161118f90 │ │ ├── info │ │ │ └── packs │ │ ├── cb │ │ │ └── 067e06bdf6e34d4abebf6cf2de85d65a52c65e │ │ ├── ce │ │ │ └── 013625030ba8dba906f756967f9e9ca394464a │ │ └── pack │ │ │ ├── pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.idx │ │ │ └── pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.pack │ │ ├── description │ │ ├── config │ │ ├── logs │ │ ├── HEAD │ │ └── refs │ │ │ └── heads │ │ │ └── master │ │ └── hooks │ │ ├── post-commit.sample │ │ ├── post-update.sample │ │ ├── pre-applypatch.sample │ │ ├── applypatch-msg.sample │ │ ├── post-receive.sample │ │ ├── commit-msg.sample │ │ ├── prepare-commit-msg.sample │ │ ├── pre-commit.sample │ │ ├── update.sample │ │ └── pre-rebase.sample ├── file_streamer_test.rb ├── io_streamer_test.rb ├── test_helper.rb ├── git_adapter_test.rb └── app_test.rb ├── .yardopts ├── .travis.yml ├── Gemfile ├── .gitignore ├── examples └── dispatch.fcgi ├── NEWS.md ├── LICENSE ├── grack.gemspec ├── README.md.erb ├── Rakefile └── README.md /lib/grack.rb: -------------------------------------------------------------------------------- 1 | require 'grack/app' 2 | -------------------------------------------------------------------------------- /lib/git_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'grack/git_adapter' 2 | -------------------------------------------------------------------------------- /tests/example/_git/COMMIT_EDITMSG: -------------------------------------------------------------------------------- 1 | Test Example 2 | -------------------------------------------------------------------------------- /tests/example/_git/HEAD: -------------------------------------------------------------------------------- 1 | ref: refs/heads/master 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --protected --private --main README.md lib/**/*.rb - NEWS.md LICENSE 2 | -------------------------------------------------------------------------------- /tests/example/_git/refs/heads/master: -------------------------------------------------------------------------------- 1 | cb067e06bdf6e34d4abebf6cf2de85d65a52c65e 2 | -------------------------------------------------------------------------------- /tests/example/_git/info/refs: -------------------------------------------------------------------------------- 1 | cb067e06bdf6e34d4abebf6cf2de85d65a52c65e refs/heads/master 2 | -------------------------------------------------------------------------------- /tests/example/_git/index: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grackorg/grack/HEAD/tests/example/_git/index -------------------------------------------------------------------------------- /tests/example/_git/objects/info/packs: -------------------------------------------------------------------------------- 1 | P pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.pack 2 | 3 | -------------------------------------------------------------------------------- /tests/example/_git/description: -------------------------------------------------------------------------------- 1 | Unnamed repository; edit this file 'description' to name the repository. 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | - 2.1.0 5 | - 2.1.1 6 | - 2.2.2 7 | script: bundle exec rake test 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | group :test, :development do 4 | gem 'coveralls', require: false 5 | end 6 | 7 | gemspec 8 | -------------------------------------------------------------------------------- /tests/example/_git/config: -------------------------------------------------------------------------------- 1 | [core] 2 | repositoryformatversion = 0 3 | filemode = true 4 | bare = false 5 | logallrefupdates = true 6 | ignorecase = true 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.sw? 2 | Gemfile.lock 3 | /.bundle/ 4 | /.yardoc/ 5 | /doc/ 6 | /pkg/ 7 | /vendor/bundle/ 8 | /tests/example/example_repo.git/ 9 | /coverage/ 10 | -------------------------------------------------------------------------------- /tests/example/_git/objects/31/d73eb4914a8ddb6cb0e4adf250777161118f90: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grackorg/grack/HEAD/tests/example/_git/objects/31/d73eb4914a8ddb6cb0e4adf250777161118f90 -------------------------------------------------------------------------------- /tests/example/_git/objects/cb/067e06bdf6e34d4abebf6cf2de85d65a52c65e: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grackorg/grack/HEAD/tests/example/_git/objects/cb/067e06bdf6e34d4abebf6cf2de85d65a52c65e -------------------------------------------------------------------------------- /tests/example/_git/objects/ce/013625030ba8dba906f756967f9e9ca394464a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grackorg/grack/HEAD/tests/example/_git/objects/ce/013625030ba8dba906f756967f9e9ca394464a -------------------------------------------------------------------------------- /tests/example/_git/logs/HEAD: -------------------------------------------------------------------------------- 1 | 0000000000000000000000000000000000000000 cb067e06bdf6e34d4abebf6cf2de85d65a52c65e Dawa Ometto 1370206701 +0200 commit (initial): Test Example 2 | -------------------------------------------------------------------------------- /tests/example/_git/logs/refs/heads/master: -------------------------------------------------------------------------------- 1 | 0000000000000000000000000000000000000000 cb067e06bdf6e34d4abebf6cf2de85d65a52c65e Dawa Ometto 1370206701 +0200 commit (initial): Test Example 2 | -------------------------------------------------------------------------------- /tests/example/_git/objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.idx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grackorg/grack/HEAD/tests/example/_git/objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.idx -------------------------------------------------------------------------------- /tests/example/_git/objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.pack: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grackorg/grack/HEAD/tests/example/_git/objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.pack -------------------------------------------------------------------------------- /tests/example/_git/hooks/post-commit.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script that is called after a successful 4 | # commit is made. 5 | # 6 | # To enable this hook, rename this file to "post-commit". 7 | 8 | : Nothing 9 | -------------------------------------------------------------------------------- /tests/example/_git/hooks/post-update.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to prepare a packed repository for use over 4 | # dumb transports. 5 | # 6 | # To enable this hook, rename this file to "post-update". 7 | 8 | exec git update-server-info 9 | -------------------------------------------------------------------------------- /tests/example/_git/info/exclude: -------------------------------------------------------------------------------- 1 | # git ls-files --others --exclude-from=.git/info/exclude 2 | # Lines that start with '#' are comments. 3 | # For a project mostly in C, the following would be a good set of 4 | # exclude patterns (uncomment them if you want to use them): 5 | # *.[oa] 6 | # *~ 7 | -------------------------------------------------------------------------------- /examples/dispatch.fcgi: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | 3 | $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib') 4 | require 'grack/app' 5 | require 'grack/git_adapter' 6 | 7 | config = { 8 | :root => '/opt', 9 | :allow_pull => true, 10 | :allow_push => false, 11 | } 12 | Rack::Handler::FastCGI.run(Grack::App.new(config)) 13 | -------------------------------------------------------------------------------- /tests/example/_git/hooks/pre-applypatch.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed 4 | # by applypatch from an e-mail message. 5 | # 6 | # The hook should exit with non-zero status after issuing an 7 | # appropriate message if it wants to stop the commit. 8 | # 9 | # To enable this hook, rename this file to "pre-applypatch". 10 | 11 | . git-sh-setup 12 | test -x "$GIT_DIR/hooks/pre-commit" && 13 | exec "$GIT_DIR/hooks/pre-commit" ${1+"$@"} 14 | : 15 | -------------------------------------------------------------------------------- /tests/example/_git/hooks/applypatch-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to check the commit log message taken by 4 | # applypatch from an e-mail message. 5 | # 6 | # The hook should exit with non-zero status after issuing an 7 | # appropriate message if it wants to stop the commit. The hook is 8 | # allowed to edit the commit message file. 9 | # 10 | # To enable this hook, rename this file to "applypatch-msg". 11 | 12 | . git-sh-setup 13 | test -x "$GIT_DIR/hooks/commit-msg" && 14 | exec "$GIT_DIR/hooks/commit-msg" ${1+"$@"} 15 | : 16 | -------------------------------------------------------------------------------- /tests/example/_git/hooks/post-receive.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script for the "post-receive" event. 4 | # 5 | # The "post-receive" script is run after receive-pack has accepted a pack 6 | # and the repository has been updated. It is passed arguments in through 7 | # stdin in the form 8 | # 9 | # For example: 10 | # aa453216d1b3e49e7f6f98441fa56946ddcd6a20 68f7abf4e6f922807889f52bc043ecd31b79f814 refs/heads/master 11 | # 12 | # see contrib/hooks/ for a sample, or uncomment the next line and 13 | # rename the file to "post-receive". 14 | 15 | #. /usr/share/doc/git-core/contrib/hooks/post-receive-email 16 | -------------------------------------------------------------------------------- /tests/file_streamer_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | require 'minitest/autorun' 4 | require 'minitest/unit' 5 | require 'tempfile' 6 | 7 | require 'grack/file_streamer' 8 | 9 | class FileStreamerTest < MiniTest::Test 10 | include Grack 11 | 12 | def setup 13 | @content = 'abcd' * 10_000 14 | @file = Tempfile.new('foo') 15 | @file.write(@content) 16 | @file.rewind 17 | @file.close 18 | @streamer = FileStreamer.new(@file.path) 19 | end 20 | 21 | def teardown 22 | @file.unlink 23 | end 24 | 25 | def test_to_path 26 | assert_equal @file.path, @streamer.to_path 27 | end 28 | 29 | def test_mtime 30 | assert_equal File.mtime(@file.path), @streamer.mtime 31 | end 32 | 33 | def test_each 34 | assert_equal @content, @streamer.to_enum.to_a.join 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /tests/io_streamer_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | require 'minitest/autorun' 4 | require 'minitest/unit' 5 | require 'tempfile' 6 | 7 | require 'grack/io_streamer' 8 | 9 | class IOStreamerTest < MiniTest::Test 10 | include Grack 11 | 12 | def setup 13 | @content = 'abcd' * 10_000 14 | @file = Tempfile.new('foo') 15 | @file.write(@content) 16 | @file.rewind 17 | @streamer = IOStreamer.new(@file, @file.mtime) 18 | end 19 | 20 | def teardown 21 | @file.close 22 | @file.unlink 23 | end 24 | 25 | def test_to_path 26 | assert ! @streamer.respond_to?(:to_path), 'responds to #to_path' 27 | end 28 | def test_mtime 29 | assert_equal File.mtime(@file.path), @streamer.mtime 30 | end 31 | 32 | def test_each 33 | assert_equal @content, @streamer.to_enum.to_a.join 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /tests/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | require 'simplecov' 3 | require 'tmpdir' 4 | require 'coveralls' 5 | 6 | Coveralls.wear! 7 | 8 | SimpleCov.start do 9 | add_filter 'tests/' 10 | end 11 | 12 | $: << File.expand_path('../../lib', __FILE__) 13 | 14 | def git_path 15 | ENV.fetch('GIT_PATH', 'git') # Path to git on test system 16 | end 17 | 18 | def stock_repo 19 | File.expand_path('../example/_git', __FILE__) 20 | end 21 | 22 | def repositories_root 23 | @repositories_root 24 | end 25 | 26 | def example_repo 27 | @example_repo 28 | end 29 | 30 | def init_example_repository 31 | @repositories_root = Pathname.new(Dir.mktmpdir('grack-testing')) 32 | @example_repo = @repositories_root + 'example_repo.git' 33 | 34 | FileUtils.cp_r(stock_repo, example_repo) 35 | end 36 | 37 | def remove_example_repository 38 | FileUtils.remove_entry_secure(repositories_root) 39 | end 40 | -------------------------------------------------------------------------------- /tests/example/_git/hooks/commit-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to check the commit log message. 4 | # Called by "git commit" with one argument, the name of the file 5 | # that has the commit message. The hook should exit with non-zero 6 | # status after issuing an appropriate message if it wants to stop the 7 | # commit. The hook is allowed to edit the commit message file. 8 | # 9 | # To enable this hook, rename this file to "commit-msg". 10 | 11 | # Uncomment the below to add a Signed-off-by line to the message. 12 | # Doing this in a hook is a bad idea in general, but the prepare-commit-msg 13 | # hook is more suited to it. 14 | # 15 | # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') 16 | # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" 17 | 18 | # This example catches duplicate Signed-off-by lines. 19 | 20 | test "" = "$(grep '^Signed-off-by: ' "$1" | 21 | sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { 22 | echo >&2 Duplicate Signed-off-by lines. 23 | exit 1 24 | } 25 | -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | # News and Notifications by Version 2 | 3 | This file lists noteworthy changes which may affect users of this project. More 4 | detailed information is available in the rest of the documentation. 5 | 6 | **NOTE:** Date stamps in the following entries are in YYYY/MM/DD format. 7 | 8 | ## v0.1.1 (2020/03/14) 9 | 10 | Minor update to dependencies and readme. 11 | 12 | ## v0.1.0 (2016/04/30) 13 | 14 | * Removed compatibility layer for version 0.0.x. Please use the configuration parameters specified in the README and update your adapters. 15 | * Fix tests for git > 2.7 16 | 17 | ## v0.1.0.pre2 18 | 19 | * Minor change required for new version [rjgit adapter](https://github.com/grackorg/rjgit_grack) to work 20 | 21 | ## v0.1.0.pre1 22 | 23 | * Moved projects to [grackorg/grack](https://github.com/grackorg/grack) 24 | * Test release for major rewrite. See https://github.com/grackorg/grack/pull/3. 25 | 26 | ## v0.0.2 (2012/10/10) 27 | 28 | * Fix the file list in the gem. 29 | 30 | ## v0.0.1 (2012/10/10) 31 | 32 | * Birthday 33 | -------------------------------------------------------------------------------- /lib/grack/file_streamer.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | 3 | require 'grack/io_streamer' 4 | 5 | module Grack 6 | ## 7 | # A Rack body implementation that streams a given file in chunks for a Rack 8 | # response. 9 | class FileStreamer < IOStreamer 10 | ## 11 | # Creates a new instance of this object. 12 | # 13 | # @param [Pathname, String] path a path to a file. 14 | def initialize(path) 15 | @path = Pathname.new(path).expand_path 16 | end 17 | 18 | ## 19 | # In order to support X-Sendfile when available, this method returns the 20 | # path to the file the web server would use to provide the content. 21 | # 22 | # @return [String] the path to the file. 23 | def to_path 24 | @path.to_s 25 | end 26 | 27 | ## 28 | # The last modified time to report for the Rack response. 29 | def mtime 30 | @path.mtime 31 | end 32 | 33 | private 34 | 35 | ## 36 | # @yieldparam [#read] io the opened file. 37 | def with_io(&b) 38 | @path.open(&b) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2015 Scott Chacon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/grack/io_streamer.rb: -------------------------------------------------------------------------------- 1 | module Grack 2 | ## 3 | # A Rack body implementation that streams a given IO object in chunks for a 4 | # Rack response. 5 | class IOStreamer 6 | ## 7 | # The number of bytes to read at a time from IO streams. 8 | READ_SIZE = 32768 9 | 10 | ## 11 | # Creates a new instance of this object. 12 | # 13 | # @param [#read] io a readable, IO-like object. 14 | # @param [Time] mtime a timestamp to use for the last modified header in the 15 | # response. 16 | def initialize(io, mtime) 17 | @io = io 18 | @mtime = mtime 19 | end 20 | 21 | ## 22 | # The last modified time to report for the Rack response. 23 | attr_reader :mtime 24 | 25 | ## 26 | # Iterates over the wrapped IO object in chunks, yielding each one. 27 | # 28 | # @yieldparam [String] chunk a chunk read from the wrapped IO object. 29 | def each 30 | with_io do |io| 31 | while chunk = io.read(READ_SIZE) do 32 | yield(chunk) 33 | end 34 | end 35 | end 36 | 37 | private 38 | 39 | ## 40 | # @yieldparam [#read] io the wrapped IO object. 41 | def with_io 42 | yield(@io) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /tests/example/_git/hooks/prepare-commit-msg.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to prepare the commit log message. 4 | # Called by "git commit" with the name of the file that has the 5 | # commit message, followed by the description of the commit 6 | # message's source. The hook's purpose is to edit the commit 7 | # message file. If the hook fails with a non-zero status, 8 | # the commit is aborted. 9 | # 10 | # To enable this hook, rename this file to "prepare-commit-msg". 11 | 12 | # This hook includes three examples. The first comments out the 13 | # "Conflicts:" part of a merge commit. 14 | # 15 | # The second includes the output of "git diff --name-status -r" 16 | # into the message, just before the "git status" output. It is 17 | # commented because it doesn't cope with --amend or with squashed 18 | # commits. 19 | # 20 | # The third example adds a Signed-off-by line to the message, that can 21 | # still be edited. This is rarely a good idea. 22 | 23 | case "$2,$3" in 24 | merge,) 25 | /usr/bin/perl -i.bak -ne 's/^/# /, s/^# #/#/ if /^Conflicts/ .. /#/; print' "$1" ;; 26 | 27 | # ,|template,) 28 | # /usr/bin/perl -i.bak -pe ' 29 | # print "\n" . `git diff --cached --name-status -r` 30 | # if /^#/ && $first++ == 0' "$1" ;; 31 | 32 | *) ;; 33 | esac 34 | 35 | # SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') 36 | # grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" 37 | -------------------------------------------------------------------------------- /tests/example/_git/hooks/pre-commit.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to verify what is about to be committed. 4 | # Called by "git commit" with no arguments. The hook should 5 | # exit with non-zero status after issuing an appropriate message if 6 | # it wants to stop the commit. 7 | # 8 | # To enable this hook, rename this file to "pre-commit". 9 | 10 | if git rev-parse --verify HEAD >/dev/null 2>&1 11 | then 12 | against=HEAD 13 | else 14 | # Initial commit: diff against an empty tree object 15 | against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 16 | fi 17 | 18 | # If you want to allow non-ascii filenames set this variable to true. 19 | allownonascii=$(git config hooks.allownonascii) 20 | 21 | # Redirect output to stderr. 22 | exec 1>&2 23 | 24 | # Cross platform projects tend to avoid non-ascii filenames; prevent 25 | # them from being added to the repository. We exploit the fact that the 26 | # printable range starts at the space character and ends with tilde. 27 | if [ "$allownonascii" != "true" ] && 28 | # Note that the use of brackets around a tr range is ok here, (it's 29 | # even required, for portability to Solaris 10's /usr/bin/tr), since 30 | # the square bracket bytes happen to fall in the designated range. 31 | test $(git diff --cached --name-only --diff-filter=A -z $against | 32 | LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 33 | then 34 | echo "Error: Attempt to add a non-ascii file name." 35 | echo 36 | echo "This can cause problems if you want to work" 37 | echo "with people on other platforms." 38 | echo 39 | echo "To be portable it is advisable to rename the file ..." 40 | echo 41 | echo "If you know what you are doing you can disable this" 42 | echo "check using:" 43 | echo 44 | echo " git config hooks.allownonascii true" 45 | echo 46 | exit 1 47 | fi 48 | 49 | # If there are whitespace errors, print the offending file names and fail. 50 | exec git diff-index --check --cached $against -- 51 | -------------------------------------------------------------------------------- /tests/git_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | require 'fileutils' 4 | require 'minitest/autorun' 5 | require 'minitest/unit' 6 | require 'mocha/setup' 7 | require 'stringio' 8 | 9 | require 'grack/git_adapter' 10 | 11 | 12 | class GitAdapterTest < Minitest::Test 13 | include Grack 14 | 15 | GIT_RECEIVE_RESPONSE = %r{\A001b# service=receive-pack\n0000[0-9a-f]{4}cb067e06bdf6e34d4abebf6cf2de85d65a52c65e refs/heads/master\000\s*report-status delete-refs side-band-64k quiet (atomic )?ofs-delta.*\n0000\z} 16 | 17 | def git_config_set(name, value) 18 | system(git_path, 'config', '--local', name, value, :chdir => example_repo) 19 | end 20 | 21 | def git_config_unset(name) 22 | system( 23 | git_path, 'config', '--local', '--unset-all', name, :chdir => example_repo 24 | ) 25 | end 26 | 27 | def setup 28 | init_example_repository 29 | @test_git = GitAdapter.new(git_path) 30 | @test_git.repository_path = example_repo 31 | end 32 | 33 | def teardown 34 | remove_example_repository 35 | end 36 | 37 | def test_break_with_bad_git_path 38 | test_git = GitAdapter.new('a/highly/unlikely/path/to/git') 39 | test_git.repository_path = example_repo 40 | assert_raises(Errno::ENOENT) do 41 | test_git.handle_pack('receive-pack', StringIO.new, StringIO.new) 42 | end 43 | end 44 | 45 | def test_receive_pack 46 | output = StringIO.new 47 | @test_git.handle_pack( 48 | 'receive-pack', StringIO.new, output, :advertise_refs => true 49 | ) 50 | 51 | assert_match GIT_RECEIVE_RESPONSE, output.string 52 | end 53 | 54 | def test_upload_pack 55 | input = StringIO.new('0000') 56 | output = StringIO.new 57 | @test_git.handle_pack('upload-pack', input, output) 58 | 59 | assert_equal '', output.string 60 | end 61 | 62 | def test_update_server_info 63 | refs_file = File.join(example_repo, 'info/refs') 64 | refs = File.read(refs_file) 65 | File.unlink(refs_file) 66 | assert ! File.exist?(refs_file), 'refs file exists' 67 | @test_git.update_server_info 68 | assert_equal refs, File.read(refs_file) 69 | end 70 | 71 | def test_exist 72 | assert @test_git.exist? 73 | @test_git.repository_path = 'a/highly/unlikely/path/to/a/repository' 74 | assert ! @test_git.exist? 75 | end 76 | 77 | def test_file 78 | assert_nil @test_git.file('a/highly/unlikely/path/to/a/file') 79 | 80 | object_path = 'objects/31/d73eb4914a8ddb6cb0e4adf250777161118f90' 81 | file_path = File.join(example_repo, object_path) 82 | git_file = @test_git.file(object_path) 83 | 84 | assert_equal file_path, git_file.to_path.to_s 85 | assert_equal File.mtime(file_path), git_file.mtime 86 | end 87 | 88 | def test_allow_push 89 | assert ! @test_git.allow_push?, 'Expected allow_push? to return false' 90 | git_config_set('http.receivepack', 'false') 91 | assert ! @test_git.allow_push?, 'Expected allow_push? to return false' 92 | git_config_set('http.receivepack', 'true') 93 | assert @test_git.allow_push?, 'Expected allow_push? to return true' 94 | end 95 | 96 | def test_allow_pull 97 | assert @test_git.allow_pull?, 'Expected allow_pull? to return true' 98 | git_config_set('http.uploadpack', 'false') 99 | assert ! @test_git.allow_pull?, 'Expected allow_pull? to return false' 100 | git_config_set('http.uploadpack', 'true') 101 | assert @test_git.allow_pull?, 'Expected allow_pull? to return true' 102 | end 103 | 104 | end 105 | -------------------------------------------------------------------------------- /grack.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'grack' 3 | s.version = '0.1.1' 4 | s.platform = Gem::Platform::RUBY 5 | s.authors = ['Scott Chacon', 'Dawa Ometto', 'Jeremy Bopp'] 6 | s.email = 7 | ['schacon@gmail.com', 'd.ometto@gmail.com', 'jeremy@bopp.net'] 8 | s.homepage = 'https://github.com/grackorg/grack' 9 | s.license = 'MIT' 10 | s.summary = <<-EOS 11 | This project aims to replace the builtin git-http-backend CGI handler 12 | distributed with C Git with a Rack application. 13 | EOS 14 | s.description = <<-EOD 15 | This project aims to replace the builtin git-http-backend CGI handler 16 | distributed with C Git with a Rack application. By default, Grack uses calls to 17 | git on the system to implement Smart HTTP. Since the git-http-backend is really 18 | just a simple wrapper for the upload-pack and receive-pack processes with the 19 | '--stateless-rpc' option, this does not actually re-implement very much. 20 | However, it is possible to use a different backend by specifying a different 21 | Adapter. 22 | EOD 23 | 24 | s.add_dependency('rack') 25 | 26 | s.add_development_dependency('rake', '>= 12.3.3', '~> 12.3.3') 27 | s.add_development_dependency('rack-test', '~> 0.6', '>= 0.6.3') 28 | s.add_development_dependency('minitest', '~> 5.8', '>= 5.8.0') 29 | s.add_development_dependency('mocha', '~> 1.1', '>= 1.1.0') 30 | s.add_development_dependency('simplecov', '~> 0.10', '>= 0.10.0') 31 | s.add_development_dependency('yard', '~> 0.9.24') 32 | s.add_development_dependency('redcarpet', '~> 3.1', '>= 3.1.0') 33 | s.add_development_dependency('github-markup', '~> 1.0', '>= 1.0.2') 34 | s.add_development_dependency('pry', '~> 0') 35 | 36 | # Explicitly list all non-test files that should be included into the gem 37 | # here. This and the test_files list will be compared against an 38 | # automatically generated list by rake to identify files potentially missed by 39 | # inclusion or exclusion rules. 40 | s.files = %w( 41 | .travis.yml 42 | .yardopts 43 | LICENSE 44 | NEWS.md 45 | README.md 46 | Rakefile 47 | lib/git_adapter.rb 48 | lib/grack.rb 49 | lib/grack/app.rb 50 | lib/grack/file_streamer.rb 51 | lib/grack/git_adapter.rb 52 | lib/grack/io_streamer.rb 53 | ) 54 | # Explicitly list all test files that should be included into the gem here. 55 | s.test_files = %w( 56 | tests/app_test.rb 57 | tests/example/_git/COMMIT_EDITMSG 58 | tests/example/_git/HEAD 59 | tests/example/_git/config 60 | tests/example/_git/description 61 | tests/example/_git/hooks/applypatch-msg.sample 62 | tests/example/_git/hooks/commit-msg.sample 63 | tests/example/_git/hooks/post-commit.sample 64 | tests/example/_git/hooks/post-receive.sample 65 | tests/example/_git/hooks/post-update.sample 66 | tests/example/_git/hooks/pre-applypatch.sample 67 | tests/example/_git/hooks/pre-commit.sample 68 | tests/example/_git/hooks/pre-rebase.sample 69 | tests/example/_git/hooks/prepare-commit-msg.sample 70 | tests/example/_git/hooks/update.sample 71 | tests/example/_git/index 72 | tests/example/_git/info/exclude 73 | tests/example/_git/info/refs 74 | tests/example/_git/logs/HEAD 75 | tests/example/_git/logs/refs/heads/master 76 | tests/example/_git/objects/31/d73eb4914a8ddb6cb0e4adf250777161118f90 77 | tests/example/_git/objects/cb/067e06bdf6e34d4abebf6cf2de85d65a52c65e 78 | tests/example/_git/objects/ce/013625030ba8dba906f756967f9e9ca394464a 79 | tests/example/_git/objects/info/packs 80 | tests/example/_git/objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.idx 81 | tests/example/_git/objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.pack 82 | tests/example/_git/refs/heads/master 83 | tests/file_streamer_test.rb 84 | tests/git_adapter_test.rb 85 | tests/io_streamer_test.rb 86 | tests/test_helper.rb 87 | ) 88 | end 89 | -------------------------------------------------------------------------------- /tests/example/_git/hooks/update.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # An example hook script to blocks unannotated tags from entering. 4 | # Called by "git receive-pack" with arguments: refname sha1-old sha1-new 5 | # 6 | # To enable this hook, rename this file to "update". 7 | # 8 | # Config 9 | # ------ 10 | # hooks.allowunannotated 11 | # This boolean sets whether unannotated tags will be allowed into the 12 | # repository. By default they won't be. 13 | # hooks.allowdeletetag 14 | # This boolean sets whether deleting tags will be allowed in the 15 | # repository. By default they won't be. 16 | # hooks.allowmodifytag 17 | # This boolean sets whether a tag may be modified after creation. By default 18 | # it won't be. 19 | # hooks.allowdeletebranch 20 | # This boolean sets whether deleting branches will be allowed in the 21 | # repository. By default they won't be. 22 | # hooks.denycreatebranch 23 | # This boolean sets whether remotely creating branches will be denied 24 | # in the repository. By default this is allowed. 25 | # 26 | 27 | # --- Command line 28 | refname="$1" 29 | oldrev="$2" 30 | newrev="$3" 31 | 32 | # --- Safety check 33 | if [ -z "$GIT_DIR" ]; then 34 | echo "Don't run this script from the command line." >&2 35 | echo " (if you want, you could supply GIT_DIR then run" >&2 36 | echo " $0 )" >&2 37 | exit 1 38 | fi 39 | 40 | if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then 41 | echo "Usage: $0 " >&2 42 | exit 1 43 | fi 44 | 45 | # --- Config 46 | allowunannotated=$(git config --bool hooks.allowunannotated) 47 | allowdeletebranch=$(git config --bool hooks.allowdeletebranch) 48 | denycreatebranch=$(git config --bool hooks.denycreatebranch) 49 | allowdeletetag=$(git config --bool hooks.allowdeletetag) 50 | allowmodifytag=$(git config --bool hooks.allowmodifytag) 51 | 52 | # check for no description 53 | projectdesc=$(sed -e '1q' "$GIT_DIR/description") 54 | case "$projectdesc" in 55 | "Unnamed repository"* | "") 56 | echo "*** Project description file hasn't been set" >&2 57 | exit 1 58 | ;; 59 | esac 60 | 61 | # --- Check types 62 | # if $newrev is 0000...0000, it's a commit to delete a ref. 63 | zero="0000000000000000000000000000000000000000" 64 | if [ "$newrev" = "$zero" ]; then 65 | newrev_type=delete 66 | else 67 | newrev_type=$(git cat-file -t $newrev) 68 | fi 69 | 70 | case "$refname","$newrev_type" in 71 | refs/tags/*,commit) 72 | # un-annotated tag 73 | short_refname=${refname##refs/tags/} 74 | if [ "$allowunannotated" != "true" ]; then 75 | echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 76 | echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 77 | exit 1 78 | fi 79 | ;; 80 | refs/tags/*,delete) 81 | # delete tag 82 | if [ "$allowdeletetag" != "true" ]; then 83 | echo "*** Deleting a tag is not allowed in this repository" >&2 84 | exit 1 85 | fi 86 | ;; 87 | refs/tags/*,tag) 88 | # annotated tag 89 | if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 90 | then 91 | echo "*** Tag '$refname' already exists." >&2 92 | echo "*** Modifying a tag is not allowed in this repository." >&2 93 | exit 1 94 | fi 95 | ;; 96 | refs/heads/*,commit) 97 | # branch 98 | if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then 99 | echo "*** Creating a branch is not allowed in this repository" >&2 100 | exit 1 101 | fi 102 | ;; 103 | refs/heads/*,delete) 104 | # delete branch 105 | if [ "$allowdeletebranch" != "true" ]; then 106 | echo "*** Deleting a branch is not allowed in this repository" >&2 107 | exit 1 108 | fi 109 | ;; 110 | refs/remotes/*,commit) 111 | # tracking branch 112 | ;; 113 | refs/remotes/*,delete) 114 | # delete tracking branch 115 | if [ "$allowdeletebranch" != "true" ]; then 116 | echo "*** Deleting a tracking branch is not allowed in this repository" >&2 117 | exit 1 118 | fi 119 | ;; 120 | *) 121 | # Anything else (is there anything else?) 122 | echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 123 | exit 1 124 | ;; 125 | esac 126 | 127 | # --- Finished 128 | exit 0 129 | -------------------------------------------------------------------------------- /lib/grack/git_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | 3 | require 'grack/file_streamer' 4 | 5 | module Grack 6 | ## 7 | # A wrapper for interacting with Git repositories using the git command line 8 | # tool. 9 | class GitAdapter 10 | ## 11 | # The number of bytes to read at a time from IO streams. 12 | READ_SIZE = 32768 13 | 14 | ## 15 | # Creates a new instance of this adapter. 16 | # 17 | # @param [String] bin_path the path to use for the Git binary. 18 | def initialize(bin_path = 'git') 19 | @repository_path = nil 20 | @git_path = bin_path 21 | end 22 | 23 | ## 24 | # The path to the repository on which to operate. 25 | attr_reader :repository_path 26 | 27 | ## 28 | # Sets the path to the repository on which to operate. 29 | def repository_path=(path) 30 | @repository_path = Pathname.new(path) 31 | end 32 | 33 | ## 34 | # @return [Boolean] +true+ if the repository exists; otherwise, +false+. 35 | def exist? 36 | repository_path.exist? 37 | end 38 | 39 | ## 40 | # Process the pack file exchange protocol. 41 | # 42 | # @param [String] pack_type the type of pack exchange to perform. 43 | # @param [#read] io_in a readable, IO-like object providing client input 44 | # data. 45 | # @param [#write] io_out a writable, IO-like object sending output data to 46 | # the client. 47 | # @param [Hash] opts options to pass to the Git adapter's #handle_pack 48 | # method. 49 | # @option opts [Boolean] :advertise_refs (false) 50 | def handle_pack(pack_type, io_in, io_out, opts = {}) 51 | args = %w{--stateless-rpc} 52 | if opts.fetch(:advertise_refs, false) 53 | io_out.write(advertisement_prefix(pack_type)) 54 | args << '--advertise-refs' 55 | end 56 | args << repository_path.to_s 57 | command(pack_type.sub(/^git-/, ''), args, io_in, io_out) 58 | end 59 | 60 | ## 61 | # Returns an object suitable for use as a Rack response body to provide the 62 | # content of a file at _path_. 63 | # 64 | # @param [Pathname] path the path to a file within the repository. 65 | # 66 | # @return [FileStreamer] a Rack response body that can stream the file 67 | # content at _path_. 68 | # @return [nil] if _path_ does not exist. 69 | def file(path) 70 | full_path = @repository_path + path 71 | return nil unless full_path.exist? 72 | FileStreamer.new(full_path) 73 | end 74 | 75 | ## 76 | # Triggers generation of data necessary to service Git Basic HTTP clients. 77 | # 78 | # @return [void] 79 | def update_server_info 80 | command('update-server-info', [], nil, nil, repository_path) 81 | end 82 | 83 | ## 84 | # @return [Boolean] +true+ if pushes should be allowed; otherwise; +false+. 85 | def allow_push? 86 | config('http.receivepack') == 'true' 87 | end 88 | 89 | ## 90 | # @return [Boolean] +true+ if pulls should be allowed; otherwise; +false+. 91 | def allow_pull? 92 | config('http.uploadpack') != 'false' 93 | end 94 | 95 | 96 | private 97 | 98 | ## 99 | # The path to use for running the git utility. 100 | attr_reader :git_path 101 | 102 | ## 103 | # The string to prepand before ref advertisements 104 | def advertisement_prefix(pack_type) 105 | str = "# service=#{pack_type}\n" 106 | '%04x' % (str.size + 4) << "#{str}0000" 107 | end 108 | 109 | ## 110 | # @param [String] key a key to look up in the Git repository configuration. 111 | # 112 | # @return [String] the value for the given key. 113 | def config(key) 114 | capture_io = StringIO.new 115 | command('config', ['--local', key], nil, capture_io, repository_path.to_s) 116 | capture_io.string.chomp 117 | end 118 | 119 | ## 120 | # Runs the Git utilty with the given subcommand. 121 | # 122 | # @param [String] cmd the Git subcommand to invoke. 123 | # @param [Array] args additional arguments for the command. 124 | # @param [#read, nil] io_in a readable, IO-like source of data to write to 125 | # the Git command. 126 | # @param [#write, nil] io_out a writable, IO-like sink for output produced 127 | # by the Git command. 128 | # @param [String, nil] dir a directory to switch to before invoking the Git 129 | # command. 130 | def command(cmd, args, io_in, io_out, dir = nil) 131 | cmd = [git_path, cmd] + args 132 | opts = {:err => :close} 133 | opts[:chdir] = dir unless dir.nil? 134 | cmd << opts 135 | IO.popen(cmd, 'r+b') do |pipe| 136 | while ! io_in.nil? && chunk = io_in.read(READ_SIZE) do 137 | pipe.write(chunk) 138 | end 139 | pipe.close_write 140 | while chunk = pipe.read(READ_SIZE) do 141 | io_out.write(chunk) unless io_out.nil? 142 | end 143 | end 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /tests/example/_git/hooks/pre-rebase.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Copyright (c) 2006, 2008 Junio C Hamano 4 | # 5 | # The "pre-rebase" hook is run just before "git rebase" starts doing 6 | # its job, and can prevent the command from running by exiting with 7 | # non-zero status. 8 | # 9 | # The hook is called with the following parameters: 10 | # 11 | # $1 -- the upstream the series was forked from. 12 | # $2 -- the branch being rebased (or empty when rebasing the current branch). 13 | # 14 | # This sample shows how to prevent topic branches that are already 15 | # merged to 'next' branch from getting rebased, because allowing it 16 | # would result in rebasing already published history. 17 | 18 | publish=next 19 | basebranch="$1" 20 | if test "$#" = 2 21 | then 22 | topic="refs/heads/$2" 23 | else 24 | topic=`git symbolic-ref HEAD` || 25 | exit 0 ;# we do not interrupt rebasing detached HEAD 26 | fi 27 | 28 | case "$topic" in 29 | refs/heads/??/*) 30 | ;; 31 | *) 32 | exit 0 ;# we do not interrupt others. 33 | ;; 34 | esac 35 | 36 | # Now we are dealing with a topic branch being rebased 37 | # on top of master. Is it OK to rebase it? 38 | 39 | # Does the topic really exist? 40 | git show-ref -q "$topic" || { 41 | echo >&2 "No such branch $topic" 42 | exit 1 43 | } 44 | 45 | # Is topic fully merged to master? 46 | not_in_master=`git rev-list --pretty=oneline ^master "$topic"` 47 | if test -z "$not_in_master" 48 | then 49 | echo >&2 "$topic is fully merged to master; better remove it." 50 | exit 1 ;# we could allow it, but there is no point. 51 | fi 52 | 53 | # Is topic ever merged to next? If so you should not be rebasing it. 54 | only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` 55 | only_next_2=`git rev-list ^master ${publish} | sort` 56 | if test "$only_next_1" = "$only_next_2" 57 | then 58 | not_in_topic=`git rev-list "^$topic" master` 59 | if test -z "$not_in_topic" 60 | then 61 | echo >&2 "$topic is already up-to-date with master" 62 | exit 1 ;# we could allow it, but there is no point. 63 | else 64 | exit 0 65 | fi 66 | else 67 | not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` 68 | /usr/bin/perl -e ' 69 | my $topic = $ARGV[0]; 70 | my $msg = "* $topic has commits already merged to public branch:\n"; 71 | my (%not_in_next) = map { 72 | /^([0-9a-f]+) /; 73 | ($1 => 1); 74 | } split(/\n/, $ARGV[1]); 75 | for my $elem (map { 76 | /^([0-9a-f]+) (.*)$/; 77 | [$1 => $2]; 78 | } split(/\n/, $ARGV[2])) { 79 | if (!exists $not_in_next{$elem->[0]}) { 80 | if ($msg) { 81 | print STDERR $msg; 82 | undef $msg; 83 | } 84 | print STDERR " $elem->[1]\n"; 85 | } 86 | } 87 | ' "$topic" "$not_in_next" "$not_in_master" 88 | exit 1 89 | fi 90 | 91 | exit 0 92 | 93 | ################################################################ 94 | 95 | This sample hook safeguards topic branches that have been 96 | published from being rewound. 97 | 98 | The workflow assumed here is: 99 | 100 | * Once a topic branch forks from "master", "master" is never 101 | merged into it again (either directly or indirectly). 102 | 103 | * Once a topic branch is fully cooked and merged into "master", 104 | it is deleted. If you need to build on top of it to correct 105 | earlier mistakes, a new topic branch is created by forking at 106 | the tip of the "master". This is not strictly necessary, but 107 | it makes it easier to keep your history simple. 108 | 109 | * Whenever you need to test or publish your changes to topic 110 | branches, merge them into "next" branch. 111 | 112 | The script, being an example, hardcodes the publish branch name 113 | to be "next", but it is trivial to make it configurable via 114 | $GIT_DIR/config mechanism. 115 | 116 | With this workflow, you would want to know: 117 | 118 | (1) ... if a topic branch has ever been merged to "next". Young 119 | topic branches can have stupid mistakes you would rather 120 | clean up before publishing, and things that have not been 121 | merged into other branches can be easily rebased without 122 | affecting other people. But once it is published, you would 123 | not want to rewind it. 124 | 125 | (2) ... if a topic branch has been fully merged to "master". 126 | Then you can delete it. More importantly, you should not 127 | build on top of it -- other people may already want to 128 | change things related to the topic as patches against your 129 | "master", so if you need further changes, it is better to 130 | fork the topic (perhaps with the same name) afresh from the 131 | tip of "master". 132 | 133 | Let's look at this example: 134 | 135 | o---o---o---o---o---o---o---o---o---o "next" 136 | / / / / 137 | / a---a---b A / / 138 | / / / / 139 | / / c---c---c---c B / 140 | / / / \ / 141 | / / / b---b C \ / 142 | / / / / \ / 143 | ---o---o---o---o---o---o---o---o---o---o---o "master" 144 | 145 | 146 | A, B and C are topic branches. 147 | 148 | * A has one fix since it was merged up to "next". 149 | 150 | * B has finished. It has been fully merged up to "master" and "next", 151 | and is ready to be deleted. 152 | 153 | * C has not merged to "next" at all. 154 | 155 | We would want to allow C to be rebased, refuse A, and encourage 156 | B to be deleted. 157 | 158 | To compute (1): 159 | 160 | git rev-list ^master ^topic next 161 | git rev-list ^master next 162 | 163 | if these match, topic has not merged in next at all. 164 | 165 | To compute (2): 166 | 167 | git rev-list master..topic 168 | 169 | if this is empty, it is fully merged to "master". 170 | -------------------------------------------------------------------------------- /README.md.erb: -------------------------------------------------------------------------------- 1 | <% 2 | # This is the template file for README.md. The gemspec details are available 3 | # within the _spec_ attribute, and all methods defined in the Rakefile are 4 | # also available. The Rakefile will use this file to regenerate README.md 5 | # when necessary. 6 | -%> 7 | [![Gem Version](https://badge.fury.io/rb/grack.svg)](http://badge.fury.io/rb/grack) 8 | [![Build Status](https://travis-ci.org/grackorg/grack.svg?branch=master)](https://travis-ci.org/grackorg/grack) 9 | [![Coverage Status](https://coveralls.io/repos/grackorg/grack/badge.svg?branch=master&service=github)](https://coveralls.io/github/grackorg/grack?branch=master) 10 | [![Dependency Status](https://gemnasium.com/grackorg/grack.svg)](https://gemnasium.com/grackorg/grack) 11 | 12 | # Grack - Ruby/Rack Git Smart HTTP Server Handler 13 | 14 | <%= word_wrap(spec.summary) %> 15 | 16 | ## Links 17 | 18 | * Homepage :: <%= spec.homepage %> 19 | * Source :: https://github.com/grackorg/grack.git 20 | 21 | ## Description 22 | 23 | <%= spec.description -%> 24 | 25 | The default git-http-backend only runs as a CGI script, and specifically is 26 | only targeted for Apache 2.x usage (it requires PATH_INFO to be set and 27 | specifically formatted). So, instead of trying to get it to work with other 28 | CGI capable webservers (Lighttpd, etc), we can get it running on nearly every 29 | major and minor webserver out there by making it Rack capable. Rack 30 | applications can run with the following handlers: 31 | 32 | * CGI 33 | * FCGI 34 | * Mongrel (and EventedMongrel and SwiftipliedMongrel) 35 | * WEBrick 36 | * SCGI 37 | * LiteSpeed 38 | * Thin 39 | 40 | These web servers include Rack handlers in their distributions: 41 | 42 | * Ebb 43 | * Fuzed 44 | * Phusion Passenger (which is mod_rack for Apache and for nginx) 45 | * Unicorn 46 | 47 | With [Warbler](http://caldersphere.rubyforge.org/warbler/classes/Warbler.html), 48 | and JRuby, we can also generate a WAR file that can be deployed in any Java web 49 | application server (Tomcat, Glassfish, Websphere, JBoss, etc). 50 | 51 | By default, Grack uses calls to git on the system to implement Smart HTTP. 52 | Since the git-http-backend is really just a simple wrapper for the upload-pack 53 | and receive-pack processes with the '--stateless-rpc' option, this does not 54 | actually re-implement very much. However, it is possible to use a different 55 | backend by specifying a different Adapter. See below for a list. 56 | 57 | Note that while it is technically possible to host non-bare repositories with 58 | this gem, it is discouraged. The only somewhat safe option is to serve such a 59 | repository as read-only since there is a greater risk of arbitrary filesystem 60 | traversal when a checkout tree must be traversed to reach the repository 61 | administrative area (`.git` directory). Additionally, any recent version of Git 62 | prevents pushes into non-bare repositories by default since pushing into the 63 | currently checked out branch can effectively "break" the checkout tree. 64 | 65 | ## Synopsis 66 | 67 | In `config.ru`: 68 | 69 | ```ruby 70 | require 'grack/app' 71 | require 'grack/git_adapter' 72 | 73 | config = { 74 | :root => '/path/to/bare/repositories', 75 | :allow_push => true, 76 | :allow_pull => true, 77 | :git_adapter_factory => ->{ Grack::GitAdapter.new } 78 | } 79 | 80 | run Grack::App.new(config) 81 | ``` 82 | 83 | Then run: 84 | 85 | ```sh 86 | $ bundle exec rackup --host 127.0.0.1 --port 8080 config.ru 87 | $ git clone http://localhost:8080/your-repository.git 88 | ``` 89 | 90 | ### Git Adapters 91 | 92 | Grack makes calls to the git binary through the GitAdapter abstraction class. 93 | Grack can be made to use a different backend by specifying a call-able object, 94 | such as a lambda, in Grack's configuration that provides new adapter instances 95 | per request. For example: 96 | 97 | ```ruby 98 | Grack::App.new(:git_adapter_factory => ->{ MyAdapter.new }) 99 | ``` 100 | 101 | Alternative adapters available: 102 | * [rjgit_grack](http://github.com/grackorg/rjgit_grack) lets Grack use the 103 | [RJGit](http://github.com/repotag/rjgit) gem to implement Smart HTTP in pure 104 | JRuby. 105 | 106 | ### Developing Adapters 107 | 108 | Adapters are abstraction classes that handle the actual implementation of the 109 | Smart HTTP protocol (advertising refs, uploading and receiving packfiles). Such 110 | abstraction classes must have the following methods: 111 | 112 | ```ruby 113 | MyAdapter.repository_path=(repository_path) 114 | MyAdapter.exist? 115 | MyAdapter.handle_pack(kind, io_in, io_out, opts = {}) 116 | MyAdapter.file(path) 117 | MyAdapter.update_server_info 118 | MyAdapter.allow_push? 119 | MyAdapter.allow_pull? 120 | ``` 121 | 122 | See `Grack::GitAdapter` for more detailed documentation and an example 123 | implementation. 124 | 125 | ## Features 126 | 127 | * Supports Git Smart HTTP protocol. 128 | * Supports Git Basic HTTP protocol. 129 | * Limits push/pull access globally and per-repository. 130 | * Thread safe operation. 131 | 132 | ### Hooks 133 | 134 | By default, grack doesn't support git hooks. This is because the default GitAdapter directly streams the requests to the `git-receive-pack` and `git-upload-pack` processes. However, alternative adapters may implement hooks. 135 | 136 | Adapters that support hooks: 137 | 138 | * [rjgit_grack](http://github.com/grackorg/rjgit_grack) 139 | 140 | ## Known Bugs/Limitations 141 | 142 | * Will likely block fully evented web servers when using the stock Git adapter. 143 | 144 | ## Runtime Requirements 145 | 146 | * Ruby >=1.9.3 147 | * Git >=1.7 (if using the included Git adapter) 148 | <% spec.runtime_dependencies.each do |dependency| -%> 149 | * <%= dependency %> 150 | <% end -%> 151 | 152 | ## Development Requirements 153 | 154 | * All runtime requirements 155 | <% spec.development_dependencies.each do |dependency| -%> 156 | * <%= dependency.name %> (<%= dependency.requirement %>) 157 | <% end -%> 158 | 159 | ## Contributing 160 | 161 | Contributions for bug fixes, documentation, extensions, tests, etc. are 162 | encouraged. 163 | 164 | 1. Clone the repository. 165 | 2. Fix a bug or add a feature. 166 | 3. Add tests for the fix or feature. 167 | 4. Make a pull request. 168 | 169 | ## Development 170 | 171 | After checking out the source, run: 172 | 173 | ```sh 174 | $ bundle install 175 | $ bundle exec rake test yard 176 | ``` 177 | 178 | This will install all dependencies, run the tests/specs, and generate the 179 | documentation. 180 | 181 | ## Authors 182 | 183 | Thanks to all contributors. Without your help this project would not exist. 184 | 185 | <% spec.authors.zip(spec.email).each do |author, email| -%> 186 | * <%= author %> :: <%= email %> 187 | <% end -%> 188 | 189 | ## License 190 | 191 | ``` 192 | <%= File.read('LICENSE') -%> 193 | ``` 194 | <% # vim: set ts=2 sw=2 et: -%> 195 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # -*- ruby -*- 3 | 4 | require 'erb' 5 | require 'rake/testtask' 6 | require 'rubygems/package_task' 7 | require 'rake/clean' 8 | require 'yard' 9 | 10 | # Load the gemspec file for this project. 11 | GEMSPEC = Dir['*.gemspec'].first 12 | SPEC = eval(File.read(GEMSPEC), nil, GEMSPEC) 13 | 14 | # A dynamically generated list of files that should match the manifest (the 15 | # combined contents of SPEC.files and SPEC.test_files). The idea is for this 16 | # list to contain all project files except for those that have been explicitly 17 | # excluded. This list will be compared with the manifest from the SPEC in order 18 | # to help catch the addition or removal of files to or from the project that 19 | # have not been accounted for either by an exclusion here or an inclusion in the 20 | # SPEC manifest. 21 | # 22 | # NOTE: 23 | # It is critical that the manifest is *not* automatically generated via globbing 24 | # and the like; otherwise, this will yield a simple comparison between 25 | # redundantly generated lists of files that probably will not protect the 26 | # project from the unintentional inclusion or exclusion of files in the 27 | # distribution. 28 | PKG_FILES = FileList.new(Dir.glob('**/*', File::FNM_DOTMATCH)) do |files| 29 | # Exclude anything that doesn't exist as well as directories. 30 | files.exclude {|file| ! File.exist?(file) || File.directory?(file)} 31 | # Exclude Git administrative files. 32 | files.exclude('.git/**/*', '**/.gitignore', '**/.gitmodule', '**/.gitkeep') 33 | # Exclude editor swap/temporary files. 34 | files.exclude('**/.*.sw?', '**/.sw?') 35 | # Exclude the gemspec file. 36 | files.exclude(GEMSPEC) 37 | # Exclude the README template file. 38 | files.exclude('README.md.erb') 39 | # Exclude resources for bundler. 40 | files.exclude('Gemfile', 'Gemfile.lock') 41 | files.exclude(%r{^.bundle([/\\]|$)}) 42 | files.exclude(%r{^vendor/bundle([/\\]|$)}) 43 | # Exclude generated content, except for the README file. 44 | files.exclude(%r{^(pkg|doc|coverage|.yardoc)([/\\]|$)}) 45 | # Exclude examples. 46 | files.exclude('examples/**/*') 47 | # Exclude Rubinius compiled Ruby files. 48 | files.exclude('**/*.rbc') 49 | end 50 | 51 | # Make sure that :clean and :clobber will not whack the repository files. 52 | CLEAN.exclude('.git/**') 53 | # Vim swap files are fair game for clean up. 54 | CLEAN.include('**/.*.sw?') 55 | 56 | # Returns the value of the VERSION environment variable as a Gem::Version object 57 | # assuming it is set and a valid Gem version string. Otherwise, raises an 58 | # exception. 59 | def get_version_argument 60 | version = ENV['VERSION'] 61 | if version.to_s.empty? 62 | raise "No version specified: Add VERSION=X.Y.Z to the command line" 63 | end 64 | begin 65 | Gem::Version.create(version.dup) 66 | rescue ArgumentError 67 | raise "Invalid version specified in `VERSION=#{version}'" 68 | end 69 | end 70 | 71 | # Performs an in place, per line edit of the file indicated by _path_ by calling 72 | # the sub method on each line and passing _pattern_, _replacement_, and _b_ as 73 | # arguments. 74 | def file_sub(path, pattern, replacement = nil, &b) 75 | tmp_path = "#{path}.tmp" 76 | File.open(path) do |infile| 77 | File.open(tmp_path, 'w') do |outfile| 78 | infile.each do |line| 79 | outfile.write(line.sub(pattern, replacement, &b)) 80 | end 81 | end 82 | end 83 | File.rename(tmp_path, path) 84 | end 85 | 86 | # Updates the version string in the gemspec file to the string in _version_. 87 | def set_version(version) 88 | file_sub(GEMSPEC, /(\.version\s*=\s*).*/, "\\1'#{version}'") 89 | end 90 | 91 | # Returns a string that is line wrapped at word boundaries, where each line is 92 | # no longer than _line_width_ characters. 93 | # 94 | # This is mostly lifted directly from ActionView::Helpers::TextHelper. 95 | def word_wrap(text, line_width = 80) 96 | text.split("\n").collect do |line| 97 | line.length > line_width ? 98 | line.gsub(/(.{1,#{line_width}})(\s+|$)/, "\\1\n").strip : 99 | line 100 | end * "\n" 101 | end 102 | 103 | desc 'Alias for build:gem' 104 | task :build => 'build:gem' 105 | 106 | # Build related tasks. 107 | namespace :build do 108 | # Create the gem and package tasks. 109 | Gem::PackageTask.new(SPEC).define 110 | 111 | # Ensure that the manifest is consulted when building the gem. Any 112 | # generated/compiled files should be available at that time. 113 | task :gem => :check_manifest 114 | 115 | desc 'Verify the manifest' 116 | task :check_manifest do 117 | manifest_files = (SPEC.files + SPEC.test_files).sort.uniq 118 | pkg_files = PKG_FILES.sort.uniq 119 | if manifest_files != pkg_files then 120 | common_files = manifest_files & pkg_files 121 | manifest_files -= common_files 122 | pkg_files -= common_files 123 | message = ["The manifest does not match the automatic file list."] 124 | unless manifest_files.empty? then 125 | message << " Extraneous files:\n " + manifest_files.join("\n ") 126 | end 127 | unless pkg_files.empty? 128 | message << " Missing files:\n " + pkg_files.join("\n ") 129 | end 130 | raise message.join("\n") 131 | end 132 | end 133 | 134 | # Creates the README.md file from its template and other sources. 135 | file 'README.md' => ['README.md.erb', 'LICENSE', GEMSPEC] do 136 | spec = SPEC 137 | File.open('README.md', 'w') do |readme| 138 | readme.write( 139 | ERB.new(File.read('README.md.erb'), nil, '-').result(binding) 140 | ) 141 | end 142 | end 143 | end 144 | 145 | # Ensure that the clobber task also clobbers package files. 146 | task :clobber => 'build:clobber_package' 147 | 148 | # Create the documentation task. 149 | YARD::Rake::YardocTask.new 150 | # Ensure that the README file is (re)generated first. 151 | task :yard => 'README.md' 152 | 153 | # Gem related tasks. 154 | namespace :gem do 155 | desc 'Alias for build:gem' 156 | task :build => 'build:gem' 157 | 158 | desc 'Publish the gemfile' 159 | task :publish => ['version:check', :test, 'repo:tag', :build] do 160 | sh "gem push pkg/#{SPEC.name}-#{SPEC.version}*.gem" 161 | end 162 | end 163 | 164 | Rake::TestTask.new do |t| 165 | t.pattern = 'tests/**/*_test.rb' 166 | end 167 | 168 | # Version string management tasks. 169 | namespace :version do 170 | desc 'Set the version for the project to a specified version' 171 | task :set do 172 | set_version(get_version_argument) 173 | end 174 | 175 | desc 'Set the version for the project back to 0.0.0' 176 | task :reset do 177 | set_version('0.0.0') 178 | end 179 | 180 | desc 'Check that all version strings are correctly set' 181 | task :check => ['version:check:spec', 'version:check:news'] 182 | 183 | namespace :check do 184 | desc 'Check that the version in the gemspec is correctly set' 185 | task :spec do 186 | version = get_version_argument 187 | if version != SPEC.version 188 | raise "The given version `#{version}' does not match the gemspec version `#{SPEC.version}'" 189 | end 190 | end 191 | 192 | desc 'Check that the NEWS.md file mentions the version' 193 | task :news do 194 | version = get_version_argument 195 | begin 196 | File.open('NEWS.md') do |news| 197 | unless news.each_line.any? {|l| l =~ /^## v#{Regexp.escape(version.to_s)} /} 198 | raise "The NEWS.md file does not mention version `#{version}'" 199 | end 200 | end 201 | rescue Errno::ENOENT 202 | raise 'No NEWS.md file found' 203 | end 204 | end 205 | end 206 | end 207 | 208 | # Repository and workspace management tasks. 209 | namespace :repo do 210 | desc 'Tag the current HEAD with the version string' 211 | task :tag => :check_workspace do 212 | version = get_version_argument 213 | sh "git tag -m 'Release v#{version}' v#{version}" 214 | end 215 | 216 | desc 'Ensure the workspace is fully committed and clean' 217 | task :check_workspace => ['README.md'] do 218 | unless `git status --untracked-files=all --porcelain`.empty? 219 | raise 'Workspace has been modified. Commit pending changes and try again.' 220 | end 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/grack.svg)](http://badge.fury.io/rb/grack) 2 | [![Build Status](https://travis-ci.org/grackorg/grack.svg?branch=master)](https://travis-ci.org/grackorg/grack) 3 | [![Coverage Status](https://coveralls.io/repos/grackorg/grack/badge.svg?branch=master&service=github)](https://coveralls.io/github/grackorg/grack?branch=master) 4 | [![Cutting Edge Dependency Status](https://dometto-cuttingedge.herokuapp.com/github/grackorg/grack/svg 'Cutting Edge Dependency Status')](https://dometto-cuttingedge.herokuapp.com/github/grackorg/grack/info) 5 | 6 | # Grack - Ruby/Rack Git Smart HTTP Server Handler 7 | 8 | This project aims to replace the builtin git-http-backend CGI handler 9 | distributed with C Git with a Rack application. 10 | 11 | ## Links 12 | 13 | * Homepage :: https://github.com/grackorg/grack 14 | * Source :: https://github.com/grackorg/grack.git 15 | 16 | ## Description 17 | 18 | This project aims to replace the builtin git-http-backend CGI handler 19 | distributed with C Git with a Rack application. By default, Grack uses calls to 20 | git on the system to implement Smart HTTP. Since the git-http-backend is really 21 | just a simple wrapper for the upload-pack and receive-pack processes with the 22 | '--stateless-rpc' option, this does not actually re-implement very much. 23 | However, it is possible to use a different backend by specifying a different 24 | Adapter. 25 | 26 | The default git-http-backend only runs as a CGI script, and specifically is 27 | only targeted for Apache 2.x usage (it requires PATH_INFO to be set and 28 | specifically formatted). So, instead of trying to get it to work with other 29 | CGI capable webservers (Lighttpd, etc), we can get it running on nearly every 30 | major and minor webserver out there by making it Rack capable. Rack 31 | applications can run with the following handlers: 32 | 33 | * CGI 34 | * FCGI 35 | * Mongrel (and EventedMongrel and SwiftipliedMongrel) 36 | * WEBrick 37 | * SCGI 38 | * LiteSpeed 39 | * Thin 40 | 41 | These web servers include Rack handlers in their distributions: 42 | 43 | * Ebb 44 | * Fuzed 45 | * Phusion Passenger (which is mod_rack for Apache and for nginx) 46 | * Unicorn 47 | 48 | With [Warbler](http://caldersphere.rubyforge.org/warbler/classes/Warbler.html), 49 | and JRuby, we can also generate a WAR file that can be deployed in any Java web 50 | application server (Tomcat, Glassfish, Websphere, JBoss, etc). 51 | 52 | By default, Grack uses calls to git on the system to implement Smart HTTP. 53 | Since the git-http-backend is really just a simple wrapper for the upload-pack 54 | and receive-pack processes with the '--stateless-rpc' option, this does not 55 | actually re-implement very much. However, it is possible to use a different 56 | backend by specifying a different Adapter. See below for a list. 57 | 58 | Note that while it is technically possible to host non-bare repositories with 59 | this gem, it is discouraged. The only somewhat safe option is to serve such a 60 | repository as read-only since there is a greater risk of arbitrary filesystem 61 | traversal when a checkout tree must be traversed to reach the repository 62 | administrative area (`.git` directory). Additionally, any recent version of Git 63 | prevents pushes into non-bare repositories by default since pushing into the 64 | currently checked out branch can effectively "break" the checkout tree. 65 | 66 | ## Synopsis 67 | 68 | In `config.ru`: 69 | 70 | ```ruby 71 | require 'grack/app' 72 | require 'grack/git_adapter' 73 | 74 | config = { 75 | :root => '/path/to/bare/repositories', 76 | :allow_push => true, 77 | :allow_pull => true, 78 | :git_adapter_factory => ->{ Grack::GitAdapter.new } 79 | } 80 | 81 | run Grack::App.new(config) 82 | ``` 83 | 84 | Then run: 85 | 86 | ```sh 87 | $ bundle exec rackup --host 127.0.0.1 --port 8080 config.ru 88 | $ git clone http://localhost:8080/your-repository.git 89 | ``` 90 | 91 | ### Git Adapters 92 | 93 | Grack makes calls to the git binary through the GitAdapter abstraction class. 94 | Grack can be made to use a different backend by specifying a call-able object, 95 | such as a lambda, in Grack's configuration that provides new adapter instances 96 | per request. For example: 97 | 98 | ```ruby 99 | Grack::App.new(:git_adapter_factory => ->{ MyAdapter.new }) 100 | ``` 101 | 102 | Alternative adapters available: 103 | * [rjgit_grack](http://github.com/grackorg/rjgit_grack) lets Grack use the 104 | [RJGit](http://github.com/repotag/rjgit) gem to implement Smart HTTP in pure 105 | JRuby. 106 | 107 | ### Developing Adapters 108 | 109 | Adapters are abstraction classes that handle the actual implementation of the 110 | Smart HTTP protocol (advertising refs, uploading and receiving packfiles). Such 111 | abstraction classes must have the following methods: 112 | 113 | ```ruby 114 | MyAdapter.repository_path=(repository_path) 115 | MyAdapter.exist? 116 | MyAdapter.handle_pack(kind, io_in, io_out, opts = {}) 117 | MyAdapter.file(path) 118 | MyAdapter.update_server_info 119 | MyAdapter.allow_push? 120 | MyAdapter.allow_pull? 121 | ``` 122 | 123 | See `Grack::GitAdapter` for more detailed documentation and an example 124 | implementation. 125 | 126 | ## Features 127 | 128 | * Supports Git Smart HTTP protocol. 129 | * Supports Git Basic HTTP protocol. 130 | * Limits push/pull access globally and per-repository. 131 | * Thread safe operation. 132 | 133 | ### Hooks 134 | 135 | By default, grack doesn't support git hooks. This is because the default GitAdapter directly streams the requests to the `git-receive-pack` and `git-upload-pack` processes. However, alternative adapters may implement hooks. 136 | 137 | Adapters that support hooks: 138 | 139 | * [rjgit_grack](http://github.com/grackorg/rjgit_grack) 140 | 141 | ## Known Bugs/Limitations 142 | 143 | * Will likely block fully evented web servers when using the stock Git adapter. 144 | 145 | ## Runtime Requirements 146 | 147 | * Ruby >=1.9.3 148 | * Git >=1.7 (if using the included Git adapter) 149 | * rack (>= 0) 150 | 151 | ## Development Requirements 152 | 153 | * All runtime requirements 154 | * rake (>= 12.3.3, ~> 12.3.3) 155 | * rack-test (~> 0.6, >= 0.6.3) 156 | * minitest (>= 5.8.0, ~> 5.8) 157 | * mocha (>= 1.1.0, ~> 1.1) 158 | * simplecov (>= 0.10.0, ~> 0.10) 159 | * yard (~> 0.9.24) 160 | * redcarpet (>= 3.1.0, ~> 3.1) 161 | * github-markup (~> 1.0, >= 1.0.2) 162 | * pry (~> 0) 163 | 164 | ## Contributing 165 | 166 | Contributions for bug fixes, documentation, extensions, tests, etc. are 167 | encouraged. 168 | 169 | 1. Clone the repository. 170 | 2. Fix a bug or add a feature. 171 | 3. Add tests for the fix or feature. 172 | 4. Make a pull request. 173 | 174 | ## Development 175 | 176 | After checking out the source, run: 177 | 178 | ```sh 179 | $ bundle install 180 | $ bundle exec rake test yard 181 | ``` 182 | 183 | This will install all dependencies, run the tests/specs, and generate the 184 | documentation. 185 | 186 | ## Authors 187 | 188 | Thanks to all contributors. Without your help this project would not exist. 189 | 190 | * Scott Chacon :: schacon@gmail.com 191 | * Dawa Ometto :: d.ometto@gmail.com 192 | * Jeremy Bopp :: jeremy@bopp.net 193 | 194 | ## License 195 | 196 | ``` 197 | (The MIT License) 198 | 199 | Copyright (c) 2015 Scott Chacon 200 | 201 | Permission is hereby granted, free of charge, to any person obtaining 202 | a copy of this software and associated documentation files (the 203 | 'Software'), to deal in the Software without restriction, including 204 | without limitation the rights to use, copy, modify, merge, publish, 205 | distribute, sublicense, and/or sell copies of the Software, and to 206 | permit persons to whom the Software is furnished to do so, subject to 207 | the following conditions: 208 | 209 | The above copyright notice and this permission notice shall be 210 | included in all copies or substantial portions of the Software. 211 | 212 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 213 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 214 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 215 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 216 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 217 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 218 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 219 | ``` 220 | -------------------------------------------------------------------------------- /tests/app_test.rb: -------------------------------------------------------------------------------- 1 | require_relative 'test_helper' 2 | 3 | require 'digest/sha1' 4 | require 'minitest/autorun' 5 | require 'minitest/unit' 6 | require 'mocha/setup' 7 | require 'pathname' 8 | require 'rack/test' 9 | require 'tempfile' 10 | require 'zlib' 11 | 12 | require 'grack/app' 13 | require 'grack/git_adapter' 14 | 15 | class AppTest < Minitest::Test 16 | include Rack::Test::Methods 17 | include Grack 18 | 19 | def example_repo_urn 20 | '/example_repo.git' 21 | end 22 | 23 | def app_config 24 | { 25 | :root => repositories_root, 26 | :allow_pull => true, 27 | :allow_push => true, 28 | :git_adapter_factory => ->{ GitAdapter.new(git_path) } 29 | } 30 | end 31 | 32 | def app 33 | App.new(app_config) 34 | end 35 | 36 | def setup 37 | init_example_repository 38 | end 39 | 40 | def teardown 41 | remove_example_repository 42 | end 43 | 44 | def test_upload_pack_advertisement 45 | get "#{example_repo_urn}/info/refs?service=git-upload-pack" 46 | assert_equal 200, r.status 47 | assert_equal 'application/x-git-upload-pack-advertisement', r.headers['Content-Type'] 48 | assert_equal '001e# service=git-upload-pack', r.body.split("\n").first 49 | assert_match 'multi_ack_detailed', r.body 50 | end 51 | 52 | def test_no_access_upload_pack_advertisement 53 | session = Rack::Test::Session.new( 54 | App.new(app_config.merge!(:allow_pull => false)) 55 | ) 56 | 57 | session.get "#{example_repo_urn}/info/refs?service=git-upload-pack" 58 | assert_equal 403, session.last_response.status 59 | end 60 | 61 | def test_no_access_wrong_content_type_up 62 | post "#{example_repo_urn}/git-upload-pack" 63 | assert_equal 403, r.status 64 | end 65 | 66 | def test_no_access_wrong_content_type_rp 67 | post "#{example_repo_urn}/git-receive-pack" 68 | assert_equal 403, r.status 69 | end 70 | 71 | def test_no_access_wrong_method_rcp 72 | get "#{example_repo_urn}/git-upload-pack" 73 | assert_equal 400, r.status 74 | get "#{example_repo_urn}/git-upload-pack", {}, {'SERVER_PROTOCOL' => 'HTTP/1.1'} 75 | assert_equal 405, r.status 76 | end 77 | 78 | def test_no_access_wrong_command_rcp 79 | post "#{example_repo_urn}/git-upload-packfile" 80 | assert_equal 404, r.status 81 | end 82 | 83 | def test_no_access_wrong_path_rcp 84 | post "/example-wrong/git-upload-pack" 85 | assert_equal 404, r.status 86 | end 87 | 88 | def test_upload_pack_rpc 89 | IO.stubs(:popen).returns(MockProcess.new) 90 | 91 | post( 92 | "#{example_repo_urn}/git-upload-pack", 93 | {}, 94 | {'CONTENT_TYPE' => 'application/x-git-upload-pack-request'} 95 | ) 96 | assert_equal 200, r.status 97 | assert_equal 'application/x-git-upload-pack-result', r.headers['Content-Type'] 98 | end 99 | 100 | def test_no_access_upload_pack_rpc 101 | session = Rack::Test::Session.new( 102 | App.new(app_config.merge!(:allow_pull => false)) 103 | ) 104 | 105 | session.post( 106 | "#{example_repo_urn}/git-upload-pack", 107 | {}, 108 | {'CONTENT_TYPE' => 'application/x-git-upload-pack-request'} 109 | ) 110 | assert_equal 403, session.last_response.status 111 | end 112 | 113 | def test_upload_pack_rpc_compressed 114 | IO.stubs(:popen).returns(MockProcess.new) 115 | 116 | content = StringIO.new 117 | gz = Zlib::GzipWriter.new(content) 118 | gz.write('foo') 119 | gz.close 120 | 121 | post( 122 | "#{example_repo_urn}/git-upload-pack", 123 | content.string, 124 | { 125 | 'CONTENT_TYPE' => 'application/x-git-upload-pack-request', 126 | 'HTTP_CONTENT_ENCODING' => 'gzip', 127 | } 128 | ) 129 | assert_equal 200, r.status 130 | assert_equal 'application/x-git-upload-pack-result', 131 | r.headers['Content-Type'] 132 | end 133 | 134 | def test_receive_pack_advertisement 135 | get "#{example_repo_urn}/info/refs?service=git-receive-pack" 136 | assert_equal 200, r.status 137 | assert_equal 'application/x-git-receive-pack-advertisement', 138 | r.headers['Content-Type'] 139 | assert_equal '001f# service=git-receive-pack', r.body.split("\n").first 140 | assert_match 'report-status', r.body 141 | assert_match 'delete-refs', r.body 142 | assert_match 'ofs-delta', r.body 143 | end 144 | 145 | def test_no_access_receive_pack_advertisement 146 | session = Rack::Test::Session.new( 147 | App.new(app_config.merge!(:allow_push => false)) 148 | ) 149 | 150 | session.get "#{example_repo_urn}/info/refs?service=git-receive-pack" 151 | assert_equal 403, session.last_response.status 152 | end 153 | 154 | def test_receive_pack_rpc 155 | IO.stubs(:popen).returns(MockProcess.new) 156 | 157 | post( 158 | "#{example_repo_urn}/git-receive-pack", 159 | {}, 160 | {'CONTENT_TYPE' => 'application/x-git-receive-pack-request'} 161 | ) 162 | assert_equal 200, r.status 163 | assert_equal 'application/x-git-receive-pack-result', 164 | r.headers['Content-Type'] 165 | end 166 | 167 | def test_no_access_receive_pack_rpc 168 | session = Rack::Test::Session.new( 169 | App.new(app_config.merge!(:allow_push => false)) 170 | ) 171 | 172 | session.post "#{example_repo_urn}/git-receive-pack", 173 | {}, 174 | {'CONTENT_TYPE' => 'application/x-git-receive-pack-request'} 175 | assert_equal 403, session.last_response.status 176 | end 177 | 178 | def test_info_refs_dumb 179 | get "#{example_repo_urn}/info/refs" 180 | assert_equal 200, r.status 181 | end 182 | 183 | def test_no_access_info_refs_dumb 184 | session = Rack::Test::Session.new( 185 | App.new(app_config.merge!(:allow_pull => false)) 186 | ) 187 | 188 | session.get "#{example_repo_urn}/info/refs" 189 | assert_equal 403, session.last_response.status 190 | end 191 | 192 | def test_info_packs 193 | get "#{example_repo_urn}/objects/info/packs" 194 | assert_equal 200, r.status 195 | assert_match /P pack-(.*?).pack/, r.body 196 | end 197 | 198 | def test_no_access_info_packs 199 | session = Rack::Test::Session.new( 200 | App.new(app_config.merge!(:allow_pull => false)) 201 | ) 202 | 203 | session.get "#{example_repo_urn}/objects/info/packs" 204 | assert_equal 403, session.last_response.status 205 | end 206 | 207 | def test_loose_objects 208 | obj_path = 'objects/31/d73eb4914a8ddb6cb0e4adf250777161118f90' 209 | obj_file = File.join(example_repo, obj_path) 210 | content = File.open(obj_file, 'rb') { |f| f.read } 211 | 212 | get "#{example_repo_urn}/#{obj_path}" 213 | assert_equal 200, r.status 214 | assert_equal content, r.body 215 | end 216 | 217 | def test_no_access_loose_objects 218 | obj_path = 'objects/31/d73eb4914a8ddb6cb0e4adf250777161118f90' 219 | session = Rack::Test::Session.new( 220 | App.new(app_config.merge!(:allow_pull => false)) 221 | ) 222 | 223 | session.get "#{example_repo_urn}/#{obj_path}" 224 | assert_equal 403, session.last_response.status 225 | end 226 | 227 | def test_pack_file 228 | pack_path = 229 | 'objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.pack' 230 | pack_file = File.join(example_repo, pack_path) 231 | content = File.open(pack_file, 'rb') { |f| f.read } 232 | 233 | get "#{example_repo_urn}/#{pack_path}" 234 | assert_equal 200, r.status 235 | assert_equal content, r.body 236 | end 237 | 238 | def test_no_access_pack_file 239 | pack_path = 240 | 'objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.pack' 241 | session = Rack::Test::Session.new( 242 | App.new(app_config.merge!(:allow_pull => false)) 243 | ) 244 | 245 | session.get "#{example_repo_urn}/#{pack_path}" 246 | assert_equal 403, session.last_response.status 247 | end 248 | 249 | def test_index_file 250 | idx_path = 'objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.idx' 251 | idx_file = File.join(example_repo, idx_path) 252 | content = File.open(idx_file, 'rb') { |f| f.read } 253 | 254 | get "#{example_repo_urn}/#{idx_path}" 255 | assert_equal 200, r.status 256 | assert_equal content, r.body 257 | end 258 | 259 | def test_no_access_index_file 260 | idx_path = 'objects/pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.idx' 261 | session = Rack::Test::Session.new( 262 | App.new(app_config.merge!(:allow_pull => false)) 263 | ) 264 | 265 | session.get "#{example_repo_urn}/#{idx_path}" 266 | assert_equal 403, session.last_response.status 267 | end 268 | 269 | def test_text_file 270 | head_file = File.join(example_repo, 'HEAD') 271 | content = File.open(head_file, 'rb') { |f| f.read } 272 | 273 | get "#{example_repo_urn}/HEAD" 274 | assert_equal 200, r.status 275 | assert_equal content, r.body 276 | end 277 | 278 | def test_no_access_text_file 279 | session = Rack::Test::Session.new( 280 | App.new(app_config.merge!(:allow_pull => false)) 281 | ) 282 | 283 | session.get "#{example_repo_urn}/HEAD" 284 | assert_equal 403, session.last_response.status 285 | end 286 | 287 | def test_config_allow_pull_off 288 | session = Rack::Test::Session.new( 289 | App.new(app_config.merge(:allow_pull => false)) 290 | ) 291 | session.get "#{example_repo_urn}/info/refs?service=git-upload-pack" 292 | assert_equal 403, session.last_response.status 293 | end 294 | 295 | def test_config_allow_push_off 296 | session = Rack::Test::Session.new( 297 | App.new(app_config.merge(:allow_push => false)) 298 | ) 299 | session.get "#{example_repo_urn}/info/refs?service=git-receive-pack" 300 | assert_equal 403, session.last_response.status 301 | end 302 | 303 | def test_config_bad_service 304 | get "#{example_repo_urn}/info/refs?service=git-receive-packfile" 305 | assert_equal 404, r.status 306 | end 307 | 308 | def test_git_adapter_forbid_push 309 | GitAdapter.any_instance.stubs(:allow_push?).returns(false) 310 | 311 | app = App.new({ 312 | :root => repositories_root 313 | }) 314 | session = Rack::Test::Session.new(app) 315 | session.get "#{example_repo_urn}/info/refs?service=git-receive-pack" 316 | assert_equal 403, session.last_response.status 317 | end 318 | 319 | def test_git_adapter_allow_push 320 | GitAdapter.any_instance.stubs(:allow_push?).returns(true) 321 | 322 | app = App.new(:root => repositories_root) 323 | session = Rack::Test::Session.new(app) 324 | session.get "#{example_repo_urn}/info/refs?service=git-receive-pack" 325 | assert_equal 200, session.last_response.status 326 | end 327 | 328 | def test_git_adapter_forbid_pull 329 | GitAdapter.any_instance.stubs(:allow_pull?).returns(false) 330 | 331 | app = App.new(:root => repositories_root) 332 | session = Rack::Test::Session.new(app) 333 | session.get "#{example_repo_urn}/info/refs?service=git-upload-pack" 334 | assert_equal 403, session.last_response.status 335 | end 336 | 337 | def test_git_adapter_allow_pull 338 | GitAdapter.any_instance.stubs(:allow_pull?).returns(true) 339 | 340 | app = App.new(:root => repositories_root) 341 | session = Rack::Test::Session.new(app) 342 | session.get "#{example_repo_urn}/info/refs?service=git-upload-pack" 343 | assert_equal 200, session.last_response.status 344 | end 345 | 346 | def test_reject_bad_uri 347 | get '/../HEAD' 348 | assert_equal 400, r.status 349 | get "#{example_repo_urn}/../HEAD" 350 | assert_equal 400, r.status 351 | get '/./HEAD' 352 | assert_equal 400, r.status 353 | get "#{example_repo_urn}/./HEAD" 354 | assert_equal 400, r.status 355 | 356 | get '/%2e%2e/HEAD' 357 | assert_equal 400, r.status 358 | get "#{example_repo_urn}/%2e%2e/HEAD" 359 | assert_equal 400, r.status 360 | get '/%2e/HEAD' 361 | assert_equal 400, r.status 362 | get "#{example_repo_urn}/%2e/HEAD" 363 | assert_equal 400, r.status 364 | end 365 | 366 | def test_not_found_in_empty_repo 367 | empty_dir = repositories_root + 'empty-dir' 368 | empty_dir.mkdir 369 | 370 | example_repo_urn = '/empty-dir' 371 | 372 | get "#{example_repo_urn}/info/refs" 373 | assert_equal 404, r.status 374 | get "#{example_repo_urn}/info/alternates" 375 | assert_equal 404, r.status 376 | get "#{example_repo_urn}/info/http-alternates" 377 | assert_equal 404, r.status 378 | get "#{example_repo_urn}/info/packs" 379 | assert_equal 404, r.status 380 | get "#{example_repo_urn}/objects/00/00000000000000000000000000000000000000" 381 | assert_equal 404, r.status 382 | get "#{example_repo_urn}/objects/packs/pack-0000000000000000000000000000000000000000.pack" 383 | assert_equal 404, r.status 384 | get "#{example_repo_urn}/objects/packs/pack-0000000000000000000000000000000000000000.idx" 385 | assert_equal 404, r.status 386 | ensure 387 | empty_dir.rmdir if empty_dir.exist? 388 | end 389 | 390 | def test_not_found_in_nonexistent_repo 391 | example_repo_urn = '/no-dir' 392 | 393 | get "#{example_repo_urn}/info/refs" 394 | assert_equal 404, r.status 395 | get "#{example_repo_urn}/info/alternates" 396 | assert_equal 404, r.status 397 | get "#{example_repo_urn}/info/http-alternates" 398 | assert_equal 404, r.status 399 | get "#{example_repo_urn}/info/packs" 400 | assert_equal 404, r.status 401 | get "#{example_repo_urn}/objects/00/00000000000000000000000000000000000000" 402 | assert_equal 404, r.status 403 | get "#{example_repo_urn}/objects/packs/pack-0000000000000000000000000000000000000000.pack" 404 | assert_equal 404, r.status 405 | get "#{example_repo_urn}/objects/packs/pack-0000000000000000000000000000000000000000.idx" 406 | assert_equal 404, r.status 407 | end 408 | 409 | def test_config_adapter_with_GitAdapter 410 | session = Rack::Test::Session.new( 411 | App.new(:root => repositories_root, :adapter => GitAdapter) 412 | ) 413 | 414 | session.get "#{example_repo_urn}/objects/info/packs" 415 | assert_equal 200, session.last_response.status 416 | assert_match /P pack-(.*?).pack/, session.last_response.body 417 | end 418 | 419 | def test_config_adapter_with_custom_adapter 420 | git_adapter = mock('git_adapter') 421 | git_adapter. 422 | expects(:exist?). 423 | returns(true) 424 | git_adapter. 425 | expects(:repository_path=). 426 | returns(true) 427 | git_adapter. 428 | expects(:update_server_info). 429 | returns(true) 430 | git_adapter. 431 | expects(:file). 432 | with('info/refs'). 433 | returns(FileStreamer.new(Tempfile.new('foo'))) 434 | git_adapter_class = mock('git_adapter_class') 435 | git_adapter_class.expects(:new).with.returns(git_adapter) 436 | session = Rack::Test::Session.new( 437 | App.new( 438 | :root => repositories_root, 439 | :allow_pull => true, 440 | :git_adapter_factory => -> { git_adapter_class.new } 441 | ) 442 | ) 443 | 444 | session.get "#{example_repo_urn}/info/refs" 445 | assert_equal 200, session.last_response.status 446 | end 447 | 448 | def test_config_adapter_ignored_when_adapter_factory_is_set 449 | git_adapter_class = mock('git_adapter_class') 450 | session = Rack::Test::Session.new( 451 | App.new( 452 | :root => repositories_root, 453 | :adapter => git_adapter_class, 454 | :git_adapter_factory => ->{ GitAdapter.new(git_path) } 455 | ) 456 | ) 457 | 458 | session.get "#{example_repo_urn}/info/refs" 459 | assert_equal 200, session.last_response.status 460 | end 461 | 462 | private 463 | 464 | def r 465 | last_response 466 | end 467 | 468 | end 469 | 470 | class MockProcess 471 | 472 | def initialize 473 | @counter = 0 474 | end 475 | 476 | def close_write 477 | end 478 | 479 | def write(data) 480 | end 481 | 482 | def read(length = nil, buffer = nil) 483 | nil 484 | end 485 | 486 | end 487 | -------------------------------------------------------------------------------- /lib/grack/app.rb: -------------------------------------------------------------------------------- 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 | @root = Pathname.new(opts.fetch(:root, '.')).expand_path 51 | @allow_push = opts.fetch(:allow_push, nil) 52 | @allow_pull = opts.fetch(:allow_pull, nil) 53 | @git_adapter_factory = 54 | opts.fetch(:git_adapter_factory, ->{ GitAdapter.new }) 55 | end 56 | 57 | ## 58 | # The Rack handler entry point for this application. This duplicates the 59 | # object and uses the duplicate to perform the work in order to enable 60 | # thread safe request handling. 61 | # 62 | # @param [Hash] env a Rack request hash. 63 | # 64 | # @return a Rack response object. 65 | def call(env) 66 | dup._call(env) 67 | end 68 | 69 | protected 70 | 71 | ## 72 | # The real request handler. 73 | # 74 | # @param [Hash] env a Rack request hash. 75 | # 76 | # @return a Rack response object. 77 | def _call(env) 78 | @git = @git_adapter_factory.call 79 | @env = env 80 | @request = Rack::Request.new(env) 81 | route 82 | end 83 | 84 | private 85 | 86 | ## 87 | # The Rack request hash. 88 | attr_reader :env 89 | 90 | ## 91 | # The request object built from the request hash. 92 | attr_reader :request 93 | 94 | ## 95 | # The Git adapter instance for the requested repository. 96 | attr_reader :git 97 | 98 | ## 99 | # The path containing 1 or more Git repositories which may be requested. 100 | attr_reader :root 101 | 102 | ## 103 | # The path to the repository. 104 | attr_reader :repository_uri 105 | 106 | ## 107 | # The HTTP verb of the request. 108 | attr_reader :request_verb 109 | 110 | ## 111 | # The requested pack type. Will be +nil+ for requests that do no involve 112 | # pack RPCs. 113 | attr_reader :pack_type 114 | 115 | ## 116 | # @return [Boolean] +true+ if the request is authorized; otherwise, +false+. 117 | def authorized? 118 | return allow_pull? if need_read? 119 | return allow_push? 120 | end 121 | 122 | ## 123 | # @return [Boolean] +true+ if read permissions are needed; otherwise, 124 | # +false+. 125 | def need_read? 126 | (request_verb == 'GET' && pack_type != 'git-receive-pack') || 127 | request_verb == 'POST' && pack_type == 'git-upload-pack' 128 | end 129 | 130 | ## 131 | # Determines whether or not pushes into the requested repository are 132 | # allowed. 133 | # 134 | # @return [Boolean] +true+ if pushes are allowed, +false+ otherwise. 135 | def allow_push? 136 | @allow_push || (@allow_push.nil? && git.allow_push?) 137 | end 138 | 139 | ## 140 | # Determines whether or not fetches/pulls from the requested repository are 141 | # allowed. 142 | # 143 | # @return [Boolean] +true+ if fetches are allowed, +false+ otherwise. 144 | def allow_pull? 145 | @allow_pull || (@allow_pull.nil? && git.allow_pull?) 146 | end 147 | 148 | ## 149 | # Routes requests to appropriate handlers. Performs request path cleanup 150 | # and several sanity checks prior to attempting to handle the request. 151 | # 152 | # @return a Rack response object. 153 | def route 154 | # Sanitize the URI: 155 | # * Unescape escaped characters 156 | # * Replace runs of / with a single / 157 | path_info = Rack::Utils.unescape(request.path_info).gsub(%r{/+}, '/') 158 | 159 | ROUTES.each do |path_matcher, verb, handler| 160 | path_info.match(path_matcher) do |match| 161 | @repository_uri = match[1] 162 | @request_verb = verb 163 | 164 | return method_not_allowed unless verb == request.request_method 165 | return bad_request if bad_uri?(@repository_uri) 166 | 167 | git.repository_path = root + @repository_uri 168 | return not_found unless git.exist? 169 | 170 | return send(handler, *match[2..-1]) 171 | end 172 | end 173 | not_found 174 | end 175 | 176 | ## 177 | # Processes pack file exchange requests for both push and pull. Ensures 178 | # that the request is allowed and properly formatted. 179 | # 180 | # @param [String] pack_type the type of pack exchange to perform per the 181 | # request. 182 | # 183 | # @return a Rack response object. 184 | def handle_pack(pack_type) 185 | @pack_type = pack_type 186 | unless request.content_type == "application/x-#{@pack_type}-request" && 187 | valid_pack_type? && authorized? 188 | return no_access 189 | end 190 | 191 | headers = {'Content-Type' => "application/x-#{@pack_type}-result"} 192 | exchange_pack(headers, request_io_in) 193 | end 194 | 195 | ## 196 | # Processes requests for the list of refs for the requested repository. 197 | # 198 | # This works for both Smart HTTP clients and basic ones. For basic clients, 199 | # the Git adapter is used to update the +info/refs+ file which is then 200 | # served to the clients. For Smart HTTP clients, the more efficient pack 201 | # file exchange mechanism is used. 202 | # 203 | # @return a Rack response object. 204 | def info_refs 205 | @pack_type = request.params['service'] 206 | return no_access unless authorized? 207 | 208 | if @pack_type.nil? 209 | git.update_server_info 210 | send_file( 211 | git.file('info/refs'), 'text/plain; charset=utf-8', hdr_nocache 212 | ) 213 | elsif valid_pack_type? 214 | headers = hdr_nocache 215 | headers['Content-Type'] = "application/x-#{@pack_type}-advertisement" 216 | exchange_pack(headers, nil, {:advertise_refs => true}) 217 | else 218 | not_found 219 | end 220 | end 221 | 222 | ## 223 | # Processes requests for info packs for the requested repository. 224 | # 225 | # @param [String] path the path to an info pack file within a Git 226 | # repository. 227 | # 228 | # @return a Rack response object. 229 | def info_packs(path) 230 | return no_access unless authorized? 231 | send_file(git.file(path), 'text/plain; charset=utf-8', hdr_nocache) 232 | end 233 | 234 | ## 235 | # Processes a request for a loose object at _path_ for the selected 236 | # repository. If the file is located, the content type is set to 237 | # +application/x-git-loose-object+ and permanent caching is enabled. 238 | # 239 | # @param [String] path the path to a loose object file within a Git 240 | # repository, such as +objects/31/d73eb4914a8ddb6cb0e4adf250777161118f90+. 241 | # 242 | # @return a Rack response object. 243 | def loose_object(path) 244 | return no_access unless authorized? 245 | send_file( 246 | git.file(path), 'application/x-git-loose-object', hdr_cache_forever 247 | ) 248 | end 249 | 250 | ## 251 | # Process a request for a pack file located at _path_ for the selected 252 | # repository. If the file is located, the content type is set to 253 | # +application/x-git-packed-objects+ and permanent caching is enabled. 254 | # 255 | # @param [String] path the path to a pack file within a Git repository such 256 | # as +pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.pack+. 257 | # 258 | # @return a Rack response object. 259 | def pack_file(path) 260 | return no_access unless authorized? 261 | send_file( 262 | git.file(path), 'application/x-git-packed-objects', hdr_cache_forever 263 | ) 264 | end 265 | 266 | ## 267 | # Process a request for a pack index file located at _path_ for the selected 268 | # repository. If the file is located, the content type is set to 269 | # +application/x-git-packed-objects-toc+ and permanent caching is enabled. 270 | # 271 | # @param [String] path the path to a pack index file within a Git 272 | # repository, such as 273 | # +pack/pack-62c9f443d8405cd6da92dcbb4f849cc01a339c06.idx+. 274 | # 275 | # @return a Rack response object. 276 | def idx_file(path) 277 | return no_access unless authorized? 278 | send_file( 279 | git.file(path), 280 | 'application/x-git-packed-objects-toc', 281 | hdr_cache_forever 282 | ) 283 | end 284 | 285 | ## 286 | # Process a request for a generic file located at _path_ for the selected 287 | # repository. If the file is located, the content type is set to 288 | # +text/plain+ and caching is disabled. 289 | # 290 | # @param [String] path the path to a file within a Git repository, such as 291 | # +HEAD+. 292 | # 293 | # @return a Rack response object. 294 | def text_file(path) 295 | return no_access unless authorized? 296 | send_file(git.file(path), 'text/plain', hdr_nocache) 297 | end 298 | 299 | ## 300 | # Produces a Rack response that wraps the output from the Git adapter. 301 | # 302 | # A 404 response is produced if _streamer_ is +nil+. Otherwise a 200 303 | # response is produced with _streamer_ as the response body. 304 | # 305 | # @param [FileStreamer,IOStreamer] streamer a provider of content for the 306 | # response body. 307 | # @param [String] content_type the MIME type of the content. 308 | # @param [Hash] headers additional headers to include in the response. 309 | # 310 | # @return a Rack response object. 311 | def send_file(streamer, content_type, headers = {}) 312 | return not_found if streamer.nil? 313 | 314 | headers['Content-Type'] = content_type 315 | headers['Last-Modified'] = streamer.mtime.httpdate 316 | 317 | [200, headers, streamer] 318 | end 319 | 320 | ## 321 | # Opens a tunnel for the pack file exchange protocol between the client and 322 | # the Git adapter. 323 | # 324 | # @param [Hash] headers headers to provide in the Rack response. 325 | # @param [#read] io_in a readable, IO-like object providing client input 326 | # data. 327 | # @param [Hash] opts options to pass to the Git adapter's #handle_pack 328 | # method. 329 | # 330 | # @return a Rack response object. 331 | def exchange_pack(headers, io_in, opts = {}) 332 | Rack::Response.new([], 200, headers).finish do |response| 333 | git.handle_pack(pack_type, io_in, response, opts) 334 | end 335 | end 336 | 337 | ## 338 | # Transparently ensures that the request body is not compressed. 339 | # 340 | # @return [#read] a +read+-able object that yields uncompressed data from 341 | # the request body. 342 | def request_io_in 343 | return request.body unless env['HTTP_CONTENT_ENCODING'] =~ /gzip/ 344 | Zlib::GzipReader.new(request.body) 345 | end 346 | 347 | ## 348 | # Determines whether or not the requested pack type is valid. 349 | # 350 | # @return [Boolean] +true+ if the pack type is valid; otherwise, +false+. 351 | def valid_pack_type? 352 | VALID_SERVICE_TYPES.include?(pack_type) 353 | end 354 | 355 | ## 356 | # Determines whether or not _path_ is an acceptable URI. 357 | # 358 | # @param [String] path the path part of the request URI. 359 | # 360 | # @return [Boolean] +true+ if the requested path is considered invalid; 361 | # otherwise, +false+. 362 | def bad_uri?(path) 363 | invalid_segments = %w{. ..} 364 | path.split('/').any? { |segment| invalid_segments.include?(segment) } 365 | end 366 | 367 | # -------------------------------------- 368 | # HTTP error response handling functions 369 | # -------------------------------------- 370 | 371 | ## 372 | # A shorthand for specifying a text content type for the Rack response. 373 | PLAIN_TYPE = {'Content-Type' => 'text/plain'} 374 | 375 | ## 376 | # Returns a Rack response appropriate for requests that use invalid verbs 377 | # for the requested resources. 378 | # 379 | # For HTTP 1.1 requests, a 405 code is returned. For other versions, the 380 | # value from #bad_request is returned. 381 | # 382 | # @return a Rack response appropriate for requests that use invalid verbs 383 | # for the requested resources. 384 | def method_not_allowed 385 | if env['SERVER_PROTOCOL'] == 'HTTP/1.1' 386 | [405, PLAIN_TYPE, ['Method Not Allowed']] 387 | else 388 | bad_request 389 | end 390 | end 391 | 392 | ## 393 | # @return a Rack response for generally bad requests. 394 | def bad_request 395 | [400, PLAIN_TYPE, ['Bad Request']] 396 | end 397 | 398 | ## 399 | # @return a Rack response for unlocatable resources. 400 | def not_found 401 | [404, PLAIN_TYPE, ['Not Found']] 402 | end 403 | 404 | ## 405 | # @return a Rack response for forbidden resources. 406 | def no_access 407 | [403, PLAIN_TYPE, ['Forbidden']] 408 | end 409 | 410 | 411 | # ------------------------ 412 | # header writing functions 413 | # ------------------------ 414 | 415 | ## 416 | # NOTE: This should probably be converted to a constant. 417 | # 418 | # @return a hash of headers that should prevent caching of a Rack response. 419 | def hdr_nocache 420 | { 421 | 'Expires' => 'Fri, 01 Jan 1980 00:00:00 GMT', 422 | 'Pragma' => 'no-cache', 423 | 'Cache-Control' => 'no-cache, max-age=0, must-revalidate' 424 | } 425 | end 426 | 427 | ## 428 | # @return a hash of headers that should trigger caches permanent caching. 429 | def hdr_cache_forever 430 | now = Time.now().to_i 431 | { 432 | 'Date' => now.to_s, 433 | 'Expires' => (now + 31536000).to_s, 434 | 'Cache-Control' => 'public, max-age=31536000' 435 | } 436 | end 437 | 438 | end 439 | end 440 | --------------------------------------------------------------------------------