# init.d -- lintian check script -*- perl -*- # Copyright © 1998 Christian Schwarz # Copyright © 2016-2020 Chris Lamb # # 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 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. package Lintian::Check::InitD; use v5.20; use warnings; use utf8; use Const::Fast; use File::Basename qw(dirname); use List::SomeUtils qw(any none); use Unicode::UTF8 qw(encode_utf8); use Moo; use namespace::clean; with 'Lintian::Check'; const my $EMPTY => q{}; const my $SPACE => q{ }; # 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' => '$local_fs', 'mountnfs' => '$remote_fs', 'hwclock' => '$time', 'portmap' => '$portmap', 'named' => '$named', 'bind9' => '$named', 'networking' => '$network', 'syslog' => '$syslog', 'rsyslog' => '$syslog', 'sysklogd' => '$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/; my $ALWAYS_START = qr/^\s*#*\s*(?:[A-Z]_)?(?:ENABLED|DISABLED|[A-Z]*RUN|(?:NO_)?START)=/; 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' or $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); 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'; if ($initd_postinst{$name}++ == 1) { $self->hint('duplicate-updaterc.d-calls-in-postinst', $name); next; } $self->hint('output-of-updaterc.d-not-redirected-to-dev-null', "$name postinst") unless $line =~ m{>\s*/dev/null}; } 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); 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,$opt) = ($1,$2); next if $opt eq 'remove'; $self->hint('preinst-calls-updaterc.d', $name); } 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); 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 = $2; if ($initd_postrm{$name}++ == 1) { $self->hint('duplicate-updaterc.d-calls-in-postrm', $2); next; } $self->hint('output-of-updaterc.d-not-redirected-to-dev-null', "$name postrm") unless $line =~ m{>\s*/dev/null}; } 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); while (my $line = <$fd>) { next if $line =~ /$EXCLUDE_R/; $line =~ s/\#.*$//; next unless $line =~ /update-rc\.d\s+($OPTS_R)*($INITD_NAME_REGEX)/; $self->hint('prerm-calls-updaterc.d', $2); } close($fd); } # init.d scripts have to be removed in postrm for (keys %initd_postinst) { if ($initd_postrm{$_}) { delete $initd_postrm{$_}; } else { $self->hint('postrm-does-not-call-updaterc.d-for-init.d-script', "etc/init.d/$_"); } } for (keys %initd_postrm) { $self->hint('postrm-contains-additional-updaterc.d-calls', "etc/init.d/$_"); } foreach my $initd_file (keys %initd_postinst) { my $initd_path; $initd_path = $initd_dir->child($initd_file) if $initd_dir; # init.d scripts have to be marked as conffiles unless they're # symlinks. if (not $initd_path or not $initd_path->resolve_path) { my $ok = 0; if ($initd_path and $initd_path->is_symlink) { $ok = 1 if $initd_path->link eq '/lib/init/upstart-job'; } if (not $ok) { $self->hint('init.d-script-not-included-in-package', "etc/init.d/$initd_file"); } next; } if ( not $initd_path or ( not $processable->conffiles->is_known($initd_path->name) and not $initd_path->is_symlink) ) { $self->hint('init.d-script-not-marked-as-conffile', "etc/init.d/$initd_file"); } # Check if file exists in package and check the script for # other issues if it was included in the package. $self->check_init($initd_path); } $self->check_defaults; return unless $initd_dir and $initd_dir->is_dir; # files actually installed in /etc/init.d should match our list :-) for my $script ($initd_dir->children) { my $tagname = 'script-in-etc-init.d-not-registered-via-update-rc.d'; my $basename = $script->basename; next if ( any {$basename eq $_} qw(README skeleton rc rcS) ) && !$script->is_dir; # In an upstart system, such as Ubuntu, init scripts are symlinks to # upstart-job which are not registered with update-rc.d. if ($script->is_symlink and $script->link eq '/lib/init/upstart-job') { $tagname = 'upstart-job-in-etc-init.d-not-registered-via-update-rc.d'; } # 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->hint($tagname, $script); $self->check_init($script); } } return; } sub check_init { my ($self, $initd_path) = @_; 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. if ($initd_path->is_symlink) { if ($initd_path->link eq '/lib/init/upstart-job') { return; } } return if not $initd_path->is_open_ok; my (%tag, %lsb); my $in_file_test = 0; my $needs_fs = 0; open(my $fd, '<', $initd_path->unpacked_path) or die encode_utf8('Cannot open ' . $initd_path->unpacked_path); while (my $l = <$fd>) { if ($. == 1) { if ($l =~ m{^ [#]! \s* (?:/usr/bin/env)? \s* /lib/init/init-d-script}xsm ) { for my $arg (qw(start stop restart force-reload status)) { $tag{$arg} = 1; } } elsif ($l =~ m{^\#!\s*(/usr/[^\s]+)}) { $self->hint('init.d-script-uses-usr-interpreter', $initd_path, $1); } } if ($l =~ m/Please remove the "Author" lines|Example initscript/) { $self->hint('init.d-script-contains-skeleton-template-content', "${initd_path}:$."); } if ($l =~ m/^\#\#\# BEGIN INIT INFO/) { if ($lsb{BEGIN}) { $self->hint('init.d-script-has-duplicate-lsb-section', $initd_path); next; } $lsb{BEGIN} = 1; my $final; # We have an LSB keyword section. Parse it and save the data # in %lsb for analysis. while (my $l = <$fd>) { if ($l =~ /^\#\#\# END INIT INFO/) { $lsb{END} = 1; last; } elsif ($l !~ /^\#/) { $self->hint('init.d-script-has-unterminated-lsb-section', "${initd_path}:$."); last; } elsif ($l =~ /^\# ([a-zA-Z-]+):\s*(.*?)\s*$/) { my $keyword = lc $1; my $value = $2; $self->hint('init.d-script-has-duplicate-lsb-keyword', "${initd_path}:$. $keyword") if (defined $lsb{$keyword}); $self->hint( 'init.d-script-has-unknown-lsb-keyword', "${initd_path}:$. $keyword" ) unless (defined($lsb_keywords{$keyword}) || $keyword =~ /^x-/); $lsb{$keyword} = defined($value) ? $value : $EMPTY; $final = $keyword; } elsif ($l =~ /^\#(\t| )/ && $final eq 'description') { my $value = $l; $value =~ s/^\#\s*//; $lsb{description} .= $SPACE . $value; } else { $self->hint('init.d-script-has-bad-lsb-line', "${initd_path}:$."); } } } # Pretty dummy way to handle conditionals, but should be enough # for simple init scripts $in_file_test = 1 if ($l =~ m/\bif\s+.*?(?:test|\[)(?:\s+\!)?\s+-[efr]\s+/); $in_file_test = 0 if ($l =~ m/\bfi\b/); if (!$in_file_test && $l =~ m{^\s*\.\s+["'"]?(/etc/default/[\$\w/-]+)}) { $self->hint('init.d-script-sourcing-without-test', "${initd_path}:$. $1"); } if ($l =~ m{\. /lib/init/init-d-script}) { # Some init.d scripts source init-d-script, since (e.g.) # kFreeBSD does not allow shell scripts as interpreters. for my $arg (qw(start stop restart force-reload status)) { $tag{$arg} = 1; } } # This should be more sophisticated: ignore heredocs, ignore quoted # text and the arguments to echo, etc. $needs_fs = 1 if $l =~ m{^[^\#]*/var/}; while ($l =~ s/^[^\#]*?(start|stop|restart|force-reload|status)//) { $tag{$1} = 1; } if ( $l =~ m{^\s*\.\s+/lib/lsb/init-functions} && !$processable->relation('strong')->satisfies('lsb-base') && none { $_->basename =~ m/\.service$/ && !$_->is_dir } @{$processable->installed->sorted_list} ) { $self->hint('init.d-script-needs-depends-on-lsb-base', $initd_path, "(line $.)"); } } close($fd); # Make sure all of the required keywords are present. if (not $lsb{BEGIN}) { $self->hint('init.d-script-missing-lsb-section', $initd_path); } else { for my $keyword (keys %lsb_keywords) { if ($lsb_keywords{$keyword} && !defined $lsb{$keyword}) { if ($keyword eq 'short-description') { $self->hint('init.d-script-missing-lsb-short-description', $initd_path); } elsif ($keyword eq 'description') { next; } else { $self->hint('init.d-script-missing-lsb-keyword', $initd_path, $keyword); } } } } # Check the runlevels. my %start; if (defined $lsb{'default-start'}) { for my $runlevel (split(/\s+/, $lsb{'default-start'})) { if ($runlevel =~ /^[sS0-6]$/) { $start{lc $runlevel} = 1; if ($runlevel eq '0' or $runlevel eq '6') { $self->hint('init.d-script-starts-in-stop-runlevel', $initd_path, $runlevel); } } else { $self->hint('init.d-script-has-bad-start-runlevel', $initd_path, $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 { !defined $start{$_} } qw(2 3 4 5); $self->hint('init.d-script-missing-start', $initd_path,@missing); } } if (defined $lsb{'default-stop'}) { my %stop; for my $runlevel (split(/\s+/, $lsb{'default-stop'})) { if ($runlevel =~ /^[sS0-6]$/) { $stop{$runlevel} = 1 unless $runlevel =~ /[sS2-5]/; if ($start{$runlevel}) { $self->hint('init.d-script-has-conflicting-start-stop', $initd_path, $runlevel); } if ($runlevel =~ /[sS]/) { $self->hint('init-d-script-stops-in-s-runlevel', $initd_path); } } else { $self->hint('init.d-script-has-bad-stop-runlevel', $initd_path, $runlevel); } } # Scripts that stop in any of 0, 1, or 6 probably should stop in all # of them, with some special exceptions. my $stop = join($SPACE, sort keys %stop); if (length($stop) > 0 and $stop ne '0 1 6') { my $base = $initd_path->basename; if (none { $base eq $_ } qw(killprocs sendsigs halt reboot)) { my @missing = grep { !defined $stop{$_} } qw(0 1 6); my $start = join($SPACE, sort keys %start); $self->hint('init.d-script-possible-missing-stop', $initd_path,@missing) unless $start eq 's'; } } } if ($lsb{'provides'}) { my $provides_self; for my $facility (split(/\s+/, $lsb{'provides'})) { if ($facility =~ /^\$/) { $self->hint('init.d-script-provides-virtual-facility', $initd_path, $facility); } if ($initd_path->basename =~/^\Q$facility\E(?:.sh)?$/) { $provides_self = 1; } } $self->hint('init.d-script-does-not-provide-itself', $initd_path) unless $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 (defined $lsb{'default-start'} && length($lsb{'default-start'})) { my @required = split($SPACE, $lsb{'required-start'} || $EMPTY); if ($needs_fs) { if (none { /^\$(?:local_fs|remote_fs|all)\z/ } @required) { $self->hint('init.d-script-missing-dependency-on-local_fs', "${initd_path}: required-start"); } } } if (defined $lsb{'default-stop'} && length($lsb{'default-stop'})) { my @required = split($SPACE, $lsb{'required-stop'} || $EMPTY); if ($needs_fs) { if ( none { /^(?:\$(?:local|remote)_fs|\$all|umountn?fs)\z/ } @required ) { $self->hint('init.d-script-missing-dependency-on-local_fs', "${initd_path}: required-stop"); } } } my $VIRTUAL_FACILITIES = $self->profile->load_data('init.d/virtual_facilities'); # Check syntax rules that apply to all of the keywords. for my $keyword (qw(required-start should-start required-stop should-stop)) { next unless defined $lsb{$keyword}; for my $dependency (split(/\s+/, $lsb{$keyword})) { if (defined $implied_dependencies{$dependency}) { $self->hint('non-virtual-facility-in-initd-script', $initd_path, "$dependency -> $implied_dependencies{$dependency}"); } elsif ($keyword =~ m/^required-/ && $dependency =~ m/^\$/) { $self->hint( 'init.d-script-depends-on-unknown-virtual-facility', $initd_path, $dependency) unless ($VIRTUAL_FACILITIES->recognizes($dependency)); } if ($dependency =~ m/^\$all$/) { $self->hint('init.d-script-depends-on-all-virtual-facility', $initd_path, $keyword); } } } # all tags included in file? for my $option (qw(start stop restart force-reload)) { $tag{$option} or $self->hint('init.d-script-does-not-implement-required-option', $initd_path, $option); } $self->hint('init.d-script-does-not-implement-status-option', $initd_path) unless $tag{'status'}; return; } sub check_defaults { my ($self) = @_; my $processable = $self->processable; my $dir = $processable->installed->resolve_path('etc/default/'); return unless $dir and $dir->is_dir; for my $path ($dir->children) { return unless $path->is_open_ok; open(my $fd, '<', $path->unpacked_path) or die encode_utf8('Cannot open ' . $path->unpacked_path); while (my $line = <$fd>) { $self->hint('init.d-script-should-always-start-service', $path, "(line $.)") if $line =~ /$ALWAYS_START/; } close($fd); } return; } sub visit_installed_files { my ($self, $item) = @_; # check for missing init.d script when alternative init system is present if ( $item =~ m{etc/sv/(?[^/]+)/run$} || $item =~ m{(?usr/)?lib/systemd/system/(?[^/@]+)\.service}) { my ($usr, $service) = ($+{usr} // $EMPTY, $+{svc}); $self->hint('package-supports-alternative-init-but-no-init.d-script', $item) unless $self->processable->installed->resolve_path( "etc/init.d/${service}") or $self->processable->installed->resolve_path( "${usr}lib/systemd/system/${service}.path") or $self->processable->installed->resolve_path( "${usr}lib/systemd/system/${service}.timer"); } if ($item =~ m{etc/sv/([^/]+)/$}) { my $service = $1; my $runfile = $self->processable->installed->resolve_path( "etc/sv/${service}/run"); $self->hint( 'directory-in-etc-sv-directory-without-executable-run-script', $runfile) if not $runfile or not $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