diff options
Diffstat (limited to 'lib/Lintian/Check/InitD.pm')
-rw-r--r-- | lib/Lintian/Check/InitD.pm | 733 |
1 files changed, 733 insertions, 0 deletions
diff --git a/lib/Lintian/Check/InitD.pm b/lib/Lintian/Check/InitD.pm new file mode 100644 index 0000000..304c186 --- /dev/null +++ b/lib/Lintian/Check/InitD.pm @@ -0,0 +1,733 @@ +# init.d -- lintian check script -*- perl -*- + +# Copyright (C) 1998 Christian Schwarz +# Copyright (C) 2016-2020 Chris Lamb <lamby@debian.org> +# Copyright (C) 2021 Felix Lechner +# +# This program is free software; you can redistribute it and/or modify +# it 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 https://www.gnu.org/copyleft/gpl.html, or write to the Free +# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, +# MA 02110-1301, USA. + +package Lintian::Check::InitD; + +use v5.20; +use warnings; +use utf8; + +use Const::Fast; +use File::Basename qw(dirname); +use List::Compare; +use List::SomeUtils qw(any none); +use Unicode::UTF8 qw(encode_utf8); + +const my $EMPTY => q{}; +const my $SPACE => q{ }; +const my $DOLLAR => q{$}; + +const my $RUN_LEVEL_6 => 6; + +use Moo; +use namespace::clean; + +with 'Lintian::Check'; + +# A list of valid LSB keywords. The value is 0 if optional and 1 if required. +my %LSB_KEYWORDS = ( + provides => 1, + 'required-start' => 1, + 'required-stop' => 1, + 'should-start' => 0, + 'should-stop' => 0, + 'default-start' => 1, + 'default-stop' => 1, + # These two are actually optional, but we mark + # them as required and give them a weaker tag if + # they are missing. + 'short-description' => 1, + 'description' => 1 +); + +# These init script names should probably not be used in dependencies. +# Instead, the corresponding virtual facility should be used. +# +# checkroot is not included here since cryptsetup needs the root file system +# mounted but not any other local file systems and therefore correctly depends +# on checkroot. There may be other similar situations. +my %implied_dependencies = ( + 'mountall' => $DOLLAR . 'local_fs', + 'mountnfs' => $DOLLAR . 'remote_fs', + + 'hwclock' => $DOLLAR . 'time', + 'portmap' => $DOLLAR . 'portmap', + 'named' => $DOLLAR . 'named', + 'bind9' => $DOLLAR . 'named', + 'networking' => $DOLLAR . 'network', + 'syslog' => $DOLLAR . 'syslog', + 'rsyslog' => $DOLLAR . 'syslog', + 'sysklogd' => $DOLLAR . 'syslog' +); + +# Regex to match names of init.d scripts; it is a bit more lax than +# package names (e.g. allows "_"). We do not allow it to start with a +# "dash" to avoid confusing it with a command-line option (also, +# update-rc.d does not allow this). +our $INITD_NAME_REGEX = qr/[\w\.\+][\w\-\.\+]*/; + +my $OPTS_R = qr/-\S+\s*/; +my $ACTION_R = qr/\w+/; +my $EXCLUDE_R = qr/if\s+\[\s+-x\s+\S*update-rc\.d/; + +sub installable { + my ($self) = @_; + + my $pkg = $self->processable->name; + my $processable = $self->processable; + + my $initd_dir = $processable->installed->resolve_path('etc/init.d/'); + my $postinst = $processable->control->lookup('postinst'); + my $preinst = $processable->control->lookup('preinst'); + my $postrm = $processable->control->lookup('postrm'); + my $prerm = $processable->control->lookup('prerm'); + + my (%initd_postinst, %initd_postrm); + + # These will never be regular initscripts. (see #918459, #933383 + # and #941140 etc.) + return + if $pkg eq 'initscripts' + || $pkg eq 'sysvinit'; + + # read postinst control file + if ($postinst and $postinst->is_file and $postinst->is_open_ok) { + + open(my $fd, '<', $postinst->unpacked_path) + or die encode_utf8('Cannot open ' . $postinst->unpacked_path); + + my $position = 1; + while (my $line = <$fd>) { + + next + if $line =~ /$EXCLUDE_R/; + + $line =~ s/\#.*$//; + + next + unless $line =~ m{^(?:.+;|^\s*system[\s\(\']+)?\s*update-rc\.d\s+ + (?:$OPTS_R)*($INITD_NAME_REGEX)\s+($ACTION_R)}x; + + my ($name,$opt) = ($1,$2); + next + if $opt eq 'remove'; + + my $pointer = $postinst->pointer($position); + + if ($initd_postinst{$name}++ == 1) { + + $self->pointed_hint('duplicate-updaterc.d-calls-in-postinst', + $pointer, $name); + next; + } + + $self->pointed_hint( + 'output-of-updaterc.d-not-redirected-to-dev-null', + $pointer, $name) + unless $line =~ m{>\s*/dev/null}; + + } continue { + ++$position; + } + + close $fd; + } + + # read preinst control file + if ($preinst and $preinst->is_file and $preinst->is_open_ok) { + + open(my $fd, '<', $preinst->unpacked_path) + or die encode_utf8('Cannot open ' . $preinst->unpacked_path); + + my $position = 1; + while (my $line = <$fd>) { + + next + if $line =~ /$EXCLUDE_R/; + + $line =~ s/\#.*$//; + next + unless $line =~ m{update-rc\.d \s+ + (?:$OPTS_R)*($INITD_NAME_REGEX) \s+ + ($ACTION_R)}x; + + my $name = $1; + my $option = $2; + next + if $option eq 'remove'; + + my $pointer = $preinst->pointer($position); + + $self->pointed_hint('preinst-calls-updaterc.d', + $pointer, $name, $option); + + } continue { + ++$position; + } + + close $fd; + } + + # read postrm control file + if ($postrm and $postrm->is_file and $postrm->is_open_ok) { + + open(my $fd, '<', $postrm->unpacked_path) + or die encode_utf8('Cannot open ' . $postrm->unpacked_path); + + my $position = 1; + while (my $line = <$fd>) { + + next + if $line =~ /$EXCLUDE_R/; + + $line =~ s/\#.*$//; + + next + unless $line =~ /update-rc\.d\s+(?:$OPTS_R)*($INITD_NAME_REGEX)/; + + my $name = $1; + + my $pointer = $postrm->pointer($position); + + if ($initd_postrm{$name}++ == 1) { + + $self->pointed_hint('duplicate-updaterc.d-calls-in-postrm', + $pointer, $name); + next; + } + + $self->pointed_hint( + 'output-of-updaterc.d-not-redirected-to-dev-null', + $pointer, $name) + unless $line =~ m{>\s*/dev/null}; + + } continue { + ++$position; + } + + close $fd; + } + + # read prerm control file + if ($prerm and $prerm->is_file and $prerm->is_open_ok) { + + open(my $fd, '<', $prerm->unpacked_path) + or die encode_utf8('Cannot open ' . $prerm->unpacked_path); + + my $position = 1; + while (my $line = <$fd>) { + + next + if $line =~ /$EXCLUDE_R/; + + $line =~ s/\#.*$//; + + next + unless $line =~ /update-rc\.d\s+(?:$OPTS_R)*($INITD_NAME_REGEX)/; + + my $name = $1; + + my $pointer = $prerm->pointer($position); + + $self->pointed_hint('prerm-calls-updaterc.d', $pointer, $name); + + } continue { + ++$position; + } + + close $fd; + } + + # init.d scripts have to be removed in postrm + for (keys %initd_postinst) { + if ($initd_postrm{$_}) { + delete $initd_postrm{$_}; + } else { + + $self->pointed_hint( + 'postrm-does-not-call-updaterc.d-for-init.d-script', + $postrm->pointer, "etc/init.d/$_"); + } + } + + for (keys %initd_postrm) { + $self->pointed_hint('postrm-contains-additional-updaterc.d-calls', + $postrm->pointer, "etc/init.d/$_"); + } + + for my $initd_file (keys %initd_postinst) { + + my $item; + $item = $initd_dir->child($initd_file) + if $initd_dir; + + unless ( + (defined $item && $item->resolve_path) + ||( defined $item + && $item->is_symlink + && $item->link eq '/lib/init/upstart-job') + ) { + + $self->hint('init.d-script-not-included-in-package', + "etc/init.d/$initd_file"); + + next; + } + + # init.d scripts have to be marked as conffiles unless they're + # symlinks. + $self->hint('init.d-script-not-marked-as-conffile', + "etc/init.d/$initd_file") + if !defined $item + || ( !$processable->declared_conffiles->is_known($item->name) + && !$item->is_symlink); + + # Check if file exists in package and check the script for + # other issues if it was included in the package. + $self->check_init($item); + } + $self->check_defaults; + + return + unless defined $initd_dir && $initd_dir->is_dir; + + # files actually installed in /etc/init.d should match our list :-) + for my $script ($initd_dir->children) { + + next + if !$script->is_dir + && (any {$script->basename eq $_}qw(README skeleton rc rcS)); + + my $tag_name = 'script-in-etc-init.d-not-registered-via-update-rc.d'; + + # In an upstart system, such as Ubuntu, init scripts are symlinks to + # upstart-job which are not registered with update-rc.d. + $tag_name= 'upstart-job-in-etc-init.d-not-registered-via-update-rc.d' + if $script->is_symlink + && $script->link eq '/lib/init/upstart-job'; + + # If $initd_postinst is true for this script, we already + # checked the syntax in the above loop. Check the syntax of + # unregistered scripts so that we get more complete Lintian + # coverage in the first pass. + unless ($initd_postinst{$script->basename}) { + + $self->pointed_hint($tag_name, $script->pointer); + $self->check_init($script); + } + } + + return; +} + +sub check_init { + my ($self, $item) = @_; + + my $processable = $self->processable; + + # In an upstart system, such as Ubuntu, init scripts are symlinks to + # upstart-job. It doesn't make sense to check the syntax of upstart-job, + # so skip the checks of the init script itself in that case. + return + if $item->is_symlink + && $item->link eq '/lib/init/upstart-job'; + + return + unless $item->is_open_ok; + + my %saw_command; + my %value_by_lsb_keyword; + my $in_file_test = 0; + my $needs_fs = 0; + + if ($item->interpreter eq '/lib/init/init-d-script') { + $saw_command{$_} = 1 for qw{start stop restart force-reload status}; + } + + $self->pointed_hint('init.d-script-uses-usr-interpreter', + $item->pointer(1), $item->interpreter) + if $item->interpreter =~ m{^ /usr/ }x; + + open(my $fd, '<', $item->unpacked_path) + or die encode_utf8('Cannot open ' . $item->unpacked_path); + + my $position = 1; + while (my $line = <$fd>) { + + $self->pointed_hint('init.d-script-contains-skeleton-template-content', + $item->pointer($position)) + if $line =~ m{Please remove the "Author" lines|Example initscript}; + + if ($line =~ m/^\#\#\# BEGIN INIT INFO/) { + + if (defined $value_by_lsb_keyword{BEGIN}) { + + $self->pointed_hint('init.d-script-has-duplicate-lsb-section', + $item->pointer($position)); + next; + } + + $value_by_lsb_keyword{BEGIN} = [1]; + my $final; + + # We have an LSB keyword section. Parse it and save the data + # in %value_by_lsb_keyword for analysis. + while (my $other_line = <$fd>) { + + # nested while + ++$position; + + if ($other_line =~ /^\#\#\# END INIT INFO/) { + $value_by_lsb_keyword{END} = [1]; + last; + + } elsif ($other_line !~ /^\#/) { + $self->pointed_hint( + 'init.d-script-has-unterminated-lsb-section', + $item->pointer($position)); + last; + + } elsif ($other_line =~ /^\# ([a-zA-Z-]+):\s*(.*?)\s*$/) { + + my $keyword = lc $1; + my $value = $2 // $EMPTY; + + $self->pointed_hint( + 'init.d-script-has-duplicate-lsb-keyword', + $item->pointer($position), $keyword) + if defined $value_by_lsb_keyword{$keyword}; + + $self->pointed_hint( + 'init.d-script-has-unknown-lsb-keyword', + $item->pointer($position), $keyword) + unless exists $LSB_KEYWORDS{$keyword} + || $keyword =~ /^x-/; + + $value_by_lsb_keyword{$keyword} = [split($SPACE, $value)]; + $final = $keyword; + + } elsif ($other_line =~ /^\#(\t| )/ + && $final eq 'description') { + + my $value = $other_line; + $value =~ s/^\#\s*//; + $value_by_lsb_keyword{description} .= $SPACE . $value; + + } else { + $self->pointed_hint('init.d-script-has-bad-lsb-line', + $item->pointer($position)); + } + } + } + + # Pretty dummy way to handle conditionals, but should be enough + # for simple init scripts + $in_file_test = 1 + if $line + =~ m{ \b if \s+ .*? (?:test|\[) (?: \s+ \! )? \s+ - [efr] \s+ }x; + + $in_file_test = 0 + if $line =~ m{ \b fi \b }x; + + if ( !$in_file_test + && $line =~ m{^\s*\.\s+["'"]?(/etc/default/[\$\w/-]+)}){ + my $sourced = $1; + + $self->pointed_hint('init.d-script-sourcing-without-test', + $item->pointer($position), $sourced); + } + + # Some init.d scripts source init-d-script, since (e.g.) + # kFreeBSD does not allow shell scripts as interpreters. + if ($line =~ m{\. /lib/init/init-d-script}) { + $saw_command{$_} = 1 + for qw{start stop restart force-reload status}; + } + + # This should be more sophisticated: ignore heredocs, ignore quoted + # text and the arguments to echo, etc. + $needs_fs = 1 + if $line =~ m{^[^\#]*/var/}; + + while ($line =~ s/^[^\#]*?(start|stop|restart|force-reload|status)//) { + $saw_command{$1} = 1; + } + + # nested while + } continue { + ++$position; + } + + close $fd; + + # Make sure all of the required keywords are present. + if (!defined $value_by_lsb_keyword{BEGIN}) { + $self->pointed_hint('init.d-script-missing-lsb-section', + $item->pointer); + + } else { + for my $keyword (keys %LSB_KEYWORDS) { + + if ($LSB_KEYWORDS{$keyword} + && !defined $value_by_lsb_keyword{$keyword}) { + + if ($keyword eq 'short-description') { + $self->pointed_hint( + 'init.d-script-missing-lsb-short-description', + $item->pointer); + + } elsif ($keyword eq 'description') { + next; + + } else { + $self->pointed_hint('init.d-script-missing-lsb-keyword', + $item->pointer, $keyword); + } + } + } + } + + # Check the runlevels. + my %start; + + for my $runlevel (@{$value_by_lsb_keyword{'default-start'} // []}) { + + if ($runlevel =~ /^[sS0-6]$/) { + + $start{lc $runlevel} = 1; + + $self->pointed_hint('init.d-script-starts-in-stop-runlevel', + $item->pointer, $runlevel) + if $runlevel eq '0' + || $runlevel eq '6'; + + } else { + $self->pointed_hint('init.d-script-has-bad-start-runlevel', + $item->pointer, $runlevel); + } + } + + # No script should start at one of the 2-5 runlevels but not at + # all of them + my $start = join($SPACE, (sort grep { /^[2-5]$/ } keys %start)); + + if (length($start) > 0 and $start ne '2 3 4 5') { + my @missing = grep { !exists $start{$_} } qw(2 3 4 5); + + $self->pointed_hint('init.d-script-missing-start', $item->pointer, + @missing); + } + + my %stop; + + for my $runlevel (@{$value_by_lsb_keyword{'default-stop'} // []}) { + + if ($runlevel =~ /^[sS0-6]$/) { + + $stop{$runlevel} = 1 + unless $runlevel =~ /[sS2-5]/; + + $self->pointed_hint('init.d-script-has-conflicting-start-stop', + $item->pointer, $runlevel) + if exists $start{$runlevel}; + + $self->pointed_hint('init-d-script-stops-in-s-runlevel', + $item->pointer) + if $runlevel =~ /[sS]/; + + } else { + $self->pointed_hint('init.d-script-has-bad-stop-runlevel', + $item->pointer, $runlevel); + } + } + + if (none { $item->basename eq $_ } qw(killprocs sendsigs halt reboot)) { + + my @required = (0, 1, $RUN_LEVEL_6); + my $stop_lc = List::Compare->new(\@required, [keys %stop]); + + my @have_some = $stop_lc->get_intersection; + my @missing = $stop_lc->get_Lonly; + + # Scripts that stop in any of 0, 1, or 6 probably should stop in all + # of them, with some special exceptions. + $self->pointed_hint('init.d-script-possible-missing-stop', + $item->pointer, (sort @missing)) + if @have_some + && @missing + && (%start != 1 || !exists $start{s}); + } + + my $provides_self = 0; + for my $facility (@{$value_by_lsb_keyword{'provides'} // []}) { + + $self->pointed_hint('init.d-script-provides-virtual-facility', + $item->pointer, $facility) + if $facility =~ /^\$/; + + $provides_self = 1 + if $item->basename =~/^\Q$facility\E(?:.sh)?$/; + } + + $self->pointed_hint('init.d-script-does-not-provide-itself',$item->pointer) + if defined $value_by_lsb_keyword{'provides'} + && !$provides_self; + + # Separately check Required-Start and Required-Stop, since while they're + # similar, they're not quite identical. This could use some further + # restructuring by pulling the regexes out as data tied to start/stop and + # remote/local and then combining the loops. + if (@{$value_by_lsb_keyword{'default-start'} // []}) { + + my @required = @{$value_by_lsb_keyword{'required-start'} // []}; + + if ($needs_fs) { + if (none { /^\$(?:local_fs|remote_fs|all)\z/ } @required) { + + $self->pointed_hint( + 'init.d-script-missing-dependency-on-local_fs', + $item->pointer, 'required-start'); + } + } + } + + if (@{$value_by_lsb_keyword{'default-stop'} // []}) { + + my @required = @{$value_by_lsb_keyword{'required-stop'} // []}; + + if ($needs_fs) { + if (none { /^(?:\$(?:local|remote)_fs|\$all|umountn?fs)\z/ } + @required) { + + $self->pointed_hint( + 'init.d-script-missing-dependency-on-local_fs', + $item->pointer, 'required-stop'); + } + } + } + + my $VIRTUAL_FACILITIES= $self->data->virtual_initd_facilities; + + # Check syntax rules that apply to all of the keywords. + for + my $keyword (qw(required-start should-start required-stop should-stop)){ + for my $prerequisite (@{$value_by_lsb_keyword{$keyword} // []}) { + + if (exists $implied_dependencies{$prerequisite}) { + + $self->pointed_hint('non-virtual-facility-in-initd-script', + $item->pointer, + "$prerequisite -> $implied_dependencies{$prerequisite}"); + + } elsif ($keyword =~ m/^required-/ && $prerequisite =~ m/^\$/) { + + $self->pointed_hint( + 'init.d-script-depends-on-unknown-virtual-facility', + $item->pointer, $prerequisite) + unless ($VIRTUAL_FACILITIES->recognizes($prerequisite)); + } + + $self->pointed_hint( + 'init.d-script-depends-on-all-virtual-facility', + $item->pointer, $keyword) + if $prerequisite =~ m/^\$all$/; + } + } + + my @required_commands = qw{start stop restart force-reload}; + my $command_lc + = List::Compare->new(\@required_commands, [keys %saw_command]); + my @missing_commands = $command_lc->get_Lonly; + + # all tags included in file? + $self->pointed_hint('init.d-script-does-not-implement-required-option', + $item->pointer, $_) + for @missing_commands; + + $self->pointed_hint('init.d-script-does-not-implement-status-option', + $item->pointer) + unless $saw_command{'status'}; + + return; +} + +sub check_defaults { + my ($self) = @_; + + my $processable = $self->processable; + + my $dir = $processable->installed->resolve_path('etc/default/'); + return + unless $dir && $dir->is_dir; + + for my $item ($dir->children) { + + return + unless $item->is_open_ok; + + open(my $fd, '<', $item->unpacked_path) + or die encode_utf8('Cannot open ' . $item->unpacked_path); + + my $position = 1; + while (my $line = <$fd>) { + + $self->pointed_hint('init.d-script-should-always-start-service', + $item->pointer($position)) + if $line + =~ m{^ \s* [#]* \s* (?:[A-Z]_)? (?:ENABLED|DISABLED|[A-Z]*RUN | (?:NO_)? START) = }x; + + } continue { + ++$position; + } + + close $fd; + } + + return; +} + +sub visit_installed_files { + my ($self, $item) = @_; + + if ($item =~ m{etc/sv/([^/]+)/$}) { + + my $service = $1; + my $runfile + = $self->processable->installed->resolve_path( + "etc/sv/${service}/run"); + + $self->pointed_hint( + 'directory-in-etc-sv-directory-without-executable-run-script', + $item->pointer, $runfile) + unless defined $runfile && $runfile->is_executable; + } + + return; +} + +1; + +# Local Variables: +# indent-tabs-mode: nil +# cperl-indent-level: 4 +# End: +# vim: syntax=perl sw=4 sts=4 sr et |