Current File : //bin/lintian
#!/usr/bin/perl
#
# Lintian -- Debian package checker
#
# Copyright © 1998 Christian Schwarz and Richard Braakman
# Copyright © 2013 Niels Thykier
# Copyright © 2017-2019 Chris Lamb <lamby@debian.org>
# Copyright © 2020 Felix Lechner
#
# This program is free software.  It is distributed under the terms of
# the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any
# later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, you can find it on the World Wide
# Web at http://www.gnu.org/copyleft/gpl.html, or write to the Free
# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston,
# MA 02110-1301, USA.

use v5.20;
use warnings;
use utf8;

use Cwd qw(realpath);
use File::Basename qw(dirname);

# neither Path::This nor lib::relative are in Debian
use constant THISFILE => realpath __FILE__;
use constant THISDIR => dirname realpath __FILE__;

# use Lintian modules that belong to this program
use lib THISDIR . '/../lib';

# substituted during package build
my $LINTIAN_VERSION = q{2.114.0ubuntu1.5};

use Carp qw(croak confess verbose);
use Config::Tiny;
use Const::Fast;
use File::BaseDir qw(config_files);
use Getopt::Long ();
use IO::Interactive qw(is_interactive);
use List::Compare;
use List::SomeUtils qw(any none first_value);
use Path::Tiny;
use POSIX qw(:sys_wait_h);
use Syntax::Keyword::Try;
use Term::ReadKey;
use Unicode::UTF8 qw(encode_utf8 decode_utf8);

use Lintian::Deb822::Parser qw(parse_dpkg_control_string);
use Lintian::Inspect::Changelog;
use Lintian::IPC::Run3 qw(safe_qx);
use Lintian::Pool;
use Lintian::Processable::Installable;
use Lintian::Processable::Buildinfo;
use Lintian::Processable::Changes;
use Lintian::Processable::Source;
use Lintian::Profile;
use Lintian::Version qw(guess_version);

const my $EMPTY => q{};
const my $SPACE => q{ };
const my $NEWLINE => qq{\n};
const my $COMMA => q{,};
const my $SLASH => q{/};
const my $DOT => q{.};
const my $DOUBLE_DOT => q{..};
const my $PLUS => q{+};
const my $EQUAL => q{=};
const my $HYPHEN => q{-};
const my $OPEN_PIPE => q{-|};

const my $DEFAULT_TAG_LIMIT => 4;
const my $DEFAULT_OUTPUT_WIDTH => 80;

# place early, may need original environment to determine terminal blacklist
my $hyperlinks_capable = is_interactive;

# Globally ignore SIGPIPE.  We'd rather deal with error returns from write
# than randomly delivered signals.
$SIG{PIPE} = 'IGNORE';

my $TERMINAL_WIDTH;
($TERMINAL_WIDTH, undef, undef, undef) = GetTerminalSize()
  if is_interactive;
$TERMINAL_WIDTH //= $DEFAULT_OUTPUT_WIDTH;

my %PRESERVE_ENV = map { $_ => 1 } qw(
  DEB_VENDOR
  DEBRELEASE_DEBS_DIR
  HOME
  LANG
  LC_ALL
  LC_MESSAGES
  PATH
  TMPDIR
  XDG_CACHE_HOME
  XDG_CONFIG_DIRS
  XDG_CONFIG_HOME
  XDG_DATA_DIRS
  XDG_DATA_HOME
);

my @disallowed= grep { !exists $PRESERVE_ENV{$_} && !/^LINTIAN_/ } keys %ENV;

delete $ENV{$_} for @disallowed;

# PATH may be unset in some environments; use sane default
$ENV{PATH} //= '/bin:/usr/bin';

# needed for tar
$ENV{LC_ALL} = 'C';
$ENV{TZ} = $EMPTY;

$ENV{LINTIAN_BASE} = realpath(THISDIR . '/..')
  // die encode_utf8('Cannot resolve LINTIAN_BASE');

