App-MechaCPAN
view release on metacpan or search on metacpan
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
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.
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 )