App-MechaCPAN

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN


0.32  2026-06-28 12:32:51 EDT
  - Skip CHECKSUMS checks during unrelated tests
  - Improve usage and help messaging
  - Add packed-mechacpan instructions to Synopsis

0.31  2026-06-27 10:42:12 EDT
  - Switch to using only tar for inflating archives, removing shell pipes
  - Clean up the logic around the cpanfile parsing, mainly around conflicts
  - Start doing SHA256 checks on perl and module installations
  - Add support for CHECKSUM checks on modules with the --verify option
  - Update the versoin of Try::Tiny in tests for new perl versions (GH#25)
  - Other small bugfixes

0.30  2023-08-22 22:29:58 EDT
  - Document the new prebuilt binary feature
  - Make the binary download feature not the default for the time being
  - Improve the binary download process not to throw an error when not found
  - Move --build-reusable to a global option --build-reusable-perl

0.29  2023-04-18 23:32:58 EDT

README  view on Meta::CPAN

    Using quiet means that the normal information descriptions are hidden.
    Note that this is not the opposite of verbose, turning both options on
    means no descriptions will be show, but all output from all commands
    will be.

 --no-log

    A log is normally outputted into the local/logs directory. This option
    will prevent a log from being created.

 --verify

    When --verify is given, a verification of the CPAN CHECKSUMS file
    during module install is required. Note that packages from BackPAN
    <https://backpan.perl.org/> will not verify when --verify is given.
    This process includes three steps after downloading the CHECKSUMS file
    from CPAN for the module.

    1. Confirm the shape of CHECKSUMS

      CHECKSUMS files contain all the files and the file checksums for a
      CPAN author's uploads. This file is compared against a rigid
      definition to ensure that it does not appear to be doing anything
      malicious.

README  view on Meta::CPAN

      program. See below for a list of usable external verification
      programs.

    3. Compare the sha256 of the downloaded module to CHECKSUMS

      Once the CHECKSUMS file has been checked, the size, CPAN author path,
      and the sha256 value of the downloaded module archive are compared
      against the values from the CHECKSUMS file. These values must match.

    You can also disable CHECKSUMS verification completely with
    --no-verify. That will prevent all of these steps from running at all.

    When neither option is provided then the signature checking step is
    attempted, but will not produce an error if the external verification
    program could not be found, or the file is from backpan and has no
    corrisponding CHECKSUMS entry. An error is still raised if any other
    parts of the process finds a problem.

    If MetaCPAN <https://metacpan.org> was used to find a module, the
    search will include the SHA256 of the package, which will be checked
    against the downloaded archive. This check cannot be disabled

lib/App/MechaCPAN.pm  view on Meta::CPAN


$loaded_at_compile //= 0;

our @args = (
  'diag-run!',
  'verbose|v!',
  'quiet|q!',
  'no-log!',
  'directory|d=s',
  'build-reusable-perl!',
  'verify!',
  'help|h!',
);

# Timeout when there's no output in seconds
our $TIMEOUT = $ENV{MECHACPAN_TIMEOUT} // 60;
our $VERBOSE;    # Print output from sub commands to STDERR
our $QUIET;      # Do not print any progress to STDERR
our $LOGFH;      # File handle to send the logs to
our $LOG_ON = 1; # Default if to log or not
our $PROJ_DIR;   # The directory given with -d or pwd if not provided

lib/App/MechaCPAN.pm  view on Meta::CPAN

  }

  # Once we've established the project directory, we need to attempt to
  # restart the script.
  &restart_script();

  local $PROJ_DIR = cwd;
  local $LOGFH;
  local $VERBOSE = $options->{verbose} // $VERBOSE;
  local $QUIET   = $options->{quiet}   // $QUIET;
  local $CHKSIGS = $options->{verify}  // $CHKSIGS;

  my $cmd;

  if ( $options->{'build-reusable-perl'} )
  {
    $cmd = 'Perl';
    $options->{'build-reusable'} = delete $options->{'build-reusable-perl'};
    if ( lc $argv[0] eq 'perl' )
    {
      info("Option build-reusable-perl implies the perl command, it can be omitted");

lib/App/MechaCPAN.pm  view on Meta::CPAN


  $expected = uc $expected;
  $actual   = uc $actual;

  die "Expected and Actual Digest differs: $actual ne $expected"
    if $expected ne $actual;

  return;
}

sub _verify_checksums_body
{
  my $chksum_body = shift;

  # CHECKSUMS has a very regular pattern. If it doesn't match, we must refuse
  # loudly since it appears to be tampered with.

  my @result;
  my @content = split /\r?\n/, $chksum_body;

  while ( @content && $content[0] eq '' )

lib/App/MechaCPAN.pm  view on Meta::CPAN

  },
  sub
  {
    return
      if !eval { run(qw/gpg --version/); 1 };

    return sub
    {
      my ( $file, $keyring ) = @_;
      run_qvf( "gpg", "--no-default-keyring", "--keyring", "$keyring",
        "--verify", "$file" );
    };
  },
  sub
  {
    my $out = eval { run(qw/sq --version/) };
    return
      if !defined $out;
    my ($major) = $out =~ m/(\d+)\.\d+(?:\.\d+)?/;
    return
      if !defined $major || $major < 1;

    return sub
    {
      my ( $file, $keyring ) = @_;
      run_qvf( "sq", "verify", "--signer-file", "$keyring", "$file" );
    };
  },
  sub
  {
    return
      if !eval { run(qw/rnp --version/); 1 };

    return sub
    {
      my ( $file, $keyring ) = @_;
      run_qvf( "rnp", "--keyfile", "$keyring", "--verify", "$file" );
    };
  },
);

