xref: /openbsd-src/gnu/usr.bin/perl/Porting/core-cpan-diff (revision 3d61058aa5c692477b6d18acfbbdb653a9930ff9)
1#!/usr/bin/env perl
2
3# core-cpan-diff: Compare CPAN modules with their equivalent in core
4
5# Originally based on App::DualLivedDiff by Steffen Mueller.
6
7use strict;
8use warnings;
9
10use 5.010;
11
12use Getopt::Long qw(:config bundling);
13use File::Basename ();
14use File::Copy     ();
15use File::Temp     ();
16use File::Path     ();
17use File::Spec;
18use File::Spec::Functions;
19use IO::Uncompress::Gunzip ();
20use File::Compare          ();
21use ExtUtils::Manifest;
22use ExtUtils::MakeMaker ();
23use HTTP::Tiny;
24
25BEGIN { die "Must be run from root of perl source tree\n" unless -d 'Porting' }
26use lib 'Porting';
27use Maintainers ();
28
29use Archive::Tar;
30use Cwd qw[cwd chdir];
31use IPC::Open3;
32use IO::Select;
33local $Archive::Tar::WARN = 0;
34
35# where, under the cache dir, to download tarballs to
36use constant SRC_DIR => 'tarballs';
37
38# where, under the cache dir, to untar stuff to
39use constant UNTAR_DIR => 'untarred';
40
41use constant DIFF_CMD => 'diff';
42
43sub usage {
44    print STDERR "\n@_\n\n" if @_;
45    print STDERR <<HERE;
46Usage: $0 [opts] [ -d | -v | -x ] [ -a | module ... ]
47
48-a/--all        Scan all dual-life modules.
49
50-c/--cachedir   Where to save downloaded CPAN tarball files
51                (defaults to /tmp/something/ with deletion after each run).
52
53-d/--diff       Display file differences using diff(1), rather than just
54                listing which files have changed.
55
56--diffopts      Options to pass to the diff command. Defaults to '-u --text'
57                (except on *BSD, where it's just '-u').
58
59-f/--force      Force download from CPAN of new 02packages.details.txt file
60                (with --crosscheck only).
61
62-m/--mirror     Preferred CPAN mirror URI (http:// or file:///)
63                (Local mirror must be a complete mirror, not minicpan)
64
65-o/--output     File name to write output to (defaults to STDOUT).
66
67-r/--reverse    Reverses the diff (perl to CPAN).
68
69-u/--upstream   Only print modules with the given upstream (defaults to all)
70
71-v/--verbose    List the fate of *all* files in the tarball, not just those
72                that differ or are missing.
73
74-x/--crosscheck List the distributions whose current CPAN version differs from
75                that in blead (i.e. the DISTRIBUTION field in Maintainers.pl).
76
77By default (i.e. without the --crosscheck option),  for each listed module
78(or with -a, all CPAN modules listed in Maintainers.pl), grab the tarball
79from CPAN associated with that module, and compare the files in it with
80those in the perl source tree.
81
82Must be run from the root of the perl source tree.
83Module names must match the keys of %Modules in Maintainers.pl.
84
85The diff(1) command is assumed to be in your PATH and is used to diff files
86regardless of whether the --diff option has been chosen to display any file
87differences.
88HERE
89    exit(1);
90}
91
92sub run {
93    my $scan_all;
94    my $diff_opts;
95    my $reverse = 0;
96    my @wanted_upstreams;
97    my $cache_dir;
98    my $mirror_url = "http://www.cpan.org/";
99    my $use_diff;
100    my $output_file;
101    my $verbose = 0;
102    my $force;
103    my $do_crosscheck;
104
105    GetOptions(
106        'a|all'         => \$scan_all,
107        'c|cachedir=s'  => \$cache_dir,
108        'd|diff'        => \$use_diff,
109        'diffopts:s'    => \$diff_opts,
110        'f|force'       => \$force,
111        'h|help'        => \&usage,
112        'm|mirror=s'    => \$mirror_url,
113        'o|output=s'    => \$output_file,
114        'r|reverse'     => \$reverse,
115        'u|upstream=s@' => \@wanted_upstreams,
116        'v|verbose:1'   => \$verbose,
117        'x|crosscheck'  => \$do_crosscheck,
118    ) or usage;
119
120    my @modules;
121
122    usage("Cannot mix -a with module list") if $scan_all && @ARGV;
123
124    if ($do_crosscheck) {
125        usage("can't use -r, -d, --diffopts with --crosscheck")
126          if ( $reverse || $use_diff || $diff_opts );
127    }
128    else {
129        #$diff_opts = '-u --text' unless defined $diff_opts;
130        if (! defined $diff_opts) {
131            $diff_opts = ($^O =~ m/bsd$/i) ? '-u' : '-u --text';
132        };
133        usage("can't use -f without --crosscheck") if $force;
134    }
135
136    @modules =
137      $scan_all
138      ? grep $Maintainers::Modules{$_}{CPAN},
139      ( sort { lc $a cmp lc $b } keys %Maintainers::Modules )
140      : @ARGV;
141    usage("No modules specified") unless @modules;
142
143    my $outfh;
144    if ( defined $output_file ) {
145        open $outfh, '>', $output_file
146          or die "ERROR: could not open file '$output_file' for writing: $!\n";
147    }
148    else {
149        open $outfh, ">&STDOUT"
150          or die "ERROR: can't dup STDOUT: $!\n";
151    }
152
153    if ( defined $cache_dir ) {
154        die "ERROR: not a directory: '$cache_dir'\n"
155            if !-d $cache_dir && -e $cache_dir;
156        File::Path::mkpath($cache_dir);
157    }
158    else {
159        $cache_dir = File::Temp::tempdir( CLEANUP => 1 );
160    }
161
162    $mirror_url .= "/" unless substr( $mirror_url, -1 ) eq "/";
163    my $test_file = "modules/03modlist.data.gz";
164    my_getstore(
165        cpan_url( $mirror_url, $test_file ),
166        catfile( $cache_dir, $test_file )
167    ) or die "ERROR: not a CPAN mirror '$mirror_url'\n";
168
169    if ($do_crosscheck) {
170        do_crosscheck(
171            $outfh, $cache_dir, $mirror_url, $verbose,
172            $force, \@modules,  \@wanted_upstreams
173        );
174    }
175    else {
176        $verbose > 2 and $use_diff++;
177        do_compare(
178            \@modules,  $outfh,      $output_file,
179            $cache_dir, $mirror_url, $verbose,
180            $use_diff,  $reverse,    $diff_opts,
181            \@wanted_upstreams
182        );
183    }
184}
185
186# construct a CPAN url
187
188sub cpan_url {
189    my ( $mirror_url, @path ) = @_;
190    return $mirror_url unless @path;
191    my $cpan_path = join( "/", map { split "/", $_ } @path );
192    $cpan_path =~ s{\A/}{};    # remove leading slash since url has one trailing
193    return $mirror_url . $cpan_path;
194}
195
196# construct a CPAN URL for a author/distribution string like:
197# BINGOS/Archive-Extract-0.52.tar.gz
198
199sub cpan_url_distribution {
200    my ( $mirror_url, $distribution ) = @_;
201    $distribution =~ /^([A-Z])([A-Z])/
202        or die "ERROR: invalid DISTRIBUTION name (not /^[A-Z]{2}/): $distribution\n";
203    my $path = "authors/id/$1/$1$2/$distribution";
204    return cpan_url( $mirror_url, $path );
205}
206
207# compare a list of modules against their CPAN equivalents
208
209sub do_compare {
210    my (
211        $modules,    $outfh,   $output_file, $cache_dir,
212        $mirror_url, $verbose, $use_diff,    $reverse,
213        $diff_opts,  $wanted_upstreams
214    ) = @_;
215
216    # first, make sure we have a directory where they can all be untarred,
217    # and if its a permanent directory, clear any previous content
218    my $untar_dir = catdir( $cache_dir, UNTAR_DIR );
219    my $src_dir   = catdir( $cache_dir, SRC_DIR );
220    for my $d ( $src_dir, $untar_dir ) {
221        next if -d $d;
222        mkdir $d or die "mkdir $d: $!\n";
223    }
224
225    my %ignorable = map { ( $_ => 1 ) } @Maintainers::IGNORABLE;
226    my %wanted_upstream = map { ( $_ => 1 ) } @$wanted_upstreams;
227
228    my %seen_dist;
229    for my $module (@$modules) {
230        warn "Processing $module ...\n" if defined $output_file;
231
232        my $m = $Maintainers::Modules{$module}
233          or die "ERROR: No such module in Maintainers.pl: '$module'\n";
234
235        unless ( $m->{CPAN} ) {
236            print $outfh "WARNING: $module is not dual-life; skipping\n";
237            next;
238        }
239
240        my $dist = $m->{DISTRIBUTION};
241        die "ERROR: $module has no DISTRIBUTION entry\n" unless defined $dist;
242
243        if ( $seen_dist{$dist}++ ) {
244            warn "WARNING: duplicate entry for $dist in $module\n";
245        }
246
247        my $upstream = $m->{UPSTREAM} // 'undef';
248        next if @$wanted_upstreams and !$wanted_upstream{$upstream};
249
250        print $outfh "\n$module - "
251          . $Maintainers::Modules{$module}->{DISTRIBUTION} . "\n";
252        print $outfh "  upstream is: "
253          . ( $m->{UPSTREAM} // 'UNKNOWN!' ) . "\n";
254
255        my $cpan_dir;
256        eval {
257            $cpan_dir =
258              get_distribution( $src_dir, $mirror_url, $untar_dir, $module,
259                $dist );
260        };
261        if ($@) {
262            print $outfh "  ", $@;
263            print $outfh "  (skipping)\n";
264            next;
265        }
266
267        my @perl_files = Maintainers::get_module_files($module);
268
269        my $manifest = catfile( $cpan_dir, 'MANIFEST' );
270        die "ERROR: no such file: $manifest\n" unless -f $manifest;
271
272        my $cpan_files = ExtUtils::Manifest::maniread($manifest);
273        my @cpan_files = sort keys %$cpan_files;
274
275        ( my $main_pm = $module ) =~ s{::}{/}g;
276        $main_pm .= ".pm";
277
278        my ( $excluded, $map, $customized ) =
279          get_map( $m, $module, \@perl_files );
280
281        my %perl_unseen;
282        @perl_unseen{@perl_files} = ();
283        my %perl_files = %perl_unseen;
284
285        foreach my $cpan_file (@cpan_files) {
286            my $mapped_file =
287              cpan_to_perl( $excluded, $map, $customized, $cpan_file );
288            unless ( defined $mapped_file ) {
289                print $outfh "  Excluded:  $cpan_file\n" if $verbose;
290                next;
291            }
292
293            if ( exists $perl_files{$mapped_file} ) {
294                delete $perl_unseen{$mapped_file};
295            }
296            else {
297
298                # some CPAN files foo are stored in core as foo.packed,
299                # which are then unpacked by 'make test_prep'
300                my $packed_file = "$mapped_file.packed";
301                if ( exists $perl_files{$packed_file} ) {
302                    if ( !-f $mapped_file and -f $packed_file ) {
303                        print $outfh <<EOF;
304WARNING: $mapped_file not found, but .packed variant exists.
305Perhaps you need to run 'make test_prep'?
306EOF
307                        next;
308                    }
309                    delete $perl_unseen{$packed_file};
310                }
311                else {
312                    if ( $ignorable{$cpan_file} ) {
313                        print $outfh "  Ignored:   $cpan_file\n" if $verbose;
314                        next;
315                    }
316
317                    unless ($use_diff) {
318                        print $outfh "  CPAN only: $cpan_file",
319                          ( $cpan_file eq $mapped_file )
320                          ? "\n"
321                          : " (missing $mapped_file)\n";
322                    }
323                    next;
324                }
325            }
326
327            my $abs_cpan_file = catfile( $cpan_dir, $cpan_file );
328
329            # should never happen
330            die "ERROR: can't find file $abs_cpan_file\n"
331              unless -f $abs_cpan_file;
332
333            # might happen if the FILES entry in Maintainers.pl is wrong
334            unless ( -f $mapped_file ) {
335                print $outfh "WARNING: perl file not found: $mapped_file\n";
336                next;
337            }
338
339            my $relative_mapped_file = relatively_mapped($mapped_file);
340
341            my $different =
342              file_diff( $outfh, $abs_cpan_file, $mapped_file, $reverse,
343                $diff_opts );
344            if ( $different && customized( $m, $relative_mapped_file ) ) {
345		print $outfh "  Customized for blead: $relative_mapped_file\n";
346                if ( $use_diff && $verbose ) {
347                    $different =~ s{^(--- |\+\+\+ )/tmp/[^/]+/}{$1}gm;
348                    print $outfh $different;
349                }
350            }
351            elsif ($different) {
352                if ($use_diff) {
353                    $different =~ s{^(--- |\+\+\+ )/tmp/[^/]+/}{$1}gm;
354                    print $outfh $different;
355                }
356                else {
357                    if ( $cpan_file eq $relative_mapped_file ) {
358                        print $outfh "  Modified:  $relative_mapped_file\n";
359                    }
360                    else {
361                        print $outfh
362                          "  Modified:  $cpan_file $relative_mapped_file\n";
363                    }
364
365                    if ( $cpan_file =~ m{\.pm\z} ) {
366                        my $pv = MM->parse_version($mapped_file)   || 'unknown';
367                        my $cv = MM->parse_version($abs_cpan_file) || 'unknown';
368                        if ( $pv ne $cv ) {
369                            print $outfh
370"  Version mismatch in '$cpan_file':\n    $cv (cpan) vs $pv (perl)\n";
371                        }
372                    }
373
374                }
375            }
376            elsif ( customized( $m, $relative_mapped_file ) ) {
377                # Maintainers.pl says we customized it, but it looks the
378                # same as CPAN so maybe we lost the customization, which
379                # could be bad
380                if ( $cpan_file eq $relative_mapped_file ) {
381                    print $outfh "  Blead customization missing: $cpan_file\n";
382                }
383                else {
384                    print $outfh
385                      "  Blead customization missing: $cpan_file $relative_mapped_file\n";
386                }
387            }
388            elsif ($verbose) {
389                if ( $cpan_file eq $relative_mapped_file ) {
390                    print $outfh "  Unchanged: $cpan_file\n";
391                }
392                else {
393                    print $outfh
394                      "  Unchanged: $cpan_file $relative_mapped_file\n";
395                }
396            }
397        }
398        for ( sort keys %perl_unseen ) {
399            my $relative_mapped_file = relatively_mapped($_);
400            if ( customized( $m, $relative_mapped_file ) ) {
401                print $outfh "  Customized for blead: $_\n";
402            }
403            else {
404                print $outfh "  Perl only: $_\n" unless $use_diff;
405            }
406        }
407        if ( $verbose ) {
408            foreach my $exclude (@$excluded) {
409                my $seen = 0;
410                foreach my $cpan_file (@cpan_files) {
411                    # may be a simple string to match exactly, or a pattern
412                    if ( ref $exclude ) {
413                        $seen = 1 if $cpan_file =~ $exclude;
414                    }
415                    else {
416                        $seen = 1 if $cpan_file eq $exclude;
417                    }
418                    last if $seen;
419                }
420                if ( not $seen ) {
421                    print $outfh "  Unnecessary exclusion: $exclude\n";
422                }
423            }
424        }
425    }
426}
427
428sub relatively_mapped {
429    my $relative = shift;
430    $relative =~ s/^(cpan|dist|ext)\/.*?\///;
431    return $relative;
432}
433
434# given FooBar-1.23_45.tar.gz, return FooBar
435
436sub distro_base {
437    my $d = shift;
438    my $tail_pat = qr/\.(?:tar\.(?:g?z|bz2|Z)|zip|tgz|tbz)/;
439    $d =~ s{-v?([0-9._]+(?:-TRIAL[0-9]*)?)$tail_pat\z}{};
440    return $d;
441}
442
443# process --crosscheck action:
444# ie list all distributions whose CPAN versions differ from that listed in
445# Maintainers.pl
446
447sub do_crosscheck {
448    my (
449        $outfh, $cache_dir, $mirror_url, $verbose,
450        $force, $modules,   $wanted_upstreams,
451    ) = @_;
452
453    my $file         = '02packages.details.txt';
454    my $download_dir = $cache_dir || File::Temp::tempdir( CLEANUP => 1 );
455    my $path         = catfile( $download_dir, $file );
456    my $gzfile       = "$path.gz";
457
458    # grab 02packages.details.txt
459
460    my $url = cpan_url( $mirror_url, "modules/02packages.details.txt.gz" );
461
462    if ( !-f $gzfile or $force ) {
463        unlink $gzfile;
464        my_getstore( $url, $gzfile );
465    }
466    unlink $path;
467    IO::Uncompress::Gunzip::gunzip( $gzfile, $path )
468      or die
469      "ERROR: failed to ungzip $gzfile: $IO::Uncompress::Gunzip::GunzipError\n";
470
471    # suck in the data from it
472
473    open my $fh, '<', $path
474      or die "ERROR: open: $file: $!\n";
475
476    my %distros;
477    my %modules;
478
479    while (<$fh>) {
480        next if 1 .. /^$/;
481        chomp;
482        my @f = split ' ', $_;
483        if ( @f != 3 ) {
484            warn
485              "WARNING: $file:$.: line doesn't have three fields (skipping)\n";
486            next;
487        }
488        my $distro = $f[2];
489        $distro =~ s{^[A-Z]/[A-Z]{2}/}{};    # strip leading A/AB/
490        $modules{ $f[0] } = $distro;
491
492        ( my $short_distro = $distro ) =~ s{^.*/}{};
493
494        $distros{ distro_base($short_distro) }{$distro} = 1;
495    }
496
497    my %wanted_upstream = map { ( $_ => 1 ) } @$wanted_upstreams;
498    for my $module (@$modules) {
499        my $m = $Maintainers::Modules{$module}
500          or die "ERROR: No such module in Maintainers.pl: '$module'\n";
501
502        $verbose and warn "Checking $module\n";
503
504        unless ( $m->{CPAN} ) {
505            print $outfh "\nWARNING: $module is not dual-life; skipping\n";
506            next;
507        }
508
509        # given an entry like
510        #   Foo::Bar 1.23 foo-bar-1.23.tar.gz,
511        # first compare the module name against Foo::Bar, and failing that,
512        # against foo-bar
513
514        my $pdist = $m->{DISTRIBUTION};
515        die "ERROR: $module has no DISTRIBUTION entry\n" unless defined $pdist;
516
517        my $upstream = $m->{UPSTREAM} // 'undef';
518        next if @$wanted_upstreams and !$wanted_upstream{$upstream};
519
520        my $cdist = $modules{$module};
521        ( my $short_pdist = $pdist ) =~ s{^.*/}{};
522
523        unless ( defined $cdist ) {
524            my $d = $distros{ distro_base($short_pdist) };
525            unless ( defined $d ) {
526                print $outfh "\n$module: Can't determine current CPAN entry\n";
527                next;
528            }
529            if ( keys %$d > 1 ) {
530                print $outfh
531                  "\n$module: (found more than one CPAN candidate):\n";
532                print $outfh "    Perl: $pdist\n";
533                print $outfh "    CPAN: $_\n" for sort keys %$d;
534                next;
535            }
536            $cdist = ( keys %$d )[0];
537        }
538
539        if ( $cdist ne $pdist ) {
540            print $outfh "\n$module:\n    Perl: $pdist\n    CPAN: $cdist\n";
541        }
542    }
543}
544
545# get the EXCLUDED and MAP entries for this module, or
546# make up defaults if they don't exist
547
548sub get_map {
549    my ( $m, $module_name, $perl_files ) = @_;
550
551    my ( $excluded, $map, $customized ) = @$m{qw(EXCLUDED MAP CUSTOMIZED)};
552
553    $excluded   ||= [];
554    $customized ||= [];
555
556    return $excluded, $map, $customized if $map;
557
558    # all files under ext/foo-bar (plus maybe some under t/lib)???
559
560    my $ext;
561    for (@$perl_files) {
562        if (m{^((?:ext|dist|cpan)/[^/]+/)}) {
563            if ( defined $ext and $ext ne $1 ) {
564
565                # more than one ext/$ext/
566                undef $ext;
567                last;
568            }
569            $ext = $1;
570        }
571        elsif (m{^t/lib/}) {
572            next;
573        }
574        else {
575            undef $ext;
576            last;
577        }
578    }
579
580    if ( defined $ext ) {
581        $map = { '' => $ext },;
582    }
583    else {
584        ( my $base = $module_name ) =~ s{::}{/}g;
585        $base = "lib/$base";
586        $map  = {
587            'lib/' => 'lib/',
588            ''     => "$base/",
589        };
590    }
591    return $excluded, $map, $customized;
592}
593
594# Given an exclude list and a mapping hash, convert a CPAN filename
595# (eg 't/bar.t') to the equivalent perl filename (eg 'lib/Foo/Bar/t/bar.t').
596# Returns an empty list for an excluded file
597
598sub cpan_to_perl {
599    my ( $excluded, $map, $customized, $cpan_file ) = @_;
600
601    my %customized = map { ( $_ => 1 ) } @$customized;
602    for my $exclude (@$excluded) {
603        next if $customized{$exclude};
604
605        # may be a simple string to match exactly, or a pattern
606        if ( ref $exclude ) {
607            return if $cpan_file =~ $exclude;
608        }
609        else {
610            return if $cpan_file eq $exclude;
611        }
612    }
613
614    my $perl_file = $cpan_file;
615
616    # try longest prefix first, then alphabetically on tie-break
617    for
618      my $prefix ( sort { length($b) <=> length($a) || $a cmp $b } keys %$map )
619    {
620        last if $perl_file =~ s/^\Q$prefix/$map->{$prefix}/;
621    }
622    return $perl_file;
623}
624
625# fetch a file from a URL and store it in a file given by a filename
626
627sub my_getstore {
628    my ( $url, $file ) = @_;
629    File::Path::mkpath( File::Basename::dirname($file) );
630    if ( $url =~ qr{\Afile://(?:localhost)?/} ) {
631        ( my $local_path = $url ) =~ s{\Afile://(?:localhost)?}{};
632        File::Copy::copy( $local_path, $file );
633    } else {
634        my $http = HTTP::Tiny->new;
635        my $response = $http->mirror($url, $file);
636        return $response->{success};
637    }
638}
639
640# download and unpack a distribution
641# Returns the full pathname of the extracted directory
642# (eg '/tmp/XYZ/Foo_bar-1.23')
643
644# cache_dir:  where to download the .tar.gz file to
645# mirror_url: CPAN mirror to download from
646# untar_dir:  where to untar or unzup the file
647# module:     name of module
648# dist:       name of the distribution
649
650sub get_distribution {
651    my ( $src_dir, $mirror_url, $untar_dir, $module, $dist ) = @_;
652
653    $dist =~ m{.+/([^/]+)$}
654      or die
655      "ERROR: $module: invalid DISTRIBUTION name (no AUTHOR/ prefix): $dist\n";
656    my $filename = $1;
657
658    my $download_file = catfile( $src_dir, $filename );
659
660    # download distribution
661
662    if ( -f $download_file and !-s $download_file ) {
663
664        # failed download might leave a zero-length file
665        unlink $download_file;
666    }
667
668    unless ( -f $download_file ) {
669
670        # not cached
671        my $url = cpan_url_distribution( $mirror_url, $dist );
672        my_getstore( $url, $download_file )
673          or die "ERROR: Could not fetch '$url'\n";
674    }
675
676    # get the expected name of the extracted distribution dir
677
678    my $path = catfile( $untar_dir, $filename );
679
680    $path =~ s/\.tar\.gz$//
681      or $path =~ s/\.tgz$//
682      or $path =~ s/\.zip$//
683      or die
684      "ERROR: downloaded file does not have a recognised suffix: $path\n";
685
686    # extract it unless we already have it cached or tarball is newer
687    if ( !-d $path || ( -M $download_file < -M $path ) ) {
688        $path = extract( $download_file, $untar_dir )
689          or die
690          "ERROR: failed to extract distribution '$download_file to temp. dir: "
691          . $! . "\n";
692    }
693
694    die "ERROR: Extracted tarball does not appear as $path\n" unless -d $path;
695
696    return $path;
697}
698
699# produce the diff of a single file
700sub file_diff {
701    my $outfh     = shift;
702    my $cpan_file = shift;
703    my $perl_file = shift;
704    my $reverse   = shift;
705    my $diff_opts = shift;
706
707    my @cmd = ( DIFF_CMD, split ' ', $diff_opts );
708    if ($reverse) {
709        push @cmd, $perl_file, $cpan_file;
710    }
711    else {
712        push @cmd, $cpan_file, $perl_file;
713    }
714    return `@cmd`;
715
716}
717
718sub customized {
719    my ( $module_data, $file ) = @_;
720    return grep { $file eq $_ } @{ $module_data->{CUSTOMIZED} };
721}
722
723sub extract {
724  my ($archive,$to) = @_;
725  my $cwd = cwd();
726  chdir $to or die "$!\n";
727  my @files;
728  EXTRACT: {
729    local $Archive::Tar::CHOWN = 0;
730    my $next;
731    unless ( $next = Archive::Tar->iter( $archive, 1 ) ) {
732       $! = $Archive::Tar::error;
733       last EXTRACT;
734    }
735    while ( my $file = $next->() ) {
736      push @files, $file->full_path;
737      unless ( $file->extract ) {
738        $! = $Archive::Tar::error;
739        last EXTRACT;
740      }
741    }
742  }
743  my $path = __get_extract_dir( \@files );
744  chdir $cwd or die "$!\n";
745  return $path;
746}
747
748sub __get_extract_dir {
749    my $files   = shift || [];
750
751    return unless scalar @$files;
752
753    my($dir1, $dir2);
754    for my $aref ( [ \$dir1, 0 ], [ \$dir2, -1 ] ) {
755        my($dir,$pos) = @$aref;
756
757        ### add a catdir(), so that any trailing slashes get
758        ### take care of (removed)
759        ### also, a catdir() normalises './dir/foo' to 'dir/foo';
760        ### which was the problem in bug #23999
761        my $res = -d $files->[$pos]
762                    ? File::Spec->catdir( $files->[$pos], '' )
763                    : File::Spec->catdir( File::Basename::dirname( $files->[$pos] ) );
764
765        $$dir = $res;
766    }
767
768    ### if the first and last dir don't match, make sure the
769    ### dirname is not set wrongly
770    my $dir;
771
772    ### dirs are the same, so we know for sure what the extract dir is
773    if( $dir1 eq $dir2 ) {
774        $dir = $dir1;
775
776    ### dirs are different.. do they share the base dir?
777    ### if so, use that, if not, fall back to '.'
778    } else {
779        my $base1 = [ File::Spec->splitdir( $dir1 ) ]->[0];
780        my $base2 = [ File::Spec->splitdir( $dir2 ) ]->[0];
781
782        $dir = File::Spec->rel2abs( $base1 eq $base2 ? $base1 : '.' );
783    }
784
785    return File::Spec->rel2abs( $dir );
786}
787
788run();
789
790