File Coverage

blib/lib/Plack/App/GitSmartHttp.pm
Criterion Covered Total %
statement 217 227 95.5
branch 47 60 78.3
condition 7 9 77.7
subroutine 40 40 100.0
pod 2 23 8.7
total 313 359 87.1


line stmt bran cond sub pod time code
1             package Plack::App::GitSmartHttp;
2              
3 21     21   1062233 use strict;
  21         51  
  21         808  
4 21     21   109 use warnings;
  21         33  
  21         725  
5              
6 21     21   600 use parent qw/Plack::Component/;
  21         277  
  21         142  
7 21     21   208929 use Plack::Request;
  21         658041  
  21         735  
8 21     21   163 use Plack::Util;
  21         35  
  21         469  
9 21     21   10813 use Plack::Util::Accessor qw( root git_path upload_pack received_pack );
  21         4876  
  21         128  
10 21     21   12639 use HTTP::Date;
  21         72333  
  21         1397  
11 21     21   138 use Cwd ();
  21         31  
  21         323  
12 21     21   10095 use File::Spec::Functions;
  21         13341  
  21         1543  
13 21     21   10666 use File::chdir;
  21         31172  
  21         2471  
14 21     21   559 use File::Which qw(which);
  21         781  
  21         944  
15 21     21   97 use Symbol qw(gensym);
  21         30  
  21         977  
16 21     21   11703 use IPC::Open3;
  21         49440  
  21         1164  
17 21     21   11313 use IO::Select;
  21         29099  
  21         1037  
18 21     21   12992 use IO::Uncompress::Gunzip qw($GunzipError);
  21         674973  
  21         2561  
19              
20 21     21   172 use constant BUFFER_SIZE => 8192;
  21         34  
  21         41784  