sub _resolve_verifier
{
  state $verify_fn;

  if ( !defined $verify_fn )
  {
    foreach my $verify (@verifier)
    {
      my $fn = $verify->();
      if ( defined $fn )
      {
        $verify_fn = $fn;
        last;
      }
    }
  }

  return $verify_fn;
}

sub get_cpan_checksums
{
  my $url = shift;
  my $key = shift;

  # If verify is off, don't check any part of the CHECKSUMS
  return
    if defined $CHKSIGS && !$CHKSIGS;

  die "CHECKSUMS URL must be HTTPS, not '$url'"
    unless $url =~ m{\Ahttps://}xmsi;

  die "CHECKSUMS URL must end with CHECKSUMS, not '$url'"
    unless $url =~ m{CHECKSUMS\z}xmsi;

  # Even if verification is optional, not downloading the CHECKSUMS is fatal
  my $checksums = '';
  fetch_file( $url, \$checksums );
  $checksums = App::MechaCPAN::_verify_checksums_body($checksums);

  # If $CHKSIGS is true, we must verify that CHECKSUMS has a valid signature
  # If $CHKSIGS is undef, we will verify that CHECKSUMS has a valid signature
  #   if possible, but won't throw errors if can't.
  # In both cases, the CHECKSUMS is used to validate the checksums of the
  #   downloaded file
  # If $CHKSIGS is otherwise false, we already did an early return above
  my $verify_fn = _resolve_verifier();

  if ( !defined $verify_fn )
  {
    die "Could not find verification program and verification was required"
      if $CHKSIGS;
    logmsg "CHECKSUMS signature not checked: PGP verifier not found";
  }
  else
  {
    my $keyring  = cpan_keyring();
    my $chk_file = humane_tmpfile('CHECKSUMS');

    $chk_file->print($checksums);
    $chk_file->flush;
    $verify_fn->( "$chk_file", $keyring );
    logmsg "VALID: $url";
  }

  my $result = do
  {
    require Safe;
    my $safe = Safe->new("App::MechaCPAN::reval");

    local $@;
    my $chksum = $safe->reval($checksums);

lib/App/MechaCPAN.pm  view on Meta::CPAN

      if $@;

    die "Failed to get HASH from CHECKSUMS"
      if ref $chksum ne 'HASH';

    $chksum;
  };

  # If a module name is passed ($key), then we check if it is listed in the
  # CHECKSUMS file. If it is missing, it depends on $CHKSIGS. If it is true
  # (strict verify), an error will be produced. If it is undef (best-effort)
  # then a log entry will be added, and nothing will be returned, whcih will
  # match the no-verify path above.
  if ( defined $key && !exists $result->{$key} )
  {
    die "Could not find module '$key' in CHECKSUMS"
      if $CHKSIGS;

    logmsg "Could not find '$key' in CHECKSUMS, not attempting to verify";
    return;
  }

  return $result;
}

my @inflate = (

  # System tar
  sub

lib/App/MechaCPAN.pm  view on Meta::CPAN

By default only informational descriptions of what is happening is shown. Turning verbose on will show every command and all output produced by running each command. Note that this is B<not> the opposite of quiet.

=head2 --quiet

Using quiet means that the normal information descriptions are hidden. Note that this is B<not> the opposite of verbose, turning both options on means no descriptions will be show, but all output from all commands will be.

=head2 --no-log

A log is normally outputted into the C<local/logs> directory. This option will prevent a log from being created.

=head2 --verify

When C<--verify> is given, a verification of the CPAN C<CHECKSUMS> file during module install is required. Note that packages from L<BackPAN|https://backpan.perl.org/> will not verify when C<--verify> is given. This process includes three steps after...

=over

=item 1. Confirm the shape of CHECKSUMS

C<CHECKSUMS> files contain all the files and the file checksums for a CPAN author's uploads. This file is compared against a rigid definition to ensure that it does not appear to be doing anything malicious.

=item 2. Confirm the signature of CHECKSUMS

Once the C<CHECKSUMS> structure has been examined, the embedded signature is verified. The C<CHECKSUMS> file is signed using the PAUSE Batch Signing Key (C<2E66 557A B97C 19C7 91AF  8E20 328D A867 450F 89EC>, accessible at L<https://www.cpan.org/modu...

=item 3. Compare the sha256 of the downloaded module to CHECKSUMS

Once the CHECKSUMS file has been checked, the size, CPAN author path, and the C<sha256> value of the downloaded module archive are compared against the values from the CHECKSUMS file. These values must match.

=back

You can also disable C<CHECKSUMS> verification completely with C<--no-verify>. That will prevent all of these steps from running at all.

When neither option is provided then the signature checking step is attempted, but will not produce an error if the external verification program could not be found, or the file is from backpan and has no corrisponding C<CHECKSUMS> entry. An error is...

If L<MetaCPAN|https://metacpan.org> was used to find a module, the search will include the SHA256 of the package, which will be checked against the downloaded archive. This check cannot be disabled currently.

The verification programs that can be used are: L<gpgv|https://www.gnupg.org/>, L<sqv|https://sequoia-pgp.org/>, L<gpg|https://www.gnupg.org/>, L<sq|https://sequoia-pgp.org/>, and L<rnp|https://www.rnpgp.org/>.

=head2 --directory=<path>

Changes to a specified directory before any processing is done. This allows you to specify what directory you want C<local/> to be in. If this isn't provided, the current working directory is used instead.

lib/App/MechaCPAN/Install.pm  view on Meta::CPAN

  }

  my @full_states = (
    'Resolving'     => \&_resolve,
    'Configuring'   => \&_meta,
    'Configuring'   => \&_config_prereq,
    'Configuring'   => \&_configure,
    'Configuring'   => \&_mymeta,
    'Prerequisites' => \&_prereq,
    'Prerequisites' => \&_test_prereq,
    'Prerequisites' => \&_prereq_verify,
    'Building'      => \&_build,
    'Testing'       => \&_test,
    'Installing'    => \&_install,
    'Installed'     => \&_write_meta,
  );

  my @states     = grep { ref $_ eq 'CODE' } @full_states;
  my @state_desc = grep { ref $_ ne 'CODE' } @full_states;

  @targets

lib/App/MechaCPAN/Install.pm  view on Meta::CPAN


  if ( !$skip_tests )
  {
    @deps = map { _phase_prereq( $target, $cache, $_ ) } qw/test/;
    push @{ $target->{prereq} }, @deps;
  }

  return @deps, $target;
}

sub _prereq_verify
{
  my $target = shift;
  my $cache  = shift;

  my @deps = _target_prereqs( $target, $cache );
  my @incomplete_deps = grep { $_->{state} ne $COMPLETE } @deps;

  if ( @incomplete_deps > 0 )
  {
    my $line = 'Unmet dependencies for: ' . $target->{src_name};

t/09_checksums.t  view on Meta::CPAN

    "Hash: SHA256\n",
    "\n",
    $payload,
    "-----BEGIN PGP SIGNATURE-----\n",
    "\n",
    "iQEcBAEBCAAGBQJfFAKEMINOTAREALSIGNATUREBASE64==\n",
    "=AAAA\n",
    "-----END PGP SIGNATURE-----\n";
}

sub run_verify_for_success
{
  my $body = shift;

  local $@;
  my $result = eval { App::MechaCPAN::_verify_checksums_body($body) };
  my $err    = $@;

  isnt( $result, '', 'run_verify_for_success did produce a result' );
  is( $err, '', 'run_verify_for_success did not have an error' );

  diag($err)
    if $err;

  return $result;
}

sub run_verify_for_error
{
  my $body = shift;

  local $@;
  my $result = eval { App::MechaCPAN::_verify_checksums_body($body) };
  my $err    = $@;

  is( $result, undef, 'run_verify_for_error did not have a result' );
  isnt( $err, undef, 'run_verify_for_error did have an error' );

  return $err;
}

# happy path: a properly clearsigned CHECKSUMS file
{
  my $text   = clearsign($basic_dd);
  my $result = run_verify_for_success($text);
  isnt( $result, undef, 'accepts a well-formed clearsigned CHECKSUMS document' );
}

# rejects: empty/undef input
{
  my $err = run_verify_for_error('');
  like( $err, qr/header-line mismatch/, 'rejects empty body' );
  $err = run_verify_for_error(undef);
  like( $err, qr/header-line mismatch/, 'rejects undef body' );
}

# tolerates extra whitespace / trailing newlines around the PGP frame
{
  my $text   = "\n\n" . clearsign($basic_dd) . "\n\n";
  my $result = run_verify_for_success($text);
  isnt( $result, undef, 'tolerates surrounding whitespace' );
}

my $verify = sub {die};

# rejects: not a clearsigned message at all
{
  my $text = $basic_dd;
  my $err  = run_verify_for_error($text);
  like( $err, qr/header-line mismatch/, 'rejects bare body with no perl prologue' );

  $text = "0&&<<''; # this PGP-signed message is also valid perl\n$text";
  $err  = run_verify_for_error($text);
  like( $err, qr/PGP header mismatch/, 'rejects bare body with no PGP wrapper' );
}

# rejects: prologue altered so the heredoc terminator is non-empty
# (a hostile mirror might leave the PGP frame intact but break the
# "also valid perl" invariant, causing a naive eval to fail)
{
  my $text = clearsign($basic_dd);
  $text =~ s/^0\&\&<<''/0\&\&<<'NOPE'/xms;
  my $err = run_verify_for_error($text);
  like( $err, qr/header-line mismatch/, 'rejects tampered prologue with non-empty heredoc terminator' );
}

# rejects: signed-message header present but no signature block
{
  my $text = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" . $basic_dd;
  my $err  = run_verify_for_error($text);
  like( $err, qr/header-line mismatch/, 'rejects truncated CHECKSUMS missing the signature block' );
}

# rejects: signature block present but no signed-message header
{
  my $text = $basic_dd . "-----BEGIN PGP SIGNATURE-----\nVersion: GnuPG v2\n\nAAAA\n-----END PGP SIGNATURE-----\n";
  my $err  = run_verify_for_error($text);
  like( $err, qr/header-line mismatch/, 'rejects truncated CHECKSUMS missing the signature block' );
}

# rejects: payload doesn't define $cksum at all
{
  my $text = clearsign("# nothing interesting here\n__END__\n");
  my $err  = run_verify_for_error($text);
  like( $err, qr/Unexpected perl code/, 'rejects payload with no $cksum assignment' );
}

# rejects: payload defines something other than a hashref
{
  my $text = clearsign("\$cksum = 'not a hashref';\n__END__\n");
  my $err  = run_verify_for_error($text);
  like( $err, qr/Unexpected perl code/, 'rejects payload where $cksum is not a hashref' );
}

# rejects: payload tries to do something dangerous (eval should be sandboxed)
{
  my $bad = <<'EOF';
$cksum = {
  'pwned' => {
  }
};
system("touch /tmp/mechacpan_pwned_$$");
__END__
EOF
  my $text = clearsign($bad);
  my $err  = run_verify_for_error($text);
  like( $err, qr/Unexpected footer/, 'rejects payload where $cksum is not a hashref' );
  is( -e "/tmp/mechacpan_pwned_$$", undef, 'sandboxed payload cannot reach system()' );

  unlink "/tmp/mechacpan_pwned_$$"
    if -e "/tmp/mechacpan_pwned_$$";
}

# tolerates CRLF line endings end-to-end
{
  my $text = clearsign($basic_dd);
  $text =~ s/\n/\r\n/g;
  my $result = run_verify_for_success($text);
  isnt( $result, undef, 'accepts CRLF line endings' );
}

# rejects: hash body lines have no leading indentation at all
{
  my $bad = <<'EOF';
$cksum = {
'NoDeps-1.0.tar.gz' => {
'md5' => '0123456789abcdef0123456789abcdef'
},
};
__END__
EOF
  my $text = clearsign($bad);
  my $err  = run_verify_for_error($text);
  like( $err, qr/missing indentation/, 'rejects hash body with no indentation' );
}

# rejects: body-loop must reject any line shape other than the three
# accepted forms (sub-hash opener, key/value pair, close-brace)
{
  # trailing junk on the closing `};` (anchored ^};$ regression guard)
  my $bad = $basic_dd;
  $bad =~ s/^};$/};garbage/m;
  my $err = run_verify_for_error( clearsign($bad) );
  like( $err, qr/Unexpected perl code/, 'rejects trailing text on hash-close line' );

  # stray comment in the body (intro loop allows `#`, body loop must not)
  $bad = $basic_dd;
  $bad =~ s/^(\s+'NoDeps-1\.0\.meta')/  # stray comment\n$1/m;
  $err = run_verify_for_error( clearsign($bad) );
  like( $err, qr/Unexpected perl code/, 'rejects comments inside hash body' );

  # three-level nesting (a value is `{` instead of a scalar)
  $bad = <<'EOF';
$cksum = {
  'NoDeps-1.0.tar.gz' => {
    'sub' => {
      'md5' => '0123456789abcdef0123456789abcdef'
    }
  },
};
__END__
EOF
  $err = run_verify_for_error( clearsign($bad) );
  like( $err, qr/Unexpected perl code/, 'rejects three-level nested hash' );
}

