xref: /OK3568_Linux_fs/yocto/scripts/verify-bashisms (revision 4882a59341e53eb6f0b4789bf948001014eff981)
1#!/usr/bin/env python3
2#
3# SPDX-License-Identifier: GPL-2.0-only
4#
5
6import sys, os, subprocess, re, shutil
7
8allowed = (
9    # type is supported by dash
10    'if type systemctl >/dev/null 2>/dev/null; then',
11    'if type systemd-tmpfiles >/dev/null 2>/dev/null; then',
12    'type update-rc.d >/dev/null 2>/dev/null; then',
13    'command -v',
14    # HOSTNAME is set locally
15    'buildhistory_single_commit "$CMDLINE" "$HOSTNAME"',
16    # False-positive, match is a grep not shell expression
17    'grep "^$groupname:[^:]*:[^:]*:\\([^,]*,\\)*$username\\(,[^,]*\\)*"',
18    # TODO verify dash's '. script args' behaviour
19    '. $target_sdk_dir/${oe_init_build_env_path} $target_sdk_dir >> $LOGFILE'
20    )
21
22def is_allowed(s):
23    for w in allowed:
24        if w in s:
25            return True
26    return False
27
28SCRIPT_LINENO_RE = re.compile(r' line (\d+) ')
29BASHISM_WARNING = re.compile(r'^(possible bashism in.*)$', re.MULTILINE)
30
31def process(filename, function, lineno, script):
32    import tempfile
33
34    if not script.startswith("#!"):
35        script = "#! /bin/sh\n" + script
36
37    fn = tempfile.NamedTemporaryFile(mode="w+t")
38    fn.write(script)
39    fn.flush()
40
41    try:
42        subprocess.check_output(("checkbashisms.pl", fn.name), universal_newlines=True, stderr=subprocess.STDOUT)
43        # No bashisms, so just return
44        return
45    except subprocess.CalledProcessError as e:
46        # TODO check exit code is 1
47
48        # Replace the temporary filename with the function and split it
49        output = e.output.replace(fn.name, function)
50        if not output or not output.startswith('possible bashism'):
51            # Probably starts with or contains only warnings. Dump verbatim
52            # with one space indention. Can't do the splitting and allowed
53            # checking below.
54            return '\n'.join([filename,
55                              ' Unexpected output from checkbashisms.pl'] +
56                             [' ' + x for x in output.splitlines()])
57
58        # We know that the first line matches and that therefore the first
59        # list entry will be empty - skip it.
60        output = BASHISM_WARNING.split(output)[1:]
61        # Turn the output into a single string like this:
62        # /.../foobar.bb
63        #  possible bashism in updatercd_postrm line 2 (type):
64        #   if ${@use_updatercd(d)} && type update-rc.d >/dev/null 2>/dev/null; then
65        #  ...
66        #   ...
67        result = []
68        # Check the results against the allowed list
69        for message, source in zip(output[0::2], output[1::2]):
70            if not is_whitelisted(source):
71                if lineno is not None:
72                    message = SCRIPT_LINENO_RE.sub(lambda m: ' line %d ' % (int(m.group(1)) + int(lineno) - 1),
73                                                   message)
74                result.append(' ' + message.strip())
75                result.extend(['  %s' % x for x in source.splitlines()])
76        if result:
77            result.insert(0, filename)
78            return '\n'.join(result)
79        else:
80            return None
81
82def get_tinfoil():
83    scripts_path = os.path.dirname(os.path.realpath(__file__))
84    lib_path = scripts_path + '/lib'
85    sys.path = sys.path + [lib_path]
86    import scriptpath
87    scriptpath.add_bitbake_lib_path()
88    import bb.tinfoil
89    tinfoil = bb.tinfoil.Tinfoil()
90    tinfoil.prepare()
91    # tinfoil.logger.setLevel(logging.WARNING)
92    return tinfoil
93
94if __name__=='__main__':
95    import argparse, shutil
96
97    parser = argparse.ArgumentParser(description='Bashim detector for shell fragments in recipes.')
98    parser.add_argument("recipes", metavar="RECIPE", nargs="*", help="recipes to check (if not specified, all will be checked)")
99    parser.add_argument("--verbose", default=False, action="store_true")
100    args = parser.parse_args()
101
102    if shutil.which("checkbashisms.pl") is None:
103        print("Cannot find checkbashisms.pl on $PATH, get it from https://salsa.debian.org/debian/devscripts/raw/master/scripts/checkbashisms.pl")
104        sys.exit(1)
105
106    # The order of defining the worker function,
107    # initializing the pool and connecting to the
108    # bitbake server is crucial, don't change it.
109    def func(item):
110        (filename, key, lineno), script = item
111        if args.verbose:
112            print("Scanning %s:%s" % (filename, key))
113        return process(filename, key, lineno, script)
114
115    import multiprocessing
116    pool = multiprocessing.Pool()
117
118    tinfoil = get_tinfoil()
119
120    # This is only the default configuration and should iterate over
121    # recipecaches to handle multiconfig environments
122    pkg_pn = tinfoil.cooker.recipecaches[""].pkg_pn
123
124    if args.recipes:
125        initial_pns = args.recipes
126    else:
127        initial_pns = sorted(pkg_pn)
128
129    pns = set()
130    scripts = {}
131    print("Generating scripts...")
132    for pn in initial_pns:
133        for fn in pkg_pn[pn]:
134            # There's no point checking multiple BBCLASSEXTENDed variants of the same recipe
135            # (at least in general - there is some risk that the variants contain different scripts)
136            realfn, _, _ = bb.cache.virtualfn2realfn(fn)
137            if realfn not in pns:
138                pns.add(realfn)
139                data = tinfoil.parse_recipe_file(realfn)
140                for key in data.keys():
141                    if data.getVarFlag(key, "func") and not data.getVarFlag(key, "python"):
142                        script = data.getVar(key, False)
143                        if script:
144                            filename = data.getVarFlag(key, "filename")
145                            lineno = data.getVarFlag(key, "lineno")
146                            # There's no point in checking a function multiple
147                            # times just because different recipes include it.
148                            # We identify unique scripts by file, name, and (just in case)
149                            # line number.
150                            attributes = (filename or realfn, key, lineno)
151                            scripts.setdefault(attributes, script)
152
153
154    print("Scanning scripts...\n")
155    for result in pool.imap(func, scripts.items()):
156        if result:
157            print(result)
158    tinfoil.shutdown()
159