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 20     20   1021280 use strict;
  20         42  
  20         718  
4 20     20   89 use warnings;
  20         28  
  20         594  
5              
6 20     20   500 use parent qw/Plack::Component/;
  20         279  
  20         121  
7 20     20   183945 use Plack::Request;
  20         630189  
  20         683  
8 20     20   157 use Plack::Util;
  20         26  
  20         421  
9 20     20   10279 use Plack::Util::Accessor qw( root git_path upload_pack received_pack );
  20         4534  
  20         125  
10 20     20   12626 use HTTP::Date;
  20         69685  
  20         1276  
11 20     20   128 use Cwd ();
  20         25  
  20         313  
12 20     20   10130 use File::Spec::Functions;
  20         13571  
  20         1533  
13 20     20   10415 use File::chdir;
  20         30183  
  20         2375  
14 20     20   537 use File::Which qw(which);
  20         815  
  20         979  
15 20     20   90 use Symbol qw(gensym);
  20         26  
  20         942  
16 20     20   11020 use IPC::Open3;
  20         47426  
  20         1069  
17 20     20   11145 use IO::Select;
  20         27515  
  20         1001  
18 20     20   13401 use IO::Uncompress::Gunzip qw($GunzipError);
  20         641284  
  20         2201  
19              
20 20     20   164 use constant BUFFER_SIZE => 8192;
  20         27  
  20         38570  
21              
22             our $VERSION = '0.06';
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 330190 my $self = shift;
41 20 100       132 unless ( $self->git_path ) {
42 16         230 my $git_path = which('git');
43 16 50       2104 unless ($git_path) {
44 0         0 die 'could not find git';
45             }
46 16         75 $self->git_path($git_path);
47             }
48 20 50       219 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 685 my $self = shift;
55 20         375 my $env = shift;
56 20         201 my $req = Plack::Request->new($env);
57              
58 20         274 my ( $cmd, $path, $reqfile, $rpc ) = $self->match_routing($req);
59              
60 20 100       84 return $self->return_404 unless $cmd;
61 19 100       72 return $self->return_not_allowed($env) if $cmd eq 'not_allowed';
62              
63 17         60 my $dir = $self->get_git_repo_dir($path);
64 17 100       55 return $self->return_404 unless $dir;
65              
66             {
67 16         25 local $CWD = $dir;
  16         127  
68 16         808 $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 6073 my $self = shift;
81 7         19 my $req = shift;
82              
83 7         24 my $service = $req->param('service');
84 7 100       1226 return unless $service;
85 6 100       31 return unless substr( $service, 0, 4 ) eq 'git-';
86 4         20 $service =~ s/git-//g;
87 4         17 return $service;
88             }
89              
90             sub match_routing {
91 30     30 0 20508 my $self = shift;
92 30         50 my $req = shift;
93              
94 30         43 my ( $cmd, $path, $file, $rpc );
95 30         73 for my $s (@SERVICES) {
96 124         903 my $match = $s->[2];
97 124 100       263 if ( $req->path_info =~ /$match/ ) {
98 29 100       462 return ('not_allowed') if $s->[0] ne uc( $req->method );
99 26         235 $cmd = $s->[1];
100 26         70 $path = $1;
101 26         130 $file = $req->path_info;
102 26         355 $file =~ s|\Q$path/\E||;
103 26         54 $rpc = $s->[3];
104 26         126 return ( $cmd, $path, $file, $rpc );
105             }
106             }
107 1         7 return ();
108             }
109              
110             sub get_git_repo_dir {
111 20     20 0 8245 my $self = shift;
112 20         71 my $path = shift;
113              
114 20   66     99 my $root = $self->root || `pwd`;
115 20         3629 chomp $root;
116 20         121 $path = catdir( $root, $path );
117 20 100       502 return $path if ( -d $path );
118 2         13 return;
119             }
120              
121             sub service_rpc {
122 8     8 0 16 my $self = shift;
123 8         14 my $args = shift;
124              
125 8         15 my $req = $args->{req};
126 8         16 my $rpc = $args->{rpc};
127              
128 8 100       43 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         23 my $input = $req->input;
134 4 100 66     28 if ( exists $req->env->{HTTP_CONTENT_ENCODING}
135             && $req->env->{HTTP_CONTENT_ENCODING} =~ /^(?:x-)?gzip$/ )
136             {
137 2         63 $input = IO::Uncompress::Gunzip->new($input);
138 2 50       2737 unless ($input) {
139 0         0 $req->env->{'psgi.errors'}->print("gunzip failed: $GunzipError");
140 0         0 return $self->return_400;
141             }
142             }
143 4         30 my ( $cout, $cerr ) = ( gensym, gensym );
144 4         95 my $pid = open3( my $cin, $cout, $cerr, @cmd );
145 4         12697 my $input_len = 0;
146 4         61 while ( my $len = $input->read( my $buf, BUFFER_SIZE ) > 0 ) {
147 3         148 print $cin $buf;
148 3         12 $input_len += $len;
149             }
150 4         77 close $cin;
151 4 100       15 if ( $input_len == 0 ) {
152 1         11 close $cout;
153 1         7 close $cerr;
154 1         4621 waitpid( $pid, 0 );
155 1         24 return $self->return_400;
156             }
157              
158             return sub {
159 3     3   576 my $respond = shift;
160 3         25 my $writer = $respond->(
161             [
162             200,
163             [
164             'Content-Type' =>
165             sprintf( 'application/x-git-%s-result', $rpc ),
166             ]
167             ]
168             );
169              
170 3         118 my ( $out, $err, $buf ) = ( '', '', '' );
171 3         30 my $s = IO::Select->new( $cout, $cerr );
172 3         235 while ( my @ready = $s->can_read ) {
173 6         9854 for my $handle (@ready) {
174 6         78 while ( sysread( $handle, $buf, BUFFER_SIZE ) ) {
175 19 50       12047 if ( $handle == $cerr ) {
176 0         0 $err .= $buf;
177             }
178             else {
179 19         298 $writer->write($buf);
180             }
181             }
182 6 50       1189 $s->remove($handle) if eof($handle);
183             }
184             }
185 3         136 close $cout;
186 3         16 close $cerr;
187 3         44 waitpid( $pid, 0 );
188              
189 3 50       10 if ($err) {
190 0         0 $req->env->{'psgi.errors'}->print("git command failed: $err");
191             }
192 3         29 $writer->close();
193             }
194 3         107 }
195              
196             sub get_info_refs {
197 3     3 0 7 my $self = shift;
198 3         4 my $args = shift;
199              
200 3         11 my $req = $args->{req};
201 3         10 my $service = $self->get_service($req);
202 3 100       15 if ( $self->has_access( $args->{req}, $service ) ) {
203 2         24 my @cmd =
204             $self->git_command( $service, '--stateless-rpc', '--advertise-refs',
205             '.' );
206              
207 2         11 my ( $cout, $cerr ) = ( gensym, gensym );
208 2         60 my $pid = open3( my $cin, $cout, $cerr, @cmd );
209 2         6283 close $cin;
210 2         13 my ( $refs, $err, $buf ) = ( '', '', '' );
211 2         34 my $s = IO::Select->new( $cout, $cerr );
212 2         187 while ( my @ready = $s->can_read ) {
213 4         4940 for my $handle (@ready) {
214 4         48 while ( sysread( $handle, $buf, BUFFER_SIZE ) ) {
215 3 50       20 if ( $handle == $cerr ) {
216 0         0 $err .= $buf;
217             }
218             else {
219 3         649 $refs .= $buf;
220             }
221             }
222 4 50       73 $s->remove($handle) if eof($handle);
223             }
224             }
225 2         78 close $cout;
226 2         13 close $cerr;
227 2         30 waitpid( $pid, 0 );
228              
229 2 50       9 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         24 my $res = $req->new_response(200);
235 2         4286 $res->headers(
236             [
237             'Content-Type' =>
238             sprintf( 'application/x-git-%s-advertisement', $service ),
239             ]
240             );
241 2         222 my $body =
242             pkt_write("# service=git-${service}\n") . pkt_flush() . $refs;
243 2         12 $res->body($body);
244 2         19 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         5 my $args = shift;
254 1         3 $self->update_server_info;
255 1         91 $self->send_file( $args, "text/plain; charset=utf-8" );
256             }
257              
258             sub get_info_packs {
259 1     1 0 3 my $self = shift;
260 1         1 my $args = shift;
261 1         5 $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         4 $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         3 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 1 my $self = shift;
278 1         2 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 2 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         3 system( $self->git_command('update-server-info') );
291             }
292              
293             sub git_command {
294 7     7 0 12 my $self = shift;
295 7         19 my @commands = @_;
296 7         23 my $git_bin = $self->git_path;
297 7         4613 return ( $git_bin, @commands );
298             }
299              
300             sub has_access {
301 11     11 0 26 my $self = shift;
302 11         22 my ( $req, $rpc, $check_content_type ) = @_;
303              
304 11 100 100     140 if ( $check_content_type
305             && $req->content_type ne
306             sprintf( "application/x-git-%s-request", $rpc ) )
307             {
308 4         63 return;
309             }
310              
311 7 100       70 return if !$rpc;
312 6 100       28 return $self->received_pack if $rpc eq 'receive-pack';
313 5 50       31 return $self->upload_pack if $rpc eq 'upload-pack';
314 0         0 return;
315             }
316              
317             sub send_file {
318 6     6 0 20 my $self = shift;
319 6         16 my ( $args, $content_type ) = @_;
320              
321 6         19 my $file = $args->{reqfile};
322 6 50       97 return $self->return_404 unless -e $file;
323              
324 6         73 my @stat = stat $file;
325 6         46 my $res = $args->{req}->new_response(200);
326 6         10800 $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       1011 if ( $stat[7] ) {
337 6         26 $res->header( 'Content-Length' => $stat[7] );
338             }
339 6 50       423 open my $fh, "<:raw", $file
340             or return $self->return_403;
341              
342 6         302 Plack::Util::set_io_path( $fh, Cwd::realpath($file) );
343 6         190 $res->body($fh);
344 6         42 $res->finalize;
345             }
346              
347             sub pkt_flush {
348 2     2 0 12 return '0000';
349             }
350              
351             sub pkt_write {
352 2     2 0 6 my $str = shift;
353 2         20 return sprintf( '%04x', length($str) + 4 ) . $str;
354             }
355              
356             sub return_not_allowed {
357 2     2 0 3 my $self = shift;
358 2         3 my $env = shift;
359 2 100       10 if ( $env->{SERVER_PROTOCOL} eq 'HTTP/1.1' ) {
360             return [
361 1         21 405, [ 'Content-Type' => 'text/plain', 'Content-Length' => 18 ],
362             ['Method Not Allowed']
363             ];
364             }
365             else {
366             return [
367 1         8 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         27 403, [ 'Content-Type' => 'text/plain', 'Content-Length' => 9 ],
377             ['Forbidden']
378             ];
379             }
380              
381             sub return_400 {
382 1     1 0 8 my $self = shift;
383             return [
384 1         64 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         37 404, [ 'Content-Type' => 'text/plain', 'Content-Length' => 9 ],
393             ['Not Found']
394             ];
395             }
396              
397             1;
398             __END__