# rejects: key must be a single-quoted simple identifier
{
  my $bad = $basic_dd;
  $bad =~ s/'NoDeps-1\.0\.tar\.gz'/"NoDeps-1.0.tar.gz"/;
  my $err = run_verify_for_error( clearsign($bad) );
  like( $err, qr/Unexpected perl code/, 'rejects double-quoted key' );

  $bad = $basic_dd;
  $bad =~ s/'NoDeps-1\.0\.tar\.gz'/42/;
  $err = run_verify_for_error( clearsign($bad) );
  like( $err, qr/Unexpected perl code/, 'rejects unquoted numeric key' );
}

# rejects: one-line empty hash (`$cksum = {};` not on its own opener line)
{
  my $text = clearsign("\$cksum = {};\n__END__\n");
  my $err  = run_verify_for_error($text);
  like( $err, qr/Unexpected perl code/, 'rejects one-line empty hash' );
}

# rejects: empty hash body — `$cksum = {` immediately followed by `};`
# leaves no first-body-line to capture indent from
{
  my $text = clearsign("\$cksum = {\n};\n__END__\n");
  my $err  = run_verify_for_error($text);
  like( $err, qr/missing indentation/, 'rejects empty hash body (no entries)' );
}

# rejects: tampering with the PGP wrapper around an otherwise-valid payload
{
  # truncated signature — no -----END PGP SIGNATURE----- line
  # (regression guard for the $result[-1] vs $content[-1] check)
  my $text = clearsign($basic_dd);
  $text =~ s/^-----END PGP SIGNATURE-----\n//m;
  my $err = run_verify_for_error($text);
  like( $err, qr/missing PGP signature end/, 'rejects truncated signature block' );

  # trailing data after the -----END PGP SIGNATURE----- line
  $text = clearsign($basic_dd) . "extra garbage after the end\n";
  $err  = run_verify_for_error($text);
  like( $err, qr/Unexpected data after end/, 'rejects content after -----END PGP SIGNATURE-----' );

  # wrong PGP signed-message marker
  $text = clearsign($basic_dd);
  $text =~ s/-----BEGIN PGP SIGNED MESSAGE-----/-----BEGIN PGP MESSAGE-----/;
  $err = run_verify_for_error($text);
  like( $err, qr/PGP header mismatch/, 'rejects wrong PGP signed-message marker' );
}

# ============================================================
# get_cpan_checksums — full path, with all network/verifier
# dependencies stubbed out at the package level.
# ============================================================

# rejects: non-HTTPS URLs
{

t/28_cpanfile.t  view on Meta::CPAN

  App::MechaCPAN::main( 'install', { 'skip-perl' => 1 }, ), 0,
  "Can run install"
);
is( cwd, $dir, 'Returned to whence it started' );
is_deeply(
  [ sort @resolvd ], [ sort @pkgs ],
  'All packages were from cpanfile'
);

# parse_cpanfile type-filter behavior. Write a cpanfile that exercises
# every directive type across phases, then verify how each opts shape
# trims the returned hash.
{
  my ( $tfh, $tname ) = tempfile( 'mecha_cpanfile_XXXXXX', DIR => $tmpdir, UNLINK => 1 );
  print $tfh <<'EOF';
requires    'Runtime::Req'      => '1.0';
recommends  'Runtime::Rec'      => '2.0';
suggests    'Runtime::Sug'      => '3.0';
conflicts   'Runtime::Conflict' => '4.0';

on 'configure' => sub



( run in 0.554 second using v1.01-cache-2.11-cpan-85f18b9d64f )