$ENV{LINTIAN_VERSION} = $LINTIAN_VERSION // guess_version($ENV{LINTIAN_BASE});
die encode_utf8('Unable to determine the version automatically!?')
  unless length $ENV{LINTIAN_VERSION};

if (my $coverage_arg = $ENV{LINTIAN_COVERAGE}) {
    my $p5opt = $ENV{PERL5OPT} // $EMPTY;
    $p5opt .= $SPACE unless $p5opt eq $EMPTY;
    $ENV{PERL5OPT} = "${p5opt} ${coverage_arg}";
}

my @getoptions = qw(
  allow-root
  cfg=s
  check|c
  check-part|C=s@
  color=s
  debug|d+
  default-display-level
  display-experimental|E!
  display-level|L=s@
  display-info|I
  display-source=s@
  dont-check-part|X=s@
  exp-output:s
  fail-on=s@
  ftp-master-rejects|F
  help|h
  hide-overrides
  hyperlinks=s
  ignore-lintian-env
  include-dir=s@
  info|i
  jobs|j=i
  no-cfg
  no-override|o
  no-tag-display-limit
  output-width=i
  packages-from-file=s
  pedantic
  perf-debug
  print-version
  profile=s
  quiet|q
  show-overrides
  status-log=s
  suppress-tags=s@
  suppress-tags-from-file=s
  tag-display-limit=i
  tags|T=s@
  tags-from-file=s
  user-dirs!
  verbose|v
  version|V
);

my %command_line;

Getopt::Long::Configure('default', 'bundling',
    'no_getopt_compat','no_auto_abbrev','permute');

Getopt::Long::GetOptions(\%command_line, @getoptions)
  or die encode_utf8("error parsing options\n");

my @basenames = map { path($_)->basename } @ARGV;
$0 = join($SPACE, THISFILE, @basenames);

if (exists $command_line{'version'}) {
    say encode_utf8("Lintian v$ENV{LINTIAN_VERSION}");
    exit;
}

if (exists $command_line{'print-version'}) {
    say encode_utf8($ENV{LINTIAN_VERSION});
    exit;
}

show_help()
  if exists $command_line{help};

$command_line{'show-overrides'} = 0
  if exists $command_line{'hide-overrides'};

$command_line{'tag-display-limit'} = 0
  if exists $command_line{'no-tag-display-limit'};

my $LINTIAN_CFG = $command_line{cfg};

$LINTIAN_CFG ||= $ENV{LINTIAN_CFG}
  if length $ENV{LINTIAN_CFG} && -e $ENV{LINTIAN_CFG};

unless ($command_line{'no-user-dirs'}) {

    my @user_configs;

    # XDG user config
    push(@user_configs, config_files('lintian/lintianrc'));

    # legacy per-user config
    push(@user_configs, "$ENV{HOME}/.lintianrc")
      if length $ENV{HOME};

    # system wide user config
    push(@user_configs, '/etc/lintianrc');

    $LINTIAN_CFG ||= first_value { length && -e } @user_configs;
}

$LINTIAN_CFG = $EMPTY
  if $command_line{'no-cfg'};

my %config;

# some environment variables can be set from the config file
my @ENV_FROM_CONFIG = qw(
  TMPDIR
);

