File Coverage

blib/lib/CPAN/Mirror/Tiny.pm
Criterion Covered Total %
statement 68 310 21.9
branch 0 130 0.0
condition 0 34 0.0
subroutine 23 54 42.5
pod 7 20 35.0
total 98 548 17.8


line stmt bran cond sub pod time code
1             package CPAN::Mirror::Tiny;
2 1     1   139603 use 5.008001;
  1         5  
3 1     1   6 use strict;
  1         2  
  1         38  
4 1     1   8 use warnings;
  1         1  
  1         82  
5              
6             our $VERSION = '0.33';
7              
8 1     1   688 use CPAN::Meta;
  1         47775  
  1         54  
9 1     1   700 use CPAN::Mirror::Tiny::Archive;
  1         6  
  1         57  
10 1     1   701 use CPAN::Mirror::Tiny::Tempdir;
  1         4  
  1         42  
11 1     1   9 use Cwd ();
  1         2  
  1         21  
12 1     1   5 use Digest::MD5 ();
  1         2  
  1         17  
13 1     1   5 use File::Basename ();
  1         3  
  1         40  
14 1     1   542 use File::Copy ();
  1         4322  
  1         35  
15 1     1   683 use File::Copy::Recursive ();
  1         5662  
  1         36  
16 1     1   8 use File::Path ();
  1         3  
  1         25  
17 1     1   6 use File::Spec::Unix;
  1         2  
  1         46  
18 1     1   7 use File::Spec;
  1         1  
  1         20  
19 1     1   4 use File::Temp ();
  1         13  
  1         21  
20 1     1   5 use File::Which ();
  1         2  
  1         34  
21 1     1   747 use HTTP::Tinyish;
  1         1411  
  1         39  
22 1     1   8 use IPC::Run3 ();
  1         2  
  1         16  
23 1     1   955 use JSON ();
  1         14556  
  1         44  
24 1     1   592 use Parse::LocalDistribution;
  1         64447  
  1         63  
25 1     1   13 use Parse::PMFile;
  1         2  
  1         39  
26              
27 1     1   6 use constant WIN32 => $^O eq 'MSWin32';
  1         2  
  1         5510  
