1#!/usr/local/bin/perl
2
3# This script is essentially copied from /usr/share/lintian/checks/scripts,
4# which is:
5#   Copyright (C) 1998 Richard Braakman
6#   Copyright (C) 2002 Josip Rodin
7# This version is
8#   Copyright (C) 2003 Julian Gilbey
9#
10# This program is free software; you can redistribute it and/or modify
11# it under the terms of the GNU General Public License as published by
12# the Free Software Foundation; either version 2 of the License, or
13# (at your option) any later version.
14#
15# This program is distributed in the hope that it will be useful,
16# but WITHOUT ANY WARRANTY; without even the implied warranty of
17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18# GNU General Public License for more details.
19#
20# You should have received a copy of the GNU General Public License
21# along with this program. If not, see <https://www.gnu.org/licenses/>.
22
23use strict;
24use warnings;
25use Getopt::Long qw(:config bundling permute no_getopt_compat);
26use File::Temp qw/tempfile/;
27
28sub init_hashes;
29
30(my $progname = $0) =~ s|.*/||;
31
32my $usage = <<"EOF";
33Usage: $progname [-n] [-f] [-x] [-e] script ...
34   or: $progname --help
35   or: $progname --version
36This script performs basic checks for the presence of bashisms
37in /bin/sh scripts and the lack of bashisms in /bin/bash ones.
38EOF
39
40my $version = <<"EOF";
41This is $progname, from the Debian devscripts package, version 2.21.4
42This code is copyright 2003 by Julian Gilbey <jdg\@debian.org>,
43based on original code which is copyright 1998 by Richard Braakman
44and copyright 2002 by Josip Rodin.
45This program comes with ABSOLUTELY NO WARRANTY.
46You are free to redistribute this code under the terms of the
47GNU General Public License, version 2, or (at your option) any later version.
48EOF
49
50my ($opt_echo, $opt_force, $opt_extra, $opt_posix, $opt_early_fail);
51my ($opt_help, $opt_version);
52my @filenames;
53
54# Detect if STDIN is a pipe
55if (scalar(@ARGV) == 0 && (-p STDIN or -f STDIN)) {
56    push(@ARGV, '-');
57}
58
59##
60## handle command-line options
61##
62$opt_help = 1 if int(@ARGV) == 0;
63
64GetOptions(
65    "help|h"       => \$opt_help,
66    "version|v"    => \$opt_version,
67    "newline|n"    => \$opt_echo,
68    "force|f"      => \$opt_force,
69    "extra|x"      => \$opt_extra,
70    "posix|p"      => \$opt_posix,
71    "early-fail|e" => \$opt_early_fail,
72  )
73  or die
74"Usage: $progname [options] filelist\nRun $progname --help for more details\n";
75
76if ($opt_help)    { print $usage;   exit 0; }
77if ($opt_version) { print $version; exit 0; }
78
79$opt_echo = 1 if $opt_posix;
80
81my $mode     = 0;
82my $issues   = 0;
83my $status   = 0;
84my $makefile = 0;
85my (%bashisms, %string_bashisms, %singlequote_bashisms);
86
87my $LEADIN
88  = qr'(?:(?:^|[`&;(|{])\s*|(?:(?:if|elif|while)(?:\s+!)?|then|do|shell)\s+)';
89init_hashes;
90
91my @bashisms_keys             = sort keys %bashisms;
92my @string_bashisms_keys      = sort keys %string_bashisms;
93my @singlequote_bashisms_keys = sort keys %singlequote_bashisms;
94
95foreach my $filename (@ARGV) {
96    my $check_lines_count = -1;
97
98    my $display_filename = $filename;
99
100    if ($filename eq '-') {
101        my $tmp_fh;
102        ($tmp_fh, $filename)
103          = tempfile("chkbashisms_tmp.XXXX", TMPDIR => 1, UNLINK => 1);
104        while (my $line = <STDIN>) {
105            print $tmp_fh $line;
106        }
107        close($tmp_fh);
108        $display_filename = "(stdin)";
109    }
110
111    if (!$opt_force) {
112        $check_lines_count = script_is_evil_and_wrong($filename);
113    }
114
115    if ($check_lines_count == 0 or $check_lines_count == 1) {
116        warn
117"script $display_filename does not appear to be a /bin/sh script; skipping\n";
118        next;
119    }
120
121    if ($check_lines_count != -1) {
122        warn
123"script $display_filename appears to be a shell wrapper; only checking the first "
124          . "$check_lines_count lines\n";
125    }
126
127    unless (open C, '<', $filename) {
128        warn "cannot open script $display_filename for reading: $!\n";
129        $status |= 2;
130        next;
131    }
132
133    $issues = 0;
134    $mode   = 0;
135    my $cat_string         = "";
136    my $cat_indented       = 0;
137    my $quote_string       = "";
138    my $last_continued     = 0;
139    my $continued          = 0;
140    my $found_rules        = 0;
141    my $buffered_orig_line = "";
142    my $buffered_line      = "";
143    my %start_lines;
144
145    while (<C>) {
146        next unless ($check_lines_count == -1 or $. <= $check_lines_count);
147
148        if ($. == 1) {    # This should be an interpreter line
149            if (m,^\#!\s*(?:\S+/env\s+)?(\S+),) {
150                my $interpreter = $1;
151
152                if ($interpreter =~ m,(?:^|/)make$,) {
153                    init_hashes if !$makefile++;
154                    $makefile = 1;
155                } else {
156                    init_hashes if $makefile--;
157                    $makefile = 0;
158                }
159                next if $opt_force;
160
161                if ($interpreter =~ m,(?:^|/)bash$,) {
162                    $mode = 1;
163                } elsif ($interpreter !~ m,(?:^|/)(sh|dash|posh)$,) {
164### ksh/zsh?
165                    warn
166"script $display_filename does not appear to be a /bin/sh script; skipping\n";
167                    $status |= 2;
168                    last;
169                }
170            } else {
171                warn
172"script $display_filename does not appear to have a \#! interpreter line;\nyou may get strange results\n";
173            }
174        }
175
176        chomp;
177        my $orig_line = $_;
178
179        # We want to remove end-of-line comments, so need to skip
180        # comments that appear inside balanced pairs
181        # of single or double quotes
182
183        # Remove comments in the "quoted" part of a line that starts
184        # in a quoted block? The problem is that we have no idea
185        # whether the program interpreting the block treats the
186        # quote character as part of the comment or as a quote
187        # terminator. We err on the side of caution and assume it
188        # will be treated as part of the comment.
189        # s/^(?:.*?[^\\])?$quote_string(.*)$/$1/ if $quote_string ne "";
190
191        # skip comment lines
192        if (   m,^\s*\#,
193            && $quote_string eq ''
194            && $buffered_line eq ''
195            && $cat_string eq '') {
196            next;
197        }
198
199        # Remove quoted strings so we can more easily ignore comments
200        # inside them
201        s/(^|[^\\](?:\\\\)*)\'(?:\\.|[^\\\'])+\'/$1''/g;
202        s/(^|[^\\](?:\\\\)*)\"(?:\\.|[^\\\"])+\"/$1""/g;
203
204        # If inside a quoted string, remove everything before the quote
205        s/^.+?\'//
206          if ($quote_string eq "'");
207        s/^.+?[^\\]\"//
208          if ($quote_string eq '"');
209
210        # If the remaining string contains what looks like a comment,
211        # eat it. In either case, swap the unmodified script line
212        # back in for processing.
213        if (m/(?:^|[^[\\])[\s\&;\(\)](\#.*$)/) {
214            $_ = $orig_line;
215            s/\Q$1\E//;    # eat comments
216        } else {
217            $_ = $orig_line;
218        }
219
220        # Handle line continuation
221        if (!$makefile && $cat_string eq '' && m/\\$/) {
222            chop;
223            $buffered_line      .= $_;
224            $buffered_orig_line .= $orig_line . "\n";
225            next;
226        }
227
228        if ($buffered_line ne '') {
229            $_                  = $buffered_line . $_;
230            $orig_line          = $buffered_orig_line . $orig_line;
231            $buffered_line      = '';
232            $buffered_orig_line = '';
233        }
234
235        if ($makefile) {
236            $last_continued = $continued;
237            if (/[^\\]\\$/) {
238                $continued = 1;
239            } else {
240                $continued = 0;
241            }
242
243            # Don't match lines that look like a rule if we're in a
244            # continuation line before the start of the rules
245            if (/^[\w%-]+:+\s.*?;?(.*)$/
246                and !($last_continued and !$found_rules)) {
247                $found_rules = 1;
248                $_           = $1 if $1;
249            }
250
251            last
252              if m%^\s*(override\s|export\s)?\s*SHELL\s*:?=\s*(/bin/)?bash\s*%;
253
254            # Remove "simple" target names
255            s/^[\w%.-]+(?:\s+[\w%.-]+)*::?//;
256            s/^\t//;
257            s/(?<!\$)\$\((\w+)\)/\${$1}/g;
258            s/(\$){2}/$1/g;
259            s/^[\s\t]*[@-]{1,2}//;
260        }
261
262        if (
263            $cat_string ne ""
264            && (m/^\Q$cat_string\E$/
265                || ($cat_indented && m/^\t*\Q$cat_string\E$/))
266        ) {
267            $cat_string = "";
268            next;
269        }
270        my $within_another_shell = 0;
271        if (m,(^|\s+)((/usr)?/bin/)?((b|d)?a|k|z|t?c)sh\s+-c\s*.+,) {
272            $within_another_shell = 1;
273        }
274        # if cat_string is set, we are in a HERE document and need not
275        # check for things
276        if ($cat_string eq "" and !$within_another_shell) {
277            my $found       = 0;
278            my $match       = '';
279            my $explanation = '';
280            my $line        = $_;
281
282            # Remove "" / '' as they clearly aren't quoted strings
283            # and not considering them makes the matching easier
284            $line =~ s/(^|[^\\])(\'\')+/$1/g;
285            $line =~ s/(^|[^\\])(\"\")+/$1/g;
286
287            if ($quote_string ne "") {
288                my $otherquote = ($quote_string eq "\"" ? "\'" : "\"");
289                # Inside a quoted block
290                if ($line =~ /(?:^|^.*?[^\\])$quote_string(.*)$/) {
291                    my $rest     = $1;
292                    my $templine = $line;
293
294                    # Remove quoted strings delimited with $otherquote
295                    $templine
296                      =~ s/(^|[^\\])$otherquote[^$quote_string]*?[^\\]$otherquote/$1/g;
297                    # Remove quotes that are themselves quoted
298                    # "a'b"
299                    $templine
300                      =~ s/(^|[^\\])$otherquote.*?$quote_string.*?[^\\]$otherquote/$1/g;
301                    # "\""
302                    $templine
303                      =~ s/(^|[^\\])$quote_string\\$quote_string$quote_string/$1/g;
304
305                    # After all that, were there still any quotes left?
306                    my $count = () = $templine =~ /(^|[^\\])$quote_string/g;
307                    next if $count == 0;
308
309                    $count = () = $rest =~ /(^|[^\\])$quote_string/g;
310                    if ($count % 2 == 0) {
311                        # Quoted block ends on this line
312                        # Ignore everything before the closing quote
313                        $line         = $rest || '';
314                        $quote_string = "";
315                    } else {
316                        next;
317                    }
318                } else {
319                    # Still inside the quoted block, skip this line
320                    next;
321                }
322            }
323
324            # Check even if we removed the end of a quoted block
325            # in the previous check, as a single line can end one
326            # block and begin another
327            if ($quote_string eq "") {
328                # Possible start of a quoted block
329                for my $quote ("\"", "\'") {
330                    my $templine   = $line;
331                    my $otherquote = ($quote eq "\"" ? "\'" : "\"");
332
333                    # Remove balanced quotes and their content
334                    while (1) {
335                        my ($length_single, $length_double) = (0, 0);
336
337                        # Determine which one would match first:
338                        if ($templine
339                            =~ m/(^.+?(?:^|[^\\\"](?:\\\\)*)\')[^\']*\'/) {
340                            $length_single = length($1);
341                        }
342                        if ($templine
343                            =~ m/(^.*?(?:^|[^\\\'](?:\\\\)*)\")(?:\\.|[^\\\"])+\"/
344                        ) {
345                            $length_double = length($1);
346                        }
347
348                        # Now simplify accordingly (shorter is preferred):
349                        if (
350                            $length_single != 0
351                            && (   $length_single < $length_double
352                                || $length_double == 0)
353                        ) {
354                            $templine =~ s/(^|[^\\\"](?:\\\\)*)\'[^\']*\'/$1/;
355                        } elsif ($length_double != 0) {
356                            $templine
357                              =~ s/(^|[^\\\'](?:\\\\)*)\"(?:\\.|[^\\\"])+\"/$1/;
358                        } else {
359                            last;
360                        }
361                    }
362
363                    # Don't flag quotes that are themselves quoted
364                    # "a'b"
365                    $templine =~ s/$otherquote.*?$quote.*?$otherquote//g;
366                    # "\""
367                    $templine =~ s/(^|[^\\])$quote\\$quote$quote/$1/g;
368                    # \' or \"
369                    $templine =~ s/\\[\'\"]//g;
370                    my $count = () = $templine =~ /(^|(?!\\))$quote/g;
371
372                    # If there's an odd number of non-escaped
373                    # quotes in the line it's almost certainly the
374                    # start of a quoted block.
375                    if ($count % 2 == 1) {
376                        $quote_string = $quote;
377                        $start_lines{'quote_string'} = $.;
378                        $line =~ s/^(.*)$quote.*$/$1/;
379                        last;
380                    }
381                }
382            }
383
384            # since this test is ugly, I have to do it by itself
385            # detect source (.) trying to pass args to the command it runs
386            # The first expression weeds out '. "foo bar"'
387            if (    not $found
388                and not
389m/$LEADIN\.\s+(\"[^\"]+\"|\'[^\']+\'|\$\([^)]+\)+(?:\/[^\s;]+)?)\s*(\&|\||\d?>|<|;|\Z)/o
390                and m/$LEADIN(\.\s+[^\s;\`:]+\s+([^\s;]+))/o) {
391                if ($2 =~ /^(\&|\||\d?>|<)/) {
392                    # everything is ok
393                    ;
394                } else {
395                    $found       = 1;
396                    $match       = $1;
397                    $explanation = "sourced script with arguments";
398                    output_explanation($display_filename, $orig_line,
399                        $explanation);
400                }
401            }
402
403            # Remove "quoted quotes". They're likely to be inside
404            # another pair of quotes; we're not interested in
405            # them for their own sake and removing them makes finding
406            # the limits of the outer pair far easier.
407            $line =~ s/(^|[^\\\'\"])\"\'\"/$1/g;
408            $line =~ s/(^|[^\\\'\"])\'\"\'/$1/g;
409
410            foreach my $re (@singlequote_bashisms_keys) {
411                my $expl = $singlequote_bashisms{$re};
412                if ($line =~ m/($re)/) {
413                    $found       = 1;
414                    $match       = $1;
415                    $explanation = $expl;
416                    output_explanation($display_filename, $orig_line,
417                        $explanation);
418                }
419            }
420
421            my $re = '(?<![\$\\\])\$\'[^\']+\'';
422            if ($line =~ m/(.*)($re)/o) {
423                my $count = () = $1 =~ /(^|[^\\])\'/g;
424                if ($count % 2 == 0) {
425                    output_explanation($display_filename, $orig_line,
426                        q<$'...' should be "$(printf '...')">);
427                }
428            }
429
430            # $cat_line contains the version of the line we'll check
431            # for heredoc delimiters later. Initially, remove any
432            # spaces between << and the delimiter to make the following
433            # updates to $cat_line easier. However, don't remove the
434            # spaces if the delimiter starts with a -, as that changes
435            # how the delimiter is searched.
436            my $cat_line = $line;
437            $cat_line =~ s/(<\<-?)\s+(?!-)/$1/g;
438
439            # Ignore anything inside single quotes; it could be an
440            # argument to grep or the like.
441            $line =~ s/(^|[^\\\"](?:\\\\)*)\'(?:\\.|[^\\\'])+\'/$1''/g;
442
443            # As above, with the exception that we don't remove the string
444            # if the quote is immediately preceded by a < or a -, so we
445            # can match "foo <<-?'xyz'" as a heredoc later
446            # The check is a little more greedy than we'd like, but the
447            # heredoc test itself will weed out any false positives
448            $cat_line =~ s/(^|[^<\\\"-](?:\\\\)*)\'(?:\\.|[^\\\'])+\'/$1''/g;
449
450            $re = '(?<![\$\\\])\$\"[^\"]+\"';
451            if ($line =~ m/(.*)($re)/o) {
452                my $count = () = $1 =~ /(^|[^\\])\"/g;
453                if ($count % 2 == 0) {
454                    output_explanation($display_filename, $orig_line,
455                        q<$"foo" should be eval_gettext "foo">);
456                }
457            }
458
459            foreach my $re (@string_bashisms_keys) {
460                my $expl = $string_bashisms{$re};
461                if ($line =~ m/($re)/) {
462                    $found       = 1;
463                    $match       = $1;
464                    $explanation = $expl;
465                    output_explanation($display_filename, $orig_line,
466                        $explanation);
467                }
468            }
469
470            # We've checked for all the things we still want to notice in
471            # double-quoted strings, so now remove those strings as well.
472            $line     =~ s/(^|[^\\\'](?:\\\\)*)\"(?:\\.|[^\\\"])+\"/$1""/g;
473            $cat_line =~ s/(^|[^<\\\'-](?:\\\\)*)\"(?:\\.|[^\\\"])+\"/$1""/g;
474            foreach my $re (@bashisms_keys) {
475                my $expl = $bashisms{$re};
476                if ($line =~ m/($re)/) {
477                    $found       = 1;
478                    $match       = $1;
479                    $explanation = $expl;
480                    output_explanation($display_filename, $orig_line,
481                        $explanation);
482                }
483            }
484            # This check requires the value to be compared, which could
485            # be done in the regex itself but requires "use re 'eval'".
486            # So it's better done in its own
487            if ($line =~ m/$LEADIN((?:exit|return)\s+(\d{3,}))/o && $2 > 255) {
488                $explanation = 'exit|return status code greater than 255';
489                output_explanation($display_filename, $orig_line,
490                    $explanation);
491            }
492
493            # Only look for the beginning of a heredoc here, after we've
494            # stripped out quoted material, to avoid false positives.
495            if ($cat_line
496                =~ m/(?:^|[^<])\<\<(\-?)\s*(?:(?!<|'|")((?:[^\s;>|]+(?:(?<=\\)[\s;>|])?)+)|[\'\"](.*?)[\'\"])/
497            ) {
498                $cat_indented = ($1 && $1 eq '-') ? 1 : 0;
499                my $quoted = defined($3);
500                $cat_string = $quoted ? $3 : $2;
501                unless ($quoted) {
502                    # Now strip backslashes. Keep the position of the
503                    # last match in a variable, as s/// resets it back
504                    # to undef, but we don't want that.
505                    my $pos = 0;
506                    pos($cat_string) = $pos;
507                    while ($cat_string =~ s/\G(.*?)\\/$1/) {
508                        # position += length of match + the character
509                        # that followed the backslash:
510                        $pos += length($1) + 1;
511                        pos($cat_string) = $pos;
512                    }
513                }
514                $start_lines{'cat_string'} = $.;
515            }
516        }
517    }
518
519    warn
520"error: $display_filename:  Unterminated heredoc found, EOF reached. Wanted: <$cat_string>, opened in line $start_lines{'cat_string'}\n"
521      if ($cat_string ne '');
522    warn
523"error: $display_filename: Unterminated quoted string found, EOF reached. Wanted: <$quote_string>, opened in line $start_lines{'quote_string'}\n"
524      if ($quote_string ne '');
525    warn "error: $display_filename: EOF reached while on line continuation.\n"
526      if ($buffered_line ne '');
527
528    close C;
529
530    if ($mode && !$issues) {
531        warn "could not find any possible bashisms in bash script $filename\n";
532        $status |= 4;
533    }
534}
535
536exit $status;
537
538sub output_explanation {
539    my ($filename, $line, $explanation) = @_;
540
541    if ($mode) {
542        # When examining a bash script, just flag that there are indeed
543        # bashisms present
544        $issues = 1;
545    } else {
546        warn "possible bashism in $filename line $. ($explanation):\n$line\n";
547        if ($opt_early_fail) {
548            exit 1;
549        }
550        $status |= 1;
551    }
552}
553
554# Returns non-zero if the given file is not actually a shell script,
555# just looks like one.
556sub script_is_evil_and_wrong {
557    my ($filename) = @_;
558    my $ret = -1;
559    # lintian's version of this function aborts if the file
560    # can't be opened, but we simply return as the next
561    # test in the calling code handles reporting the error
562    # itself
563    open(IN, '<', $filename) or return $ret;
564    my $i            = 0;
565    my $var          = "0";
566    my $backgrounded = 0;
567    local $_;
568    while (<IN>) {
569        chomp;
570        next if /^#/o;
571        next if /^$/o;
572        last if (++$i > 55);
573        if (
574            m~
575	    # the exec should either be "eval"ed or a new statement
576	    (^\s*|\beval\s*[\'\"]|(;|&&|\b(then|else))\s*)
577
578	    # eat anything between the exec and $0
579	    exec\s*.+\s*
580
581	    # optionally quoted executable name (via $0)
582	    .?\$$var.?\s*
583
584	    # optional "end of options" indicator
585	    (--\s*)?
586
587	    # Match expressions of the form '${1+$@}', '${1:+"$@"',
588	    # '"${1+$@', "$@", etc where the quotes (before the dollar
589	    # sign(s)) are optional and the second (or only if the $1
590	    # clause is omitted) parameter may be $@ or $*.
591	    #
592	    # Finally the whole subexpression may be omitted for scripts
593	    # which do not pass on their parameters (i.e. after re-execing
594	    # they take their parameters (and potentially data) from stdin
595	    .?(\$\{1:?\+.?)?(\$(\@|\*))?~x
596        ) {
597            $ret = $. - 1;
598            last;
599        } elsif (/^\s*(\w+)=\$0;/) {
600            $var = $1;
601        } elsif (
602            m~
603	    # Match scripts which use "foo $0 $@ &\nexec true\n"
604	    # Program name
605	    \S+\s+
606
607	    # As above
608	    .?\$$var.?\s*
609	    (--\s*)?
610	    .?(\$\{1:?\+.?)?(\$(\@|\*))?.?\s*\&~x
611        ) {
612
613            $backgrounded = 1;
614        } elsif (
615            $backgrounded
616            and m~
617	    # the exec should either be "eval"ed or a new statement
618	    (^\s*|\beval\s*[\'\"]|(;|&&|\b(then|else))\s*)
619	    exec\s+true(\s|\Z)~x
620        ) {
621
622            $ret = $. - 1;
623            last;
624        } elsif (m~\@DPATCH\@~) {
625            $ret = $. - 1;
626            last;
627        }
628
629    }
630    close IN;
631    return $ret;
632}
633
634sub init_hashes {
635
636    %bashisms = (
637        qr'(?:^|\s+)function [^<>\(\)\[\]\{\};|\s]+(\s|\(|\Z)' =>
638          q<'function' is useless>,
639        $LEADIN . qr'select\s+\w+'               => q<'select' is not POSIX>,
640        qr'(test|-o|-a)\s*[^\s]+\s+==\s'         => q<should be 'b = a'>,
641        qr'\[\s+[^\]]+\s+==\s'                   => q<should be 'b = a'>,
642        qr'\s\|\&'                               => q<pipelining is not POSIX>,
643        qr'[^\\\$]\{([^\s\\\}]*?,)+[^\\\}\s]*\}' => q<brace expansion>,
644        qr'\{\d+\.\.\d+(?:\.\.\d+)?\}' =>
645          q<brace expansion, {a..b[..c]}should be $(seq a [c] b)>,
646        qr'(?i)\{[a-z]\.\.[a-z](?:\.\.\d+)?\}' => q<brace expansion>,
647        qr'(?:^|\s+)\w+\[\d+\]='               => q<bash arrays, H[0]>,
648        $LEADIN
649          . qr'read\s+(?:-[a-qs-zA-Z\d-]+)' =>
650          q<read with option other than -r>,
651        $LEADIN
652          . qr'read\s*(?:-\w+\s*)*(?:\".*?\"|[\'].*?[\'])?\s*(?:;|$)' =>
653          q<read without variable>,
654        $LEADIN . qr'echo\s+(-n\s+)?-n?en?\s' => q<echo -e>,
655        $LEADIN . qr'exec\s+-[acl]'           => q<exec -c/-l/-a name>,
656        $LEADIN . qr'let\s'                   => q<let ...>,
657        qr'(?<![\$\(])\(\(.*\)\)'             => q<'((' should be '$(('>,
658        qr'(?:^|\s+)(\[|test)\s+-a' => q<test with unary -a (should be -e)>,
659        qr'\&>'                     => q<should be \>word 2\>&1>,
660        qr'(<\&|>\&)\s*((-|\d+)[^\s;|)}`&\\\\]|[^-\d\s]+(?<!\$)(?!\d))' =>
661          q<should be \>word 2\>&1>,
662        qr'\[\[(?!:)' =>
663          q<alternative test command ([[ foo ]] should be [ foo ])>,
664        qr'/dev/(tcp|udp)'               => q</dev/(tcp|udp)>,
665        $LEADIN . qr'builtin\s'          => q<builtin>,
666        $LEADIN . qr'caller\s'           => q<caller>,
667        $LEADIN . qr'compgen\s'          => q<compgen>,
668        $LEADIN . qr'complete\s'         => q<complete>,
669        $LEADIN . qr'declare\s'          => q<declare>,
670        $LEADIN . qr'dirs(\s|\Z)'        => q<dirs>,
671        $LEADIN . qr'disown\s'           => q<disown>,
672        $LEADIN . qr'enable\s'           => q<enable>,
673        $LEADIN . qr'mapfile\s'          => q<mapfile>,
674        $LEADIN . qr'readarray\s'        => q<readarray>,
675        $LEADIN . qr'shopt(\s|\Z)'       => q<shopt>,
676        $LEADIN . qr'suspend\s'          => q<suspend>,
677        $LEADIN . qr'time\s'             => q<time>,
678        $LEADIN . qr'type\s'             => q<type>,
679        $LEADIN . qr'typeset\s'          => q<typeset>,
680        $LEADIN . qr'ulimit(\s|\Z)'      => q<ulimit>,
681        $LEADIN . qr'set\s+-[BHT]+'      => q<set -[BHT]>,
682        $LEADIN . qr'alias\s+-p'         => q<alias -p>,
683        $LEADIN . qr'unalias\s+-a'       => q<unalias -a>,
684        $LEADIN . qr'local\s+-[a-zA-Z]+' => q<local -opt>,
685        # function '=' is special-cased due to bash arrays (think of "foo=()")
686        qr'(?:^|\s)\s*=\s*\(\s*\)\s*([\{|\(]|\Z)' =>
687          q<function names should only contain [a-z0-9_]>,
688qr'(?:^|\s)(?<func>function\s)?\s*(?:[^<>\(\)\[\]\{\};|\s]*[^<>\(\)\[\]\{\};|\s\w][^<>\(\)\[\]\{\};|\s]*)(?(<func>)(?=)|(?<!=))\s*(?(<func>)(?:\(\s*\))?|\(\s*\))\s*([\{|\(]|\Z)'
689          => q<function names should only contain [a-z0-9_]>,
690        $LEADIN . qr'(push|pop)d(\s|\Z)' => q<(push|pop)d>,
691        $LEADIN . qr'export\s+-[^p]'   => q<export only takes -p as an option>,
692        qr'(?:^|\s+)[<>]\(.*?\)'       => q<\<() process substitution>,
693        $LEADIN . qr'readonly\s+-[af]' => q<readonly -[af]>,
694        $LEADIN . qr'(sh|\$\{?SHELL\}?) -[rD]' => q<sh -[rD]>,
695        $LEADIN . qr'(sh|\$\{?SHELL\}?) --\w+' => q<sh --long-option>,
696        $LEADIN . qr'(sh|\$\{?SHELL\}?) [-+]O' => q<sh [-+]O>,
697        qr'\[\^[^]]+\]'                        => q<[^] should be [!]>,
698        $LEADIN
699          . qr'printf\s+-v' =>
700          q<'printf -v var ...' should be var='$(printf ...)'>,
701        $LEADIN . qr'coproc\s' => q<coproc>,
702        qr';;?&'               => q<;;& and ;& special case operators>,
703        $LEADIN . qr'jobs\s'   => q<jobs>,
704 #	$LEADIN . qr'jobs\s+-[^lp]\s' =>  q<'jobs' with option other than -l or -p>,
705        $LEADIN
706          . qr'command\s+(?:-[pvV]+\s+)*-(?:[pvV])*[^pvV\s]' =>
707          q<'command' with option other than -p, -v or -V>,
708        $LEADIN
709          . qr'setvar\s' =>
710          q<setvar 'foo' 'bar' should be eval 'foo="'"$bar"'"'>,
711        $LEADIN
712          . qr'trap\s+["\']?.*["\']?\s+.*(?:ERR|DEBUG|RETURN)' =>
713          q<trap with ERR|DEBUG|RETURN>,
714        $LEADIN
715          . qr'(?:exit|return)\s+-\d' =>
716          q<exit|return with negative status code>,
717        $LEADIN
718          . qr'(?:exit|return)\s+--' =>
719          q<'exit --' should be 'exit' (idem for return)>,
720        $LEADIN . qr'hash(\s|\Z)' => q<hash>,
721        qr'(?:[:=\s])~(?:[+-]|[+-]?\d+)(?:[/\s]|\Z)' =>
722          q<non-standard tilde expansion>,
723    );
724
725    %string_bashisms = (
726        qr'\$\[[^][]+\]' => q<'$[' should be '$(('>,
727        qr'\$\{(?:\w+|@|\*)\:(?:\d+|\$\{?\w+\}?)+(?::(?:\d+|\$\{?\w+\}?)+)?\}'
728          => q<${foo:3[:1]}>,
729        qr'\$\{!\w+[\@*]\}' => q<${!prefix[*|@]>,
730        qr'\$\{!\w+\}'      => q<${!name}>,
731        qr'\$\{(?:\w+|@|\*)([,^]{1,2}.*?)\}' =>
732          q<${parm,[,][pat]} or ${parm^[^][pat]}>,
733        qr'\$\{[@*]([#%]{1,2}.*?)\}' => q<${[@|*]#[#]pat} or ${[@|*]%[%]pat}>,
734        qr'\$\{#[@*]\}'              => q<${#@} or ${#*}>,
735        qr'\$\{(?:\w+|@|\*)(/.+?){1,2}\}' => q<${parm/?/pat[/str]}>,
736        qr'\$\{\#?\w+\[.+\](?:[/,:#%^].+?)?\}' =>
737          q<bash arrays, ${name[0|*|@]}>,
738        qr'\$\{?RANDOM\}?\b'          => q<$RANDOM>,
739        qr'\$\{?(OS|MACH)TYPE\}?\b'   => q<$(OS|MACH)TYPE>,
740        qr'\$\{?HOST(TYPE|NAME)\}?\b' => q<$HOST(TYPE|NAME)>,
741        qr'\$\{?DIRSTACK\}?\b'        => q<$DIRSTACK>,
742        qr'\$\{?EUID\}?\b'            => q<$EUID should be "$(id -u)">,
743        qr'\$\{?UID\}?\b'             => q<$UID should be "$(id -ru)">,
744        qr'\$\{?SECONDS\}?\b'         => q<$SECONDS>,
745        qr'\$\{?BASH_[A-Z]+\}?\b'     => q<$BASH_SOMETHING>,
746        qr'\$\{?SHELLOPTS\}?\b'       => q<$SHELLOPTS>,
747        qr'\$\{?PIPESTATUS\}?\b'      => q<$PIPESTATUS>,
748        qr'\$\{?SHLVL\}?\b'           => q<$SHLVL>,
749        qr'\$\{?FUNCNAME\}?\b'        => q<$FUNCNAME>,
750        qr'\$\{?TMOUT\}?\b'           => q<$TMOUT>,
751        qr'(?:^|\s+)TMOUT='           => q<TMOUT=>,
752        qr'\$\{?TIMEFORMAT\}?\b'      => q<$TIMEFORMAT>,
753        qr'(?:^|\s+)TIMEFORMAT='      => q<TIMEFORMAT=>,
754        qr'(?<![$\\])\$\{?_\}?\b'     => q<$_>,
755        qr'(?:^|\s+)GLOBIGNORE='      => q<GLOBIGNORE=>,
756        qr'<<<'                       => q<\<\<\< here string>,
757        $LEADIN
758          . qr'echo\s+(?:-[^e\s]+\s+)?\"[^\"]*(\\[abcEfnrtv0])+.*?[\"]' =>
759          q<unsafe echo with backslash>,
760        qr'\$\(\([\s\w$*/+-]*\w\+\+.*?\)\)' =>
761          q<'$((n++))' should be '$n; $((n=n+1))'>,
762        qr'\$\(\([\s\w$*/+-]*\+\+\w.*?\)\)' =>
763          q<'$((++n))' should be '$((n=n+1))'>,
764        qr'\$\(\([\s\w$*/+-]*\w\-\-.*?\)\)' =>
765          q<'$((n--))' should be '$n; $((n=n-1))'>,
766        qr'\$\(\([\s\w$*/+-]*\-\-\w.*?\)\)' =>
767          q<'$((--n))' should be '$((n=n-1))'>,
768        qr'\$\(\([\s\w$*/+-]*\*\*.*?\)\)' => q<exponentiation is not POSIX>,
769        $LEADIN . qr'printf\s["\'][^"\']*?%q.+?["\']' => q<printf %q>,
770    );
771
772    %singlequote_bashisms = (
773        $LEADIN
774          . qr'echo\s+(?:-[^e\s]+\s+)?\'[^\']*(\\[abcEfnrtv0])+.*?[\']' =>
775          q<unsafe echo with backslash>,
776        $LEADIN
777          . qr'source\s+[\"\']?(?:\.\/|\/|\$|[\w~.-])\S*' =>
778          q<should be '.', not 'source'>,
779    );
780
781    if ($opt_echo) {
782        $bashisms{ $LEADIN . qr'echo\s+-[A-Za-z]*n' } = q<echo -n>;
783    }
784    if ($opt_posix) {
785        $bashisms{ $LEADIN . qr'local\s+\w+(\s+\W|\s*[;&|)]|$)' }
786          = q<local foo>;
787        $bashisms{ $LEADIN . qr'local\s+\w+=' }      = q<local foo=bar>;
788        $bashisms{ $LEADIN . qr'local\s+\w+\s+\w+' } = q<local x y>;
789        $bashisms{ $LEADIN . qr'((?:test|\[)\s+.+\s-[ao])\s' } = q<test -a/-o>;
790        $bashisms{ $LEADIN . qr'kill\s+-[^sl]\w*' } = q<kill -[0-9] or -[A-Z]>;
791        $bashisms{ $LEADIN . qr'trap\s+["\']?.*["\']?\s+.*[1-9]' }
792          = q<trap with signal numbers>;
793    }
794
795    if ($makefile) {
796        $string_bashisms{qr'(\$\(|\`)\s*\<\s*([^\s\)]{2,}|[^DF])\s*(\)|\`)'}
797          = q<'$(\< foo)' should be '$(cat foo)'>;
798    } else {
799        $bashisms{ $LEADIN . qr'\w+\+=' } = q<should be VAR="${VAR}foo">;
800        $string_bashisms{qr'(\$\(|\`)\s*\<\s*\S+\s*(\)|\`)'}
801          = q<'$(\< foo)' should be '$(cat foo)'>;
802    }
803
804    if ($opt_extra) {
805        $string_bashisms{qr'\$\{?BASH\}?\b'}            = q<$BASH>;
806        $string_bashisms{qr'(?:^|\s+)RANDOM='}          = q<RANDOM=>;
807        $string_bashisms{qr'(?:^|\s+)(OS|MACH)TYPE='}   = q<(OS|MACH)TYPE=>;
808        $string_bashisms{qr'(?:^|\s+)HOST(TYPE|NAME)='} = q<HOST(TYPE|NAME)=>;
809        $string_bashisms{qr'(?:^|\s+)DIRSTACK='}        = q<DIRSTACK=>;
810        $string_bashisms{qr'(?:^|\s+)EUID='}            = q<EUID=>;
811        $string_bashisms{qr'(?:^|\s+)UID='}             = q<UID=>;
812        $string_bashisms{qr'(?:^|\s+)BASH(_[A-Z]+)?='}  = q<BASH(_SOMETHING)=>;
813        $string_bashisms{qr'(?:^|\s+)SHELLOPTS='}       = q<SHELLOPTS=>;
814        $string_bashisms{qr'\$\{?POSIXLY_CORRECT\}?\b'} = q<$POSIXLY_CORRECT>;
815    }
816}
817