File Coverage

blib/lib/CPAN/Mirror/Tiny.pm
Criterion Covered Total %
statement 68 410 16.5
branch 0 130 0.0
condition 0 34 0.0
subroutine 23 54 42.5
pod 7 20 35.0
total 98 648 15.1


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