NAME

Perl::Critic::Policy::PreferredBinaries - Recommend a perl sub over shelling out to a binary that does the same job

VERSION

version 0.002

SYNOPSIS

With ssh-keygen, dig and curl named in .preferred_binaries.ini -- see "CONFIGURATION" -- each of these is reported, along with what to use instead:

my ( $path, $name, $url );

system( 'ssh-keygen', '-t', 'rsa', '-f', $path );
my $out = `dig +short $name`;
open( my $fh, '-|', 'curl', '-s', $url ) or die "curl: $!";

DESCRIPTION

Perl::Critic::Policy::logicLAB::ProhibitShellDispatch says not to shell out. This says what to do instead, for the binaries somebody has already worked out an answer for -- which is what makes the difference between a rule people suppress and a rule people follow.

It is the shelling-out counterpart of Perl::Critic::Policy::PreferredModules, and deliberately the same shape: an INI file of sections and prefer/reason pairs, so one convention covers both halves of "we already decided this".

Nothing is configured by default. A distribution with no .preferred_binaries.ini gets no violations, because the policy has no opinion of its own about which binaries are worth replacing -- only about recording the ones you have decided.

What it looks at

Anywhere a command reaches a shell or an exec:

  • system and exec as function calls, in list or string form. Not as a method name -- $obj->system(...) -- and not as a hash key.

  • backticks and qx//.

  • a piped open, either '-|' or '|-', in two- or three-argument form.

  • runners, by name: run3, run, capture, capturex, runx and systemx, and any more named in "runners". Called bare, fully qualified (IPC::Run3::run3, IPC::Run::run, IPC::Cmd::run, IPC::System::Simple::capturex) or as a method. The command is the first argument -- an arrayref, as IPC::Run3 and IPC::Run take it, or a list -- or the value of a named command =>, as IPC::Cmd takes it, wherever it falls among the other named arguments. An arrayref of numbers in front of the command, which is how IPC::System::Simple takes the exit values it allows, is passed over. A runner name used as a hash key is not a call and is not read as one.

Capture::Tiny is not on that list. What it captures is a block of perl, not a command, and any shell-out inside the block is one of the above and is reported as that. Its capture shares a name with IPC::System::Simple's, but a block holds no command this can read, so it has nothing to say about one.

In each case it takes the first word of the command, drops any directory in front of it, and looks that up. So /usr/bin/ssh-keygen and ssh-keygen are the same binary, and $ENV{SSH_KEYGEN} is not one it can see -- a name computed at runtime is a name this cannot know, and it says nothing rather than guessing.

Commands on other machines

Runners are matched by name, methods included, because a runner is usually wrapped in a method of the same name and this cannot see what a method does. That cuts one way it is worth knowing about: a method called run or capture that runs its command on another machine -- over ssh, say -- is read as a local runner, and gets advice that makes no sense for it. An in-process module cannot stand in for a program running somewhere else.

That is not something this policy should be concerned with, and the answer is in the method's name rather than in a ## no critic at every call: name it for what it does and keep it off the list above -- run_cmd, run_there, anything that is not on it. A local runner called something else is invisible until it is named in "runners", which is the same rule seen from the other side: the name is the only thing this reads.

Matching a flag as well as a binary

A section name may carry arguments: [ssh-keygen -y] matches only an invocation whose first two words are ssh-keygen and -y. The longest matching section wins, so a bare [ssh-keygen] can name the general answer while [ssh-keygen -y] names the one for reading a public key back.

Only leading words count, and only literal ones. [ssh-keygen -y] does not match ssh-keygen -q -y, because working out whether two argument lists mean the same thing is a job for something that understands the binary.

CONFIGURATION

In .perlcriticrc:

[PreferredBinaries]
config  = ~/.preferred_binaries.ini
runners = run_local

In .preferred_binaries.ini:

[ssh-keygen]
prefer = Provisioner::Utils::write_ssh_keypair
reason = "In-process: no quoting to get wrong, no temp file, and errors you can catch"

[ssh-keygen -y]
prefer = Provisioner::Utils::ssh_pubkey_from_private
reason = "Derives the public half with CryptX"

[wget]
reason = "Nothing here should be fetching anything with this"

[curl]
prefer = HTTP::Tiny
reason = "One HTTP client, and one place redirects and timeouts are decided"

[dig]
prefer = Net::DNS
reason = "Parsing dig output is parsing a UI"

config

Path to the INI file. ~ is expanded. Defaults to .preferred_binaries.ini in the current directory.

Sections are binary names, optionally with leading arguments. Each takes:

  • prefer -- what to use instead. A module name, or a fully qualified sub, or a method call written however your readers will recognise it. Printed verbatim.

  • reason -- why, in a few words. Printed after it.

  • severity -- 1 to 5 for this entry alone.

A section with no prefer is a ban rather than a recommendation, and is reported as one: "Shelling out to 'wget' is not recommended". That is how Perl::Critic::Policy::PreferredModules reads a section with no prefer, and it is worth knowing because consecutive sections look like they share the entry below them and do not:

[wget]
[curl]
prefer = HTTP::Tiny

names one ban and one recommendation, not two recommendations. Config::INI carries nothing forward, so each section needs its own prefer if that is what it means.

runners

More subs to read as runners, separated by whitespace -- a project's own wrapper around system, say. Each is read the way the defaults are: by name, called bare, fully qualified or as a method, with the command as its first argument or its named command =>.

These are added to the defaults -- run3, run, capture, capturex, runx and systemx -- and never replace them, so there is no way to take one of those off the list.

A package in front of a name is dropped: My::Util::run_local is read as run_local, because that is all that is read of a call to it. So it matches Other::run_local and $obj->run_local as well.

BUGS

Please report any bugs or feature requests on the bugtracker website https://github.com/Troglodyne-Internet-Widgets/perl-critic-policy-preferredbinaries/issues

When submitting a bug or request, please include a test-file or a patch to an existing test-file that illustrates the bug or desired feature.

AUTHORS

Current Maintainers:

  • George S. Baugh <george@troglodyne.net>

COPYRIGHT AND LICENSE

Copyright (c) 2026 Troglodyne LLC

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.