21              
22             our $VERSION = '0.07';
23             my @SERVICES = (
24             [ 'POST', 'service_rpc', qr{(.*?)/git-upload-pack$}, 'upload-pack' ],
25             [ 'POST', 'service_rpc', qr{(.*?)/git-receive-pack$}, 'receive-pack' ],
26              
27             [ 'GET', 'get_info_refs', qr{(.*?)/info/refs$} ],
28             [ 'GET', 'get_text_file', qr{(.*?)/HEAD$} ],
29             [ 'GET', 'get_text_file', qr{(.*?)/objects/info/alternates$} ],
30             [ 'GET', 'get_text_file', qr{(.*?)/objects/info/http-alternates$} ],
31             [ 'GET', 'get_info_packs', qr{(.*?)/objects/info/packs$} ],
32             [ 'GET', 'get_loose_object', qr{(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$} ],
33             [
34             'GET', 'get_pack_file', qr{(.*?)/objects/pack/pack-[0-9a-f]{40}\.pack$}
35             ],
36             [ 'GET', 'get_idx_file', qr{(.*?)/objects/pack/pack-[0-9a-f]{40}\.idx$} ],
37             );
38              
39             sub prepare_app {
40 20     20 1 320512 my $self = shift;
41 20 100       137 unless ( $self->git_path ) {
42 16         206 my $git_path = which('git');
43 16 50       2173 unless ($git_path) {
44 0         0 die 'could not find git';
45             }
46 16         74 $self->git_path($git_path);
47             }
48 20 50       235 unless ( -x $self->git_path ) {
49 0         0 die 'git_path is not executable [' . $self->git_path . ']';
50             }
51             }
52              
53             sub call {
54 20     20 1 644 my $self = shift;
55 20         397 my $env = shift;
56 20         184 my $req = Plack::Request->new($env);
57              
58 20         264 my ( $cmd, $path, $reqfile, $rpc ) = $self->match_routing($req);
59              
60 20 100       86 return $self->return_404 unless $cmd;
61 19 100       71 return $self->return_not_allowed($env) if $cmd eq 'not_allowed';
62              
63 17         61 my $dir = $self->get_git_repo_dir($path);
64 17 100       53 return $self->return_404 unless $dir;
65              
66             {
67 16         26 local $CWD = $dir;
  16         137  
68 16         821 $self->$cmd(
69             {
70             req => $req,
71             path => $path,
72             reqfile => $reqfile,
73             rpc => $rpc
74             }
75             );
76             }
77             }
78              
79             sub get_service {
80 7     7 0 9719 my $self = shift;
81 7         18 my $req = shift;
82              
83 7         31 my $service = $req->param('service');
84 7 100       1657 return unless $service;
85 6 100       38 return unless substr( $service, 0, 4 ) eq 'git-';
86 4         21 $service =~ s/git-//g;
87 4         19 return $service;
88             }
89              
90             sub match_routing {
91 30     30 0 22814 my $self = shift;
92 30         51 my $req = shift;
93              
94 30         46 my ( $cmd, $path, $file, $rpc );
95 30         77 for my $s (@SERVICES) {
96 124         814 my $match = $s->[2];
97 124 100       253 if ( $req->path_info =~ /$match/ ) {
98 29 100       478 return ('not_allowed') if $s->[0] ne uc( $req->method );
99 26         322 $cmd = $s->[1];
100 26         66 $path = $1;
101 26         73 $file = $req->path_info;
102 26         360 $file =~ s|\Q$path/\E||;
103 26         55 $rpc = $s->[3];
104 26         102 return ( $cmd, $path, $file, $rpc );
105             }
106             }
107 1         6 return ();
108             }
109              
110             sub get_git_repo_dir {
111 20     20 0 6555 my $self = shift;
112 20         44 my $path = shift;
113              
114 20   66     101 my $root = $self->root || `pwd`;
115 20         2705 chomp $root;
116 20         142 $path = catdir( $root, $path );
117 20 100       433 return $path if ( -d $path );
118 2         13 return;
119             }
120              
121             sub service_rpc {
122 8     8 0 17 my $self = shift;
123 8         13 my $args = shift;
124              
125 8         16 my $req = $args->{req};
126 8         14 my $rpc = $args->{rpc};
127              
128 8 100       40 return $self->return_403
129             unless $self->has_access( $req, $rpc, 1 );
130              
131 4         36 my @cmd = $self->git_command( $rpc, '--stateless-rpc', '.' );
132              
133 4         13 my $input = $req->input;
134 4 100 66     26 if ( exists $req->env->{HTTP_CONTENT_ENCODING}
135             && $req->env->{HTTP_CONTENT_ENCODING} =~ /^(?:x-)?gzip$/ )
136             {
137 2         64 $input = IO::Uncompress::Gunzip->new($input);
138 2 50       2908 unless ($input) {
139 0         0 $req->env->{'psgi.errors'}->print("gunzip failed: $GunzipError");
140 0         0 return $self->return_400;
141             }
142             }
143 4         26 my ( $cout, $cerr ) = ( gensym, gensym );
144 4         83 my $pid = open3( my $cin, $cout, $cerr, @cmd );
145 4         11296 my $input_len = 0;
146 4         74 while ( my $len = $input->read( my $buf, BUFFER_SIZE ) > 0 ) {
147 3         173 print $cin $buf;
148 3         15 $input_len += $len;
149             }
150 4         68 close $cin;
151 4 100       12 if ( $input_len == 0 ) {
152 1         6 close $cout;
153 1         4 close $cerr;
154 1         4575 waitpid( $pid, 0 );
155 1         13 return $self->return_400;
156             }
157              
158             return sub {
159 3     3   529 my $respond = shift;
160 3         28 my $writer = $respond->(
161             [
162             200,
163             [
164             'Content-Type' =>
165             sprintf( 'application/x-git-%s-result', $rpc ),
166             ]
167             ]
168             );
169              
170 3         141 my ( $out, $err, $buf ) = ( '', '', '' );
171 3         34 my $s = IO::Select->new( $cout, $cerr );
172 3         239 while ( my @ready = $s->can_read ) {
173 6         8534 for my $handle (@ready) {
174 6         60 while ( sysread( $handle, $buf, BUFFER_SIZE ) ) {
175 20 50       10679 if ( $handle == $cerr ) {
176 0         0 $err .= $buf;
177             }
178             else {
179 20         161 $writer->write($buf);
180             }
181             }
182 6 50       1548 $s->remove($handle) if eof($handle);
183             }
184             }
185 3         151 close $cout;
186 3         18 close $cerr;
187 3         46 waitpid( $pid, 0 );
188              
189 3 50       9 if ($err) {
190 0         0 $req->env->{'psgi.errors'}->print("git command failed: $err");
191             }
192 3         32 $writer->close();
193             }
194 3         105 }
195              
196             sub get_info_refs {
197 3     3 0 6 my $self = shift;
198 3         4 my $args = shift;
199              
200 3         7 my $req = $args->{req};
201 3         11 my $service = $self->get_service($req);
202 3 100       13 if ( $self->has_access( $args->{req}, $service ) ) {
203 2         18 my @cmd =
204             $self->git_command( $service, '--stateless-rpc', '--advertise-refs',
205             '.' );
206              
207 2         10 my ( $cout, $cerr ) = ( gensym, gensym );
208 2         53 my $pid = open3( my $cin, $cout, $cerr, @cmd );
209 2         5607 close $cin;
210 2         12 my ( $refs, $err, $buf ) = ( '', '', '' );
211 2         34 my $s = IO::Select->new( $cout, $cerr );
212 2         237 while ( my @ready = $s->can_read ) {
213 4         4765 for my $handle (@ready) {
214 4         138 while ( sysread( $handle, $buf, BUFFER_SIZE ) ) {
215 3 50       18 if ( $handle == $cerr ) {
216 0         0 $err .= $buf;
217             }
218             else {
219 3         1159 $refs .= $buf;
220             }
221             }
222 4 50       142 $s->remove($handle) if eof($handle);
223             }
224             }
225 2         264 close $cout;
226 2         12 close $cerr;
227 2         44 waitpid( $pid, 0 );
228              
229 2 50       16 if ($err) {
230 0         0 $req->env->{'psgi.errors'}->print("git command failed: $err");
231 0         0 return $self->return_400;
232             }
233              
234 2         26 my $res = $req->new_response(200);
235 2         5092 $res->headers(
236             [
237             'Content-Type' =>
238             sprintf( 'application/x-git-%s-advertisement', $service ),
239             ]
240             );
241 2         322 my $body =
242             pkt_write("# service=git-${service}\n") . pkt_flush() . $refs;
243 2         14 $res->body($body);
244 2         20 return $res->finalize;
245             }
246             else {
247 1         3 return $self->dumb_info_refs($args);
248             }
249             }
250              
251             sub dumb_info_refs {
252 1     1 0 2 my $self = shift;
253 1         1 my $args = shift;
254 1         3 $self->update_server_info;
255 1         21 $self->send_file( $args, "text/plain; charset=utf-8" );
256             }
257              
258             sub get_info_packs {
259 1     1 0 2 my $self = shift;
260 1         1 my $args = shift;
261 1         3 $self->send_file( $args, "text/plain; charset=utf-8" );
262             }
263              
264             sub get_loose_object {
265 1     1 0 2 my $self = shift;
266 1         1 my $args = shift;
267 1         3 $self->send_file( $args, "application/x-git-loose-object" );
268             }
269              
270             sub get_pack_file {
271 1     1 0 2 my $self = shift;
272 1         1 my $args = shift;
273 1         4 $self->send_file( $args, "application/x-git-packed-objects" );
274             }
275              
276             sub get_idx_file {
277 1     1 0 3 my $self = shift;
278 1         1 my $args = shift;
279 1         3 $self->send_file( $args, "application/x-git-packed-objects-toc" );
280             }
281              
282             sub get_text_file {
283 1     1 0 1 my $self = shift;
284 1         2 my $args = shift;
285 1         4 $self->send_file( $args, "text/plain" );
286             }
287              
288             sub update_server_info {
289 1     1 0 2 my $self = shift;
290 1         2 system( $self->git_command('update-server-info') );
291             }
292              
293             sub git_command {
294 7     7 0 11 my $self = shift;
295 7         20 my @commands = @_;
296 7         20 my $git_bin = $self->git_path;
297 7         3884 return ( $git_bin, @commands );
298             }
299              
300             sub has_access {
301 11     11 0 15 my $self = shift;
302 11         20 my ( $req, $rpc, $check_content_type ) = @_;
303              
304 11 100 100     82 if ( $check_content_type
305             && $req->content_type ne
306             sprintf( "application/x-git-%s-request", $rpc ) )
307             {
308 4         66 return;
309             }
310              
311 7 100       63 return if !$rpc;
312 6 100       19 return $self->received_pack if $rpc eq 'receive-pack';
313 5 50       33 return $self->upload_pack if $rpc eq 'upload-pack';
314 0         0 return;
315             }
316              
317             sub send_file {
318 6     6 0 11 my $self = shift;
319 6         18 my ( $args, $content_type ) = @_;
320              
321 6         16 my $file = $args->{reqfile};
322 6 50       90 return $self->return_404 unless -e $file;
323              
324 6         85 my @stat = stat $file;
325 6         54 my $res = $args->{req}->new_response(200);
326 6         9765 $res->headers(
327             [
328             'Content-Type' => $content_type,
329             'Last-Modified' => HTTP::Date::time2str( $stat[9] ),
330             'Expires' => 'Fri, 01 Jan 1980 00:00:00 GMT',
331             'Pragma' => 'no-cache',
332             'Cache-Control' => 'no-cache, max-age=0, must-revalidate',
333             ]
334             );
335              
336 6 50       934 if ( $stat[7] ) {
337 6         24 $res->header( 'Content-Length' => $stat[7] );
338             }
339 6 50       362 open my $fh, "<:raw", $file
340             or return $self->return_403;
341              
342 6         198 Plack::Util::set_io_path( $fh, Cwd::realpath($file) );
343 6         171 $res->body($fh);
344 6         37 $res->finalize;
345             }
346              
347             sub pkt_flush {
348 2     2 0 13 return '0000';
349             }
350              
351             sub pkt_write {
352 2     2 0 5 my $str = shift;
353 2         24 return sprintf( '%04x', length($str) + 4 ) . $str;
354             }
355              
356             sub return_not_allowed {
357 2     2 0 3 my $self = shift;
358 2         2 my $env = shift;
359 2 100       4 if ( $env->{SERVER_PROTOCOL} eq 'HTTP/1.1' ) {
360             return [
361 1         14 405, [ 'Content-Type' => 'text/plain', 'Content-Length' => 18 ],
362             ['Method Not Allowed']
363             ];
364             }
365             else {
366             return [
367 1         7 400, [ 'Content-Type' => 'text/plain', 'Content-Length' => 11 ],
368             ['Bad Request']
369             ];
370             }
371             }
372              
373             sub return_403 {
374 4     4 0 7 my $self = shift;
375             return [
376 4         38 403, [ 'Content-Type' => 'text/plain', 'Content-Length' => 9 ],
377             ['Forbidden']
378             ];
379             }
380              
381             sub return_400 {
382 1     1 0 3 my $self = shift;
383             return [
384 1         42 400, [ 'Content-Type' => 'text/plain', 'Content-Length' => 11 ],
385             ['Bad Request']
386             ];
387             }
388              
389             sub return_404 {
390 2     2 0 3 my $self = shift;
391             return [
392 2         39 404, [ 'Content-Type' => 'text/plain', 'Content-Length' => 9 ],
393             ['Not Found']
394             ];
395             }
396              
397             1;
398             __END__