28              
29             my $JSON = JSON->new->canonical(1)->utf8(1);
30             my $CACHE_VERSION = 1;
31              
32             sub run3 {
33 0     0 0   my ($cmd, $outfile) = @_;
34 0           my $out;
35 0 0         IPC::Run3::run3 $cmd, \undef, ($outfile ? $outfile : \$out), \my $err;
36 0           return ($out, $err, $?);
37             }
38              
39             sub new {
40 0     0 1   my ($class, %option) = @_;
41 0 0 0       my $base = $option{base} || $ENV{PERL_CPAN_MIRROR_TINY_BASE} or die "Missing base directory argument";
42 0   0       my $tempdir = $option{tempdir} || File::Temp::tempdir(CLEANUP => 1);
43 0 0         File::Path::mkpath($base) unless -d $base;
44 0           $base = Cwd::abs_path($base);
45 0           my $archive = CPAN::Mirror::Tiny::Archive->new;
46 0           my $http = HTTP::Tinyish->new;
47 0           my $self = bless {
48             base => $base,
49             archive => $archive,
50             http => $http,
51             tempdir => $tempdir,
52             }, $class;
53 0           $self->init_tools;
54             }
55              
56             sub init_tools {
57 0     0 0   my $self = shift;
58 0           for my $cmd (qw(git tar gzip)) {
59 0 0         $self->{$cmd} = File::Which::which($cmd)
60             or die "Couldn't find $cmd; CPAN::Mirror::Tiny needs it";
61             }
62 0           $self;
63             }
64              
65 0     0 0   sub archive { shift->{archive} }
66 0     0 1   sub http { shift->{http} }
67              
68             sub extract {
69 0     0 0   my ($self, $path) = @_;
70 0           $self->archive->unpack($path);
71             }
72              
73             sub base {
74 0     0 1   my $self = shift;
75 0 0         return $self->{base} unless @_;
76 0           File::Spec->catdir($self->{base}, @_);
77             }
78              
79 0     0 1   sub tempdir { CPAN::Mirror::Tiny::Tempdir->new(shift->{tempdir}) }
80 0     0 0   sub pushd_tempdir { CPAN::Mirror::Tiny::Tempdir->pushd(shift->{tempdir}) }
81              
82             sub _author_dir {
83 0     0     my ($self, $author) = @_;
84 0           my ($a2, $a1) = $author =~ /^((.).)/;
85 0           $self->base("authors", "id", $a1, $a2, $author);
86             }
87              
88             sub _locate_tarball {
89 0     0     my ($self, $file, $author) = @_;
90 0           my $dir = $self->_author_dir($author);
91 0 0         File::Path::mkpath($dir) unless -d $dir;
92 0           my $basename = File::Basename::basename($file);
93 0           my $dest = File::Spec->catfile($dir, $basename);
94 0           File::Copy::move($file, $dest);
95 0 0         return -f $dest ? $dest : undef;
96             }
97              
98             sub inject {
99 0     0 1   my ($self, $url, $option) = @_;
100              
101             my $maybe_git = sub {
102 0     0     my $url = shift;
103 0           scalar($url =~ m{\A https?:// (?:github\.com|bitbucket.org) / [^/]+ / [^/]+ \z}x);
104 0           };
105              
106 0 0 0       if ($url =~ s{^file://}{} or -e $url) {
    0 0        
    0          
    0          
107 0           $self->inject_local($url, $option);
108             } elsif ($url =~ /(?:^git|\.git(?:@(.+))?$)/ or $maybe_git->($url)) {
109 0           $self->inject_git($url, $option);
110             } elsif ($url =~ /^cpan:(.+)/) {
111 0           $self->inject_cpan($1, $option);
112             } elsif ($url =~ /^https?:/) {
113 0           $self->inject_http($url, $option);
114             } else {
115 0           die "Unknown url $url\n";
116             }
117             }
118              
119             sub _encode {
120 0     0     my $str = shift;
121 0           $str =~ s/([^a-zA-Z0-9_\-.])/uc sprintf("%%%02x",ord($1))/eg;
  0            
122 0           $str;
123             }
124              
125             sub _cpan_url {
126 0     0     my ($self, $module, $version) = @_;
127 0           my $url = "https://fastapi.metacpan.org/v1/download_url/$module";
128 0 0         $url .= "?version=" . _encode("== $version") if $version;
129 0           my $res = $self->http->get($url);
130 0 0         return (undef, "$res->{status} $res->{reason}, $url") unless $res->{success};
131 0           my $hash = eval { $JSON->decode($res->{content}) };
  0            
132 0 0         if ($@) {
133 0           return (undef, $@);
134             } else {
135 0           return ($hash->{download_url}, undef);
136             }
137             }
138              
139             sub inject_local {
140 0     0 0   my ($self, $arg) = (shift, shift);
141 0 0         if (-f $arg) {
    0          
142 0           return $self->inject_local_file($arg, @_);
143             } elsif (-d $arg) {
144 0           return $self->inject_local_directory($arg, @_);
145             } else {
146 0           die "$arg is neither file nor directory";
147             }
148             }
149              
150             sub inject_local_file {
151 0     0 0   my ($self, $file, $option) = @_;
152 0 0         die "'$file' is not a file" unless -f $file;
153 0 0         die "'$file' must be tarball or zipball" if $file !~ /(?:\.tgz|\.tar\.gz|\.tar\.bz2|\.zip)$/;
154 0           $file = Cwd::abs_path($file);
155 0           my $guard = $self->pushd_tempdir;
156 0           my $dir = $self->extract($file);
157 0           return $self->inject_local_directory($dir, $option);
158             }
159              
160             sub inject_local_directory {
161 0     0 0   my ($self, $dir, $option) = @_;
162 0           my $metafile = File::Spec->catfile($dir, "META.json");
163 0 0         die "Missing META.json in $dir" unless -f $metafile;
164 0           my $meta = CPAN::Meta->load_file($metafile);
165 0           my $distvname = sprintf "%s-%s", $meta->name, $meta->version;
166 0           $dir = Cwd::abs_path($dir);
167 0           my $guard = $self->pushd_tempdir;
168 0 0         File::Path::rmtree($distvname) if -d $distvname;
169 0 0         File::Copy::Recursive::dircopy($dir, $distvname) or die;
170 0           my ($out, $err, $exit) = run3 [$self->{tar}, "czf", "$distvname.tar.gz", $distvname];
171 0 0         die "Failed to create tarball: $err" unless $exit == 0;
172 0   0       my $author = ($option ||= {})->{author} || "VENDOR";
173 0           return $self->_locate_tarball("$distvname.tar.gz", $author);
174             }
175              
176             sub inject_http {
177 0     0 0   my ($self, $url, $option) = @_;
178 0 0         if ($url !~ /(?:\.tgz|\.tar\.gz|\.tar\.bz2|\.zip)$/) {
179 0           die "URL must be tarball or zipball\n";
180             }
181 0           my $basename = File::Basename::basename($url);
182 0           my $tempdir = $self->tempdir;
183 0           my $file = File::Spec->catfile($tempdir->as_string, $basename);
184 0           my $res = $self->http->mirror($url => $file);
185 0 0         if ($res->{success}) {
186 0   0       my $author = ($option ||= {})->{author};
187 0 0         if (!$author) {
188 0 0         if ($url =~ m{/authors/id/./../([^/]+)/}) {
189 0           $author = $1;
190 0           return $self->_locate_tarball($file, $author);
191             } else {
192 0           $author = "VENDOR";
193             }
194             }
195 0           return $self->inject_local_file($file, {author => $author});
196             } else {
197 0           die "Couldn't get $url: $res->{status} $res->{reason}";
198             }
199             }
200              
201             sub inject_cpan {
202 0     0 0   my ($self, $package, $option) = @_;
203 0           $package =~ s/^cpan://;
204 0           my $version = $option->{version};
205 0 0         if ($package =~ s/@(.+)$//) {
206 0   0       $version ||= $1;
207             }
208 0           my ($url, $err) = $self->_cpan_url($package, $version);
209 0 0         die $err if $err;
210 0           $self->inject_http($url, $option);
211             }
212              
213             sub inject_git {
214 0     0 0   my ($self, $url, $option) = @_;
215              
216 0   0       my $ref = ($option ||= {})->{ref};
217 0 0         if ($url =~ /(.*)\@(.*)$/) {
218             # take care of git@github.com:skaji/repo@tag, http://user:pass@example.com/foo@tag
219 0           my ($leading, $remove) = ($1, $2);
220 0           my ($out, $err, $exit) = run3 [$self->{git}, "ls-remote", $leading];
221 0 0         if ($exit == 0) {
222 0           $ref = $remove;
223 0           $url =~ s/\@$remove$//;
224             }
225             }
226              
227 0           my $guard = $self->pushd_tempdir;
228 0           my (undef, $err, $exit) = run3 [$self->{git}, "clone", $url, "."];
229 0 0         die "Couldn't git clone $url: $err" unless $exit == 0;
230 0 0         if ($ref) {
231 0           my (undef, $err, $exit) = run3 [$self->{git}, "checkout", $ref];
232 0 0         die "Couldn't git checkout $ref: $err" unless $exit == 0;
233             }
234 0           my $metafile = "META.json";
235 0 0         die "Couldn't find $metafile in $url" unless -f $metafile;
236 0           my $meta = CPAN::Meta->load_file($metafile);
237 0           my ($rev) = run3 [$self->{git}, "rev-parse", "--short", "HEAD"];
238 0           chomp $rev;
239 0           my $distvname = sprintf "%s-%s-%s", $meta->name, $meta->version, $rev;
240             {
241 0           my $temp = File::Temp->new(SUFFIX => '.tar', EXLOCK => 0);
  0            
242             (undef, $err, $exit)
243 0           = run3 [$self->{git}, "archive", "--format=tar", "--prefix=$distvname/", "HEAD"], $temp->filename;
244 0 0         last if $exit != 0;
245 0           (undef, $err, $exit) = run3 [$self->{gzip}, "--stdout", "--no-name", $temp->filename], "$distvname.tar.gz";
246             }
247 0 0 0       if ($exit == 0 && -f "$distvname.tar.gz") {
248 0   0       my $author = ($option || +{})->{author} || "VENDOR";
249 0           return $self->_locate_tarball("$distvname.tar.gz", $author);
250             } else {
251 0           die "Couldn't archive $url: $err";
252             }
253             }
254              
255             sub _cached {
256 0     0     my ($self, $path, $sub) = @_;
257 0 0         return unless -f $path;
258 0           my $cache_dir = $self->base("modules", ".cache");
259 0 0         File::Path::mkpath($cache_dir) unless -d $cache_dir;
260              
261 0           my $md5 = Digest::MD5->new;
262 0 0         $md5->addfile(do { open my $fh, "<", $path or die; $fh });
  0            
  0            
263 0           my $cache_file = File::Spec->catfile($cache_dir, $md5->hexdigest . ".json");
264              
265 0 0         if (-f $cache_file) {
266 0 0         my $content = do { open my $fh, "<", $cache_file or die; local $/; <$fh> };
  0            
  0            
  0            
267 0           my $cache = $JSON->decode($content);
268 0 0 0       if ( ($cache->{version} || 0) == $CACHE_VERSION ) {
269 0           return $cache->{payload};
270             } else {
271 0           unlink $cache_file;
272             }
273             }
274 0           my $result = $sub->();
275 0 0         if ($result) {
276 0 0         open my $fh, ">", $cache_file or die;
277 0           my $content = {version => $CACHE_VERSION, payload => $result};
278 0           print {$fh} $JSON->encode($content), "\n";
  0            
279 0           close $fh;
280             }
281 0           $result;
282             }
283              
284             sub extract_provides {
285 0     0 0   my ($self, $path) = @_;
286 0           $path = Cwd::abs_path($path);
287 0     0     $self->_cached($path, sub { $self->_extract_provides($path) });
  0            
288             }
289              
290             sub _extract_provides {
291 0     0     my ($self, $path) = @_;
292 0           my $gurad = $self->pushd_tempdir;
293 0 0         my $dir = $self->extract($path) or return;
294 0           my $parser = Parse::LocalDistribution->new({ALLOW_DEV_VERSION => 1});
295 0 0         $parser->parse($dir) || +{};
296             }
297              
298             sub index_path {
299 0     0 0   my ($self, %option) = @_;
300 0           my $file = $self->base("modules", "02packages.details.txt");
301 0 0         $option{compress} ? "$file.gz" : $file;
302             }
303              
304 0     0     sub _win32_path_fix { my $path = shift; $path =~ s{\\}{/}g; $path }
  0            
  0            
305              
306             sub index {
307 0     0 1   my ($self, %option) = @_;
308 0           my $base = $self->base("authors", "id");
309 0 0         return unless -d $base;
310              
311 0           $base = _win32_path_fix($base) if WIN32;
312 0           my @dist;
313             my $wanted = sub {
314 0 0   0     return unless -f;
315 0 0         return unless /(?:\.tgz|\.tar\.gz|\.tar\.bz2|\.zip)$/;
316 0           my $path = $_;
317 0           $path = _win32_path_fix($path) if WIN32;
318 0           push @dist, {
319             path => $path,
320             mtime => (stat $path)[9],
321             relative => File::Spec::Unix->abs2rel($path, $base),
322             };
323 0           };
324 0           File::Find::find({wanted => $wanted, no_chdir => 1}, $base);
325              
326 0           my %packages;
327 0           for my $i (0..$#dist) {
328 0           my $dist = $dist[$i];
329 0 0         if ($option{show_progress}) {
330             warn sprintf "%d/%d examining %s\n",
331 0           $i+1, scalar @dist, $dist->{relative};
332             }
333 0           my $provides = $self->extract_provides($dist->{path});
334 0           $self->_update_packages(\%packages, $provides, $dist->{relative}, $dist->{mtime});
335             }
336              
337 0           my @line;
338 0           for my $package (sort { lc $a cmp lc $b } keys %packages) {
  0            
339 0           my $path = $packages{$package}[1];
340 0           my $version = $packages{$package}[0];
341 0 0         $version = 'undef' unless defined $version;
342 0           push @line, sprintf "%-36s %-8s %s\n", $package, $version, $path;
343             }
344 0           join '', @line;
345             }
346              
347             sub write_index {
348 0     0 1   my ($self, %option) = @_;
349 0           my $file = $self->index_path;
350 0           my $dir = File::Basename::dirname($file);
351 0 0         File::Path::mkpath($dir) unless -d $dir;
352 0 0         open my $fh, ">", "$file.tmp" or die "Couldn't open $file: $!";
353 0           printf {$fh} "Written-By: %s %s\n\n", ref $self, $self->VERSION;
  0            
354 0           print {$fh} $self->index(%option);
  0            
355 0           close $fh;
356 0 0         if ($option{compress}) {
357             my (undef, $err, $exit)
358 0           = run3 [$self->{gzip}, "--stdout", "--no-name", "$file.tmp"], "$file.gz.tmp";
359 0 0         if ($exit == 0) {
360 0 0         rename "$file.gz.tmp", "$file.gz"
361             or die "Couldn't rename $file.gz.tmp to $file.gz: $!";
362 0           unlink "$file.tmp";
363 0           return "$file.gz";
364             } else {
365 0           unlink $_ for "$file.tmp", "$file.gz.tmp";
366 0           return;
367             }
368             } else {
369 0 0         rename "$file.tmp", $file or die "Couldn't rename $file.tmp to $file: $!";
370 0           return $file;
371             }
372             }
373              
374             # Copy from WorePAN: https://github.com/charsbar/worepan/blob/master/lib/WorePAN.pm
375             # Copyright (C) 2012 by Kenichi Ishigaki.
376             # This program is free software; you can redistribute it and/or
377             # modify it under the same terms as Perl itself.
378             sub _update_packages {
379 0     0     my ($self, $packages, $info, $path, $mtime) = @_;
380              
381 0           for my $module (sort keys %$info) {
382 0 0         next unless exists $info->{$module}{version};
383 0           my $new_version = $info->{$module}{version};
384 0 0         if (!$packages->{$module}) { # shortcut
385 0           $packages->{$module} = [$new_version, $path, $mtime];
386 0           next;
387             }
388 0           my $ok = 0;
389 0           my $cur_version = $packages->{$module}[0];
390 0 0         if (Parse::PMFile->_vgt($new_version, $cur_version)) {
    0          
391 0           $ok++;
392             }
393             elsif (Parse::PMFile->_vgt($cur_version, $new_version)) {
394             # lower VERSION number
395             }
396             else {
397 1     1   11 no warnings; # numeric/version
  1         2  
  1         234  
398 0 0 0       if (
      0        
399             $new_version eq 'undef' or $new_version == 0 or
400             Parse::PMFile->_vcmp($new_version, $cur_version) == 0
401             ) {
402 0 0         if ($mtime >= $packages->{$module}[2]) {
403 0           $ok++; # dist is newer
404             }
405             }
406             }
407 0 0         if ($ok) {
408 0           $packages->{$module} = [$new_version, $path, $mtime];
409             }
410             }
411             }
412              
413             1;
414             __END__