if (length $LINTIAN_CFG) {

    # for keys appearing multiple times, now uses the last value
    my $object = Config::Tiny->read($LINTIAN_CFG, 'utf8');
    my $error = Config::Tiny->errstr;
    die encode_utf8(
        "syntax error in configuration file $LINTIAN_CFG: $error\n")
      if length $error;

    # used elsewhere to check for values already set
    %config = %{$object->{_} // {}};

    my @allowed = qw(
      color
      display-experimental
      display-info
      display-level
      hyperlinks
      info
      jobs
      LINTIAN_PROFILE
      override
      pedantic
      profile
      quiet
      show-overrides
      suppress-tags
      suppress-tags-from-file
      tag-display-limit
      TMPDIR
      verbose
    );

    my $knownlc
      = List::Compare->new([keys %config], [@allowed, @ENV_FROM_CONFIG]);
    my @unknown = $knownlc->get_Lonly;
    die encode_utf8(
        "Unknown setting in $LINTIAN_CFG: ". join($SPACE, @unknown). $NEWLINE)
      if @unknown;
}

# substitute home directory
s{\$HOME/}{$ENV{HOME}/}g for values %config;
s{\~/}{$ENV{HOME}/}g for values %config;

# option inverted in config file
$config{'no-override'} = !$config{'no-override'}
  if exists $config{'no-override'};

my @GETOPT_ARRAYS = qw(
  display-level
  suppress-tags
);

# convert some strings to array references
for my $name (@GETOPT_ARRAYS) {
    if (exists $config{$name}) {
        $config{$name} = [$config{$name}];
    } else {
        $config{$name} = [];
    }
}

# Translate boolean strings to "0" or "1"; ignore
# errors as not all values are (intended to be)
# booleans.
my $booleanlc
  = List::Compare->new([keys %config], [qw(jobs tag-display-limit)]);
eval { $config{$_} = parse_boolean($config{$_}); }for $booleanlc->get_Lonly;

# our defaults
my %selected = (
    'check-part'        => [],
    'color'             => 'auto',
    'debug'             => 0,
    'display-level'     => [],
    'display-source'    => [],
    'dont-check-part'   => [],
    'fail-on'           => [],
    'include-dir'       => [],
    'jobs'              => default_jobs(),
    'output-width'      => $TERMINAL_WIDTH,
    'tags'              => [],
    'suppress-tags'     => [],
    'user-dirs'         => 1,
    'verbose'           => 0,
);

$selected{$_} = $config{$_} for keys %config;

my @MUTUAL_OPTIONS = (
    [qw(verbose quiet)],
    [qw(default-display-level display-level display-info pedantic)],
);

# for precedence of command line
for my $exclusive (@MUTUAL_OPTIONS) {

    if (any { defined $command_line{$_} } @{$exclusive}) {
        my @scalars = grep { ref $selected{$_} eq 'SCALAR' } @{$exclusive};
        delete $selected{$_} for @scalars;

        my @arrays = grep { ref $selected{$_} eq 'ARRAY' } @{$exclusive};
        $selected{$_} = [] for @arrays;
    }
}

$selected{$_} = $command_line{$_} for keys %command_line;

@{$selected{'display-level'}}
  = split(/\s*,\s*/, join($COMMA, @{$selected{'display-level'}}));

my @display_level;

push(@display_level,[$EQUAL, '>=', 'warning'])
  if $selected{'default-display-level'};

push(@display_level, [$PLUS, '>=', 'info'])
  if $selected{'display-info'};

push(@display_level, [$PLUS, $EQUAL, 'pedantic'])
  if $selected{'pedantic'};

sub display_classificationtags {
    push(@display_level, [$PLUS, $EQUAL, 'classification']);
    return;
}

for my $level (@{$selected{'display-level'}}) {

    my $operator;
    if ($level =~ s/^([+=-])//) {
        $operator = $1;
    }

    my $relation;
    if ($level =~ s/^([<>]=?|=)//) {
        $relation = $1;
    }

    my $severity = $level;
    $operator //= $EQUAL;
    $relation //= $EQUAL;

    push(@display_level, [$operator, $relation, $severity]);
}

@{$selected{'display-source'}}
  = split(/\s*,\s*/, join($COMMA, @{$selected{'display-source'}}));

@{$selected{'check-part'}}
  = split(/\s*,\s*/, join($COMMA, @{$selected{'check-part'}}));
@{$selected{'dont-check-part'}}
  = split(/\s*,\s*/, join($COMMA, @{$selected{'dont-check-part'}}));

@{$selected{tags}} = split(/\s*,\s*/, join($COMMA, @{$selected{tags}}));
@{$selected{'suppress-tags'}}
  = split(/\s*,\s*/, join($COMMA, @{$selected{'suppress-tags'}}));

if (length $selected{'tags-from-file'}) {

    my @lines = path($selected{'tags-from-file'})->lines_utf8;
    for my $line (@lines) {

        # trim both ends
        $line =~ s/^\s+|\s+$//g;

        next
          unless length $line;
        next
          if $line =~ /^\#/;

        my @activate = split(/\s*,\s*/, $line);
        push(@{$selected{tags}}, @activate);
    }
}

if (length $selected{'suppress-tags-from-file'}) {

    my @lines = path($selected{'suppress-tags-from-file'})->lines_utf8;
    for my $line (@lines) {

        # trim both ends
        $line =~ s/^\s+|\s+$//g;

        next
          unless length $line;
        next
          if $line =~ /^\#/;

        my @suppress = split(/\s*,\s*/, $line);
        push(@{$selected{'suppress-tags'}}, @suppress);
    }
}

my $exit_code = 0;

# root permissions?
# check if effective UID is 0
warn encode_utf8("running with root privileges is not recommended!\n")
  if $> == 0 && !$selected{'allow-root'};

if ($selected{'ignore-lintian-env'}) {
    delete($ENV{$_}) for grep { m/^LINTIAN_/ } keys %ENV;
}

# option --all and packages specified at the same time?
if ($selected{'packages-from-file'} && $#ARGV+1 > 0) {
    warn encode_utf8(
"option --packages-from-file cannot be mixed with package parameters!\n"
    );
    warn encode_utf8("(will ignore --packages-from-file option)\n");

    delete($selected{'packages-from-file'});
}

die encode_utf8("Cannot use profile together with --ftp-master-rejects.\n")
  if $selected{profile} && $selected{'ftp-master-rejects'};
# --ftp-master-rejects is implemented in a profile
$selected{profile} = 'debian/ftp-master-auto-reject'
  if $selected{'ftp-master-rejects'};

@{$selected{'fail-on'}} = split(/,/, join($COMMA, @{$selected{'fail-on'}}));
my @known_fail_on = qw(
  error
  warning
  info
  pedantic
  experimental
  override
  none
);
my $fail_on_lc = List::Compare->new($selected{'fail-on'}, \@known_fail_on);
my @unknown_fail_on = $fail_on_lc->get_Lonly;
die encode_utf8("Unrecognized fail-on argument: @unknown_fail_on\n")
  if @unknown_fail_on;

if (any { $_ eq 'none' } @{$selected{'fail-on'}}) {

    die encode_utf8(
"Cannot combine 'none' with other conditions: @{$selected{'fail-on'}}\n"
    )if @{$selected{'fail-on'}} > 1;

    $selected{'fail-on'} = [];
}

# environment variables override settings in conf file, so load them now
# assuming they were not set by cmd-line options
for my $var (@ENV_FROM_CONFIG) {
# note $selected{$var} will usually always exists due to the call to GetOptions
# so we have to use "defined" here
    $selected{$var} = $ENV{$var} if $ENV{$var} && !defined $selected{$var};
}

my %output
  = map { split(/=/) } split(/,/, ($selected{'exp-output'} // $EMPTY));
$selected{'output-format'} = lc($output{format} // 'ewi');

my $PROFILE = Lintian::Profile->new;

# dies on error
$PROFILE->load(
    $selected{profile},
    $selected{'include-dir'},
    !$command_line{'no-user-dirs'});
say {*STDERR} encode_utf8('Using profile ' . $PROFILE->name . $DOT)
  if $selected{debug};

my $envlc = List::Compare->new([keys %config], \@ENV_FROM_CONFIG);
my @from_file = $envlc->get_intersection;

my @already = grep { defined $ENV{$_} } @from_file;
warn encode_utf8(
        'The environment overrides these settings in the configuration file: '
      . join($SPACE, @already)
      . $NEWLINE)
  if @already;

my @not_yet = grep { !defined $ENV{$_} } @from_file;
if (@not_yet) {
    say {*STDERR}
      encode_utf8('Setting environment variables from configuration file: '
          . join($SPACE, @not_yet))
      if $selected{debug};
}
$ENV{$_} = $config{$_} for @not_yet;

die encode_utf8("The color value must be one of auto, always, or never.\n")
  unless (any { $selected{color} eq $_ } qw(auto always never));

$selected{hyperlinks} //= 'off'
  if $selected{color} eq 'never';

# change to 'on' after gcc's terminal blacklist was implemented here
$selected{hyperlinks} //= 'on';

die encode_utf8("The hyperlink value must be on or off\n")
  unless any { $selected{hyperlinks} eq $_ } qw(on off);

$selected{hyperlinks} = $hyperlinks_capable && $selected{hyperlinks} eq 'on';

$selected{color} = $selected{color} eq 'always'
  || ($selected{color} eq 'auto' && is_interactive);

$selected{verbose} = 0
  if $selected{quiet};

if ($selected{verbose} || !is_interactive) {
    $selected{'tag-display-limit'} //= 0;
} else {
    $selected{'tag-display-limit'} //= $DEFAULT_TAG_LIMIT;
}

if ($selected{debug}) {
    $selected{verbose} = 1;
    $ENV{LINTIAN_DEBUG} = $selected{debug};
    $SIG{__DIE__} = sub {
        confess(map { encode_utf8($_) } @_);
    };
}

# check for arguments
unless (@ARGV || $selected{'packages-from-file'}) {

    my $ok = 0;
    # If debian/changelog exists, assume an implied
    # "../<source>_<version>_<arch>.changes" (or
    # "../<source>_<version>_source.changes").
    if (-e 'debian/changelog') {
        my $file = _find_changes();
        push @ARGV, $file;
        $ok = 1;
    }

    show_help()
      unless $ok;
}

if ($selected{debug}) {
    say {*STDERR} encode_utf8("Lintian v$ENV{LINTIAN_VERSION}");
    say {*STDERR} encode_utf8("Lintian root directory: $ENV{LINTIAN_BASE}");
    say {*STDERR} encode_utf8("Configuration file: $LINTIAN_CFG");
    say {*STDERR} encode_utf8('UTF-8: ✓ (☃)');
}

if (defined $selected{LINTIAN_PROFILE}) {
    warn encode_utf8(
        "Please use 'profile' in config file; LINTIAN_PROFILE is obsolete.\n");
    $selected{profile} //= $selected{LINTIAN_PROFILE};
    delete $selected{LINTIAN_PROFILE};
}

# if tags are listed explicitly (--tags) then show them even if
# they are pedantic/experimental etc.  However, for --check-part
# people explicitly have to pass the relevant options.

if (@{$selected{'check-part'}} || @{$selected{tags}}) {

    $PROFILE->disable_tag($_) for $PROFILE->enabled_tags;

    if (@{$selected{tags}}) {
        $selected{'display-experimental'} = 1;

        # discard current display level; get everything
        @display_level
          = ([$PLUS, '>=', 'pedantic'], [$PLUS, $EQUAL, 'classification']);

        $PROFILE->enable_tag($_) for @{$selected{tags}};

    } else {
        for my $checkname (@{$selected{'check-part'}}) {
            if ($checkname eq 'all') {
                my @tags = map { @{$PROFILE->tagnames_for_check->{$_} // []} }
                  $PROFILE->known_checks;
                $PROFILE->enable_tag($_) for @tags;
                next;
            }

            die encode_utf8("Unrecognized check (via -C): $checkname\n")
              unless exists $PROFILE->check_module_by_name->{$checkname};

            $PROFILE->enable_tag($_)
              for @{$PROFILE->tagnames_for_check->{$checkname} // []};
        }
    }

} elsif (@{$selected{'dont-check-part'}}) {
    # we are disabling checks
    for my $checkname (@{$selected{'dont-check-part'}}) {

        die encode_utf8("Unrecognized check (via -X): $checkname\n")
          unless exists $PROFILE->check_module_by_name->{$checkname};

        $PROFILE->disable_tag($_)
          for @{$PROFILE->tagnames_for_check->{$checkname} // []};
    }
}

# ignore --suppress-tags when used with --tags.
if (@{$selected{'suppress-tags'}} && !@{$selected{tags}}) {
    $PROFILE->disable_tag($_) for @{$selected{'suppress-tags'}};
}

# initialize display level settings; dies on error
$PROFILE->display(@{$_}) for @display_level;

my @subjects;
push(@subjects, @ARGV);

if ($selected{'packages-from-file'}){
    my $fd = open_file_or_fd($selected{'packages-from-file'}, '<');

    while (my $bytes = <$fd>) {
        my $line = decode_utf8($bytes);
        chomp $line;

        next
          if $line =~ /^\s*$/;

        push(@subjects, $line);
    }

    # close unless it is STDIN (else we will see a lot of warnings
    # about STDIN being reopened as "output only")
    close($fd)
      unless fileno($fd) == fileno(STDIN);
}

my $pool = Lintian::Pool->new;

for my $subject (@subjects) {
    die encode_utf8("$subject is not a readable file\n") unless -r $subject;

    # in ubuntu, automatic dbgsym packages end with .ddeb
    die encode_utf8(
"bad package file name $subject (neither .deb, .udeb, .ddeb, .changes, .dsc or .buildinfo file)\n"
    ) unless $subject =~ /\.(?:[u|d]?deb|dsc|changes|buildinfo)$/;

    try {
        # create a new group
        my $group = Lintian::Group->new;
        $group->pooldir($pool->basedir);
        $group->profile($PROFILE);

        my $processable = create_processable_from_file($subject);
        $group->add_processable($processable);

        my $parent = path($subject)->parent->stringify;

        my @files;

        # pull in any additional files
        @files = keys %{$processable->files}
          if $processable->can('files');

        for my $basename (@files) {

            # ignore traversal attempts
            next
              if $basename =~ m{/};

            die encode_utf8("$parent/$basename does not exist, exiting\n")
              unless -e "$parent/$basename";

            # only care about some files; ddeb is ubuntu dbgsym
            next
              unless $basename =~ /\.[ud]?deb$/
              || $basename =~ /\.dsc$/
              || $basename =~ /\.buildinfo$/;

            my $additional = create_processable_from_file("$parent/$basename");
            $group->add_processable($additional);
        }

        $pool->add_group($group);

    } catch {
        warn encode_utf8("Skipping $subject: $@\n");
        $exit_code = 1;
    }
}

$pool->process($PROFILE, \$exit_code, \%selected);

exit $exit_code;

=item create_processable_from_file

=cut

sub create_processable_from_file {
    my ($path) = @_;

    croak encode_utf8("Cannot resolve $path: $!")
      unless -e $path;

    my $processable;

    if ($path =~ /\.dsc$/) {
        $processable = Lintian::Processable::Source->new;

    } elsif ($path =~ /\.buildinfo$/) {
        $processable = Lintian::Processable::Buildinfo->new;

    } elsif ($path =~ /\.d?deb$/) {
        # in ubuntu, automatic dbgsym packages end with .ddeb
        $processable = Lintian::Processable::Installable->new;
        $processable->type('binary');

    } elsif ($path =~ /\.udeb$/) {
        $processable = Lintian::Processable::Installable->new;
        $processable->type('udeb');

    } elsif ($path =~ /\.changes$/) {
        $processable = Lintian::Processable::Changes->new;

    } else {
        croak encode_utf8("$path is not a known type of package");
    }

    $processable->init_from_file($path);

    return $processable;
}

=item parse_boolean (STR)

Attempt to parse STR as a boolean and return its value.
If STR is not a valid/recognised boolean, the sub will
invoke croak.

The following values recognised (string checks are not
case sensitive):

=over 4

=item The integer 0 is considered false

=item Any non-zero integer is considered true

=item "true", "y" and "yes" are considered true

=item "false", "n" and "no" are considered false

=back

=cut

sub parse_boolean {
    my ($str) = @_;

    return $str == 0 ? 0 : 1
      if $str =~ /^-?\d++$/;

    $str = lc $str;

    return 1
      if $str eq 'true' || $str =~ m/^y(?:es)?$/;

    return 0
      if $str eq 'false' || $str =~ m/^no?$/;

    croak encode_utf8("'$str' is not a valid boolean value");
}

sub _find_changes {
    # read bytes to side-step any encoding errors
    my $contents = path('debian/changelog')->slurp;
    my $changelog = Lintian::Inspect::Changelog->new;
    $changelog->parse($contents);
    my @entries = @{$changelog->entries};
    my $latest = @entries ? $entries[0] : undef;
    my ($source, $version);
    my $changes;
    my @archs;
    my @dirs = ($DOUBLE_DOT, '../build-area', '/var/cache/pbuilder/result');

    unshift(@dirs, $ENV{DEBRELEASE_DEBS_DIR})
      if exists $ENV{DEBRELEASE_DEBS_DIR};

    if (not $latest) {
        my @errors = @{$changelog->errors};
        if (@errors) {
            warn encode_utf8("Cannot parse debian/changelog due to errors:\n");
            for my $error (@errors) {
                warn encode_utf8("$error->[2] (line $error->[1])\n");
            }
        } else {
            warn encode_utf8("debian/changelog does not have any data?\n");
        }
        exit 1;
    }
    $version = $latest->Version;
    $source = $latest->Source;
    unless (defined $version && defined $source) {
        $version //= '<N/A>';
        $source //= '<N/A>';
        warn encode_utf8(
            "Cannot determine source and version from debian/changelog:\n");
        warn encode_utf8("Source: $source\n");
        warn encode_utf8("Version: $source\n");
        exit 1;
    }
    # remove the epoch
    $version =~ s/^\d+://;
    if (exists $ENV{DEB_BUILD_ARCH}) {
        push(@archs, decode_utf8($ENV{DEB_BUILD_ARCH}));
    } else {
        my $arch = decode_utf8(safe_qx('dpkg', '--print-architecture'));
        chomp $arch;
        push(@archs, $arch) if length $arch;
    }
    push(@archs, decode_utf8($ENV{DEB_HOST_ARCH}))
      if exists $ENV{DEB_HOST_ARCH};
    # Maybe cross-built for something dpkg knows about...
    my @command = qw{dpkg --print-foreign-architectures};
    open(my $foreign, $OPEN_PIPE, @command)
      or die encode_utf8("Cannot open pipe to @command");

    while (my $bytes = <$foreign>) {
        my $line = decode_utf8($bytes);
        chomp($line);
        # Skip already attempted architectures (e.g. via DEB_BUILD_ARCH)
        next
          if any { $_ eq $line } @archs;
        push(@archs, $line);
    }
    close($foreign);
    push @archs, qw(multi all source);
    for my $dir (@dirs) {
        for my $arch (@archs) {
            $changes = "$dir/${source}_${version}_${arch}.changes";
            return $changes if -e $changes;
        }
    }

    warn encode_utf8(
"Cannot find a changes file for ${source}/${version}. It would be named like:\n"
    );

    warn encode_utf8("    ${source}_${version}_${_}.changes\n") for @archs;

    warn encode_utf8(" in any of those places:\n");
    warn encode_utf8("    $_\n") for @dirs;

    exit 0;
}

=item open_file_or_fd

=cut

# open_file_or_fd(TO_OPEN, MODE)
#
# Open a given file or FD based on TO_OPEN and MODE and returns the
# open handle.  Will croak / throw a trappable error on failure.
#
# MODE can be one of "<" (read) or ">" (write).
#
# TO_OPEN is one of:
#  * "-", alias of "&0" or "&1" depending on MODE
#  * "&N", reads/writes to the file descriptor numbered N
#          based on MODE.
#  * "+FILE" (MODE eq '>' only), open FILE in append mode
#  * "FILE", open FILE in read or write depending on MODE.
#            Note that this will truncate the file if MODE
#            is ">".
sub open_file_or_fd {
    my ($to_open, $mode) = @_;

    my $fd;
    # autodie trips this for some reasons (possibly fixed
    # in v2.26)
    no autodie qw(open);
    if ($mode eq '<') {
        if ($to_open eq $HYPHEN || $to_open eq '&0') {
            $fd = \*STDIN;
        } elsif ($to_open =~ m/^\&\d+$/) {
            open($fd, '<&=', substr($to_open, 1))
              or die encode_utf8("fdopen $to_open for reading: $!\n");
        } else {
            open($fd, '<', $to_open)
              or die encode_utf8("open $to_open for reading: $!\n");
        }

    } elsif ($mode eq '>') {
        if ($to_open eq $HYPHEN || $to_open eq '&1') {
            $fd = \*STDOUT;
        } elsif ($to_open =~ m/^\&\d+$/) {
            open($fd, '>&=', substr($to_open, 1))
              or die encode_utf8("fdopen $to_open for writing: $!\n");
        } else {
            $mode = ">$mode" if $to_open =~ s/^\+//;
            open($fd, $mode, $to_open)
              or
              die encode_utf8("open $to_open for write/append ($mode): $!\n");
        }

    } else {
        croak encode_utf8("Invalid mode '$mode' for open_file_or_fd");
    }

    return $fd;
}

=item default_jobs

=cut

sub default_jobs {

    my $cpus = decode_utf8(safe_qx('nproc'));

    return 2
      unless $cpus =~ m/^\d+$/;

    # could be 2x
    return $cpus + 1;
}

sub show_help {

    say encode_utf8("Lintian v$ENV{LINTIAN_VERSION}");

    my $message =<<"EOT";
Syntax: lintian [action] [options] [--] [packages] ...
Actions:
    -c, --check               check packages (default action)
    -C X, --check-part X      check only certain aspects
    -F, --ftp-master-rejects  only check for automatic reject tags
    -T X, --tags X            only run checks needed for requested tags
    --tags-from-file X        like --tags, but read list from file
    -X X, --dont-check-part X don't check certain aspects
General options:
    -h, --help                display this help text
    --print-version           print unadorned version number and exit
    -q, --quiet               suppress all informational messages
    -v, --verbose             verbose messages
    -V, --version             display Lintian version and exit
Behavior options:
    --color never/always/auto disable, enable, or enable color for TTY
    --hyperlinks on/off       hyperlinks for TTY (when supported)
    --default-display-level   reset the display level to the default
    --display-source X        restrict displayed tags by source
    -E, --display-experimental display "X:" tags (normally suppressed)
    --no-display-experimental suppress "X:" tags
    --fail-on error,warning,info,pedantic,experimental,override
                              define condition for exit status 2 (default: error)
    -i, --info                give detailed info about tags
    -I, --display-info        display "I:" tags (normally suppressed)
    -L, --display-level       display tags with the specified level
    -o, --no-override         ignore overrides
    --output-width NUM        set output width instead of probing terminal
    --pedantic                display "P:" tags (normally suppressed)
    --profile X               Use the profile X or use vendor X checks
    --show-overrides          output tags that have been overridden
    --hide-overrides          do not output tags that have been overridden (default)
    --suppress-tags T,...     don't show the specified tags
    --suppress-tags-from-file X don't show the tags listed in file X
    --tag-display-limit X     Specify "tag per package" display limit
    --no-tag-display-limit    Disable "tag per package" display limit
                              (equivalent to --tag-display-limit=0)
Configuration options:
    --cfg CONFIGFILE          read CONFIGFILE for configuration
    --no-cfg                  do not read any config files
    --ignore-lintian-env      ignore LINTIAN_* env variables
    --include-dir DIR         include checks, libraries (etc.) from DIR
    -j X, --jobs X            limit the number of parallel unpacking jobs to X
    --[no-]user-dirs          whether to use files from user directories

Some options were omitted. Please check the manual page for the complete list.
EOT

    print encode_utf8($message);

    exit;
}

# Local Variables:
# indent-tabs-mode: nil
# cperl-indent-level: 4
# End:
# vim: syntax=perl sw=4 sts=4 sr et