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   895138 use strict;
  21         1316  
  21         807  
4 21     21   118 use warnings;
  21         43  
  21         709  
5              
6 21     21   931 use parent qw/Plack::Component/;
  21         414  
  21         857  
7 21     21   307259 use Plack::Request;
  21         977994  
  21         717  
8 21     21   206 use Plack::Util;
  21         45  
  21         571  
9 21     21   18210 use Plack::Util::Accessor qw( root git_path upload_pack received_pack );
  21         5421  
  21         152  
10 21     21   20203 use HTTP::Date;
  21         95023  
  21         1409  
11 21     21   160 use Cwd ();
  21         47  
  21         343  
12 21     21   27253 use File::Spec::Functions;
  21         17127  
  21         1908  
13 21     21   18983 use File::chdir;
  21         38318  
  21         2453  
14 21     21   1079 use File::Which qw(which);
  21         1271  
  21         1111  
15 21     21   116 use Symbol qw(gensym);
  21         67  
  21         1214  
16 21     21   23951 use IPC::Open3;
  21         60287  
  21         1172  
17 21     21   21126 use IO::Select;
  21         35193  
  21         1143  
18 21     21   23537 use IO::Uncompress::Gunzip qw($GunzipError);
  21         925814  
  21         2614  
19              
20 21     21   212 use constant BUFFER_SIZE => 8192;
  21         55  
  21         57092  
21              
22             our $VERSION = '0.05';
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 429012 my $self = shift;
41 20 100       166 unless ( $self->git_path ) {
42 16         226 my $git_path = which('git');
43 16 50       3129 unless ($git_path) {
44 0         0 die 'could not find git';
45             }
46 16         79 $self->git_path($git_path);
47             }
48 20 50       204 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 839 my $self = shift;
55 20         40 my $env = shift;
56 20         211 my $req = Plack::Request->new($env);
57              
58 20         280 my ( $cmd, $path, $reqfile, $rpc ) = $self->match_routing($req);
59              
60 20 100       104 return $self->return_404 unless $cmd;
61 19 100       78 return $self->return_not_allowed($env) if $cmd eq 'not_allowed';
62              
63 17         69 my $dir = $self->get_git_repo_dir($path);
64 17 100       91 return $self->return_404 unless $dir;
65              
66             {
67 16         28 local $CWD = $dir;
  16         166  
68 16         953 $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 2400 my $self = shift;
81 7         14 my $req = shift;
82              
83 7         34 my $service = $req->param('service');
84 7 100       1413 return unless $service;
85 6 100       30 return unless substr( $service, 0, 4 ) eq 'git-';
86 4         33 $service =~ s/git-//g;
87 4         19 return $service;
88             }
89              
90             sub match_routing {
91 30     30 0 6057 my $self = shift;
92 30         59 my $req = shift;
93              
94 30         65 my ( $cmd, $path, $file, $rpc );
95 30         85 for my $s (@SERVICES) {
96 124         911 my $match = $s->[2];
97 124 100       335 if ( $req->path_info =~ /$match/ ) {
98 29 100       516 return ('not_allowed') if $s->[0] ne uc( $req->method );
99 26         243 $cmd = $s->[1];
100 26         76 $path = $1;
101 26         87 $file = $req->path_info;
102 26         413 $file =~ s|\Q$path/\E||;
103 26         69 $rpc = $s->[3];
104 26         125 return ( $cmd, $path, $file, $rpc );
105             }
106             }
107 1         10 return ();
108             }
109              
110             sub get_git_repo_dir {
111 20     20 0 4150 my $self = shift;
112 20         46 my $path = shift;
113              
114 20   66     132 my $root = $self->root || `pwd`;
115 20         6380 chomp $root;
116 20         151 $path = catdir( $root, $path );
117 20 100       588 return $path if ( -d $path );
118 2         18 return;
119             }
120              
121             sub service_rpc {
122 8     8 0 14 my $self = shift;
123 8         14 my $args = shift;
124              
125 8         18 my $req = $args->{req};
126 8         17 my $rpc = $args->{rpc};
127              
128 8 100       60 return $self->return_403
129             unless $self->has_access( $req, $rpc, 1 );
130              
131 4         38 my @cmd = $self->git_command( $rpc, '--stateless-rpc', '.' );
132              
133 4         15 my $input = $req->input;
134 4 100 66     33 if ( exists $req->env->{HTTP_CONTENT_ENCODING}
135             && $req->env->{HTTP_CONTENT_ENCODING} =~ /^(?:x-)?gzip$/ )
136             {
137 2         80 $input = IO::Uncompress::Gunzip->new($input);
138 2 50       3269 unless ($input) {
139 0         0 $req->env->{'psgi.errors'}->print("gunzip failed: $GunzipError");
140 0         0 return $self->return_400;
141             }
142             }
143 4         38 my ( $cout, $cerr ) = ( gensym, gensym );
144 4         125 my $pid = open3( my $cin, $cout, $cerr, @cmd );
145 4         33342 my $input_len = 0;
146 4         127 while ( my $len = $input->read( my $buf, BUFFER_SIZE ) > 0 ) {
147 3         214 print $cin $buf;
148 3         15 $input_len += $len;
149             }
150 4         94 close $cin;
151 4 100       15 if ( $input_len == 0 ) {
152 1         18 close $cout;
153 1         15 close $cerr;
154 1         8849 waitpid( $pid, 0 );
155 1         38 return $self->return_400;
156             }
157              
158             return sub {
159 3     3   1159 my $respond = shift;
160 3         44 my $writer = $respond->(
161             [
162             200,
163             [
164             'Content-Type' =>
165             sprintf( 'application/x-git-%s-result', $rpc ),
166             ]
167             ]
168             );
169              
170 3         231 my ( $out, $err, $buf ) = ( '', '', '' );
171 3         61 my $s = IO::Select->new( $cout, $cerr );
172 3         423 while ( my @ready = $s->can_read ) {
173 6         17812 for my $handle (@ready) {
174 6         125 while ( sysread( $handle, $buf, BUFFER_SIZE ) ) {
175 11 50       35361 if ( $handle == $cerr ) {
176 0         0 $err .= $buf;
177             }
178             else {
179 11         194 $writer->write($buf);
180             }
181             }
182 6 50       2485 $s->remove($handle) if eof($handle);
183             }
184             }
185 3         153 close $cout;
186 3         40 close $cerr;
187 3         61 waitpid( $pid, 0 );
188              
189 3 50       11 if ($err) {
190 0         0 $req->env->{'psgi.errors'}->print("git command failed: $err");
191             }
192 3         34 $writer->close();
193             }
194 3         225 }
195              
196             sub get_info_refs {
197 3     3 0 6 my $self = shift;
198 3         7 my $args = shift;
199              
200 3         8 my $req = $args->{req};
201 3         17 my $service = $self->get_service($req);
202 3 100       17 if ( $self->has_access( $args->{req}, $service ) ) {
203 2         25 my @cmd =
204             $self->git_command( $service, '--stateless-rpc', '--advertise-refs',
205             '.' );
206              
207 2         19 my ( $cout, $cerr ) = ( gensym, gensym );
208 2         72 my $pid = open3( my $cin, $cout, $cerr, @cmd );
209 2         11486 close $cin;
210 2         31 my ( $refs, $err, $buf ) = ( '', '', '' );
211 2         69 my $s = IO::Select->new( $cout, $cerr );
212 2         243 while ( my @ready = $s->can_read ) {
213 3         11948 for my $handle (@ready) {
214 4         160 while ( sysread( $handle, $buf, BUFFER_SIZE ) ) {
215 3 50       31 if ( $handle == $cerr ) {
216 0         0 $err .= $buf;
217             }
218             else {
219 3         1394 $refs .= $buf;
220             }
221             }
222 4 50       150 $s->remove($handle) if eof($handle);
223             }
224             }
225 2         139 close $cout;
226 2         30 close $cerr;
227 2         79 waitpid( $pid, 0 );
228              
229 2 50       22 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         34 my $res = $req->new_response(200);
235 2         7624 $res->headers(
236             [
237             'Content-Type' =>
238             sprintf( 'application/x-git-%s-advertisement', $service ),
239             ]
240             );
241 2         358 my $body =
242             pkt_write("# service=git-${service}\n") . pkt_flush() . $refs;
243 2         13 $res->body($body);
244 2         73 return $res->finalize;
245             }
246             else {
247 1         4 return $self->dumb_info_refs($args);
248             }
249             }
250              
251             sub dumb_info_refs {
252 1     1 0 3 my $self = shift;
253 1         1 my $args = shift;
254 1         12 $self->update_server_info;
255 1         55 $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         2 my $args = shift;
261 1         4 $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         2 my $args = shift;
267 1         5 $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         2 my $args = shift;
273 1         5 $self->send_file( $args, "application/x-git-packed-objects" );
274             }
275              
276             sub get_idx_file {
277 1     1 0 2 my $self = shift;
278 1         2 my $args = shift;
279 1         5 $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         4 system( $self->git_command('update-server-info') );
291             }
292              
293             sub git_command {
294 7     7 0 25 my $self = shift;
295 7         19 my @commands = @_;
296 7         23 my $git_bin = $self->git_path;
297 7         10713 return ( $git_bin, @commands );
298             }
299              
300             sub has_access {
301 11     11 0 20 my $self = shift;
302 11         25 my ( $req, $rpc, $check_content_type ) = @_;
303              
304 11 100 100     114 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       78 return if !$rpc;
312 6 100       23 return $self->received_pack if $rpc eq 'receive-pack';
313 5 50       35 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         29 my ( $args, $content_type ) = @_;
320              
321 6         24 my $file = $args->{reqfile};
322 6 50       151 return $self->return_404 unless -e $file;
323              
324 6         109 my @stat = stat $file;
325 6         80 my $res = $args->{req}->new_response(200);
326 6         14568 $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       1143 if ( $stat[7] ) {
337 6         26 $res->header( 'Content-Length' => $stat[7] );
338             }
339 6 50       537 open my $fh, "<:raw", $file
340             or return $self->return_403;
341              
342 6         401 Plack::Util::set_io_path( $fh, Cwd::realpath($file) );
343 6         193 $res->body($fh);
344 6         58 $res->finalize;
345             }
346              
347             sub pkt_flush {
348 2     2 0 16 return '0000';
349             }
350              
351             sub pkt_write {
352 2     2 0 7 my $str = shift;
353 2         26 return sprintf( '%04x', length($str) + 4 ) . $str;
354             }
355              
356             sub return_not_allowed {
357 2     2 0 4 my $self = shift;
358 2         2 my $env = shift;
359 2 100       8 if ( $env->{SERVER_PROTOCOL} eq 'HTTP/1.1' ) {
360             return [
361 1         13 405, [ 'Content-Type' => 'text/plain', 'Content-Length' => 18 ],
362             ['Method Not Allowed']
363             ];
364             }
365             else {
366             return [
367 1         9 400, [ 'Content-Type' => 'text/plain', 'Content-Length' => 11 ],
368             ['Bad Request']
369             ];
370             }
371             }
372              
373             sub return_403 {
374 4     4 0 14 my $self = shift;
375             return [
376 4         28 403, [ 'Content-Type' => 'text/plain', 'Content-Length' => 9 ],
377             ['Forbidden']
378             ];
379             }
380              
381             sub return_400 {
382 1     1 0 10 my $self = shift;
383             return [
384 1         157 400, [ 'Content-Type' => 'text/plain', 'Content-Length' => 11 ],
385             ['Bad Request']
386             ];
387             }
388              
389             sub return_404 {
390 2     2 0 4 my $self = shift;
391             return [
392 2         50 404, [ 'Content-Type' => 'text/plain', 'Content-Length' => 9 ],
393             ['Not Found']
394             ];
395             }
396              
397             1;
398             __END__