1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this
3# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5from __future__ import absolute_import, print_function, unicode_literals
6
7import inspect
8import re
9import types
10from dis import Bytecode
11from functools import wraps
12from io import StringIO
13from . import (
14    CombinedDependsFunction,
15    ConfigureError,
16    ConfigureSandbox,
17    DependsFunction,
18    SandboxedGlobal,
19    TrivialDependsFunction,
20    SandboxDependsFunction,
21)
22from .help import HelpFormatter
23from mozbuild.util import memoize
24
25
26class LintSandbox(ConfigureSandbox):
27    def __init__(self, environ=None, argv=None, stdout=None, stderr=None):
28        out = StringIO()
29        stdout = stdout or out
30        stderr = stderr or out
31        environ = environ or {}
32        argv = argv or []
33        self._wrapped = {}
34        self._has_imports = set()
35        self._bool_options = []
36        self._bool_func_options = []
37        self.LOG = ""
38        super(LintSandbox, self).__init__(
39            {}, environ=environ, argv=argv, stdout=stdout, stderr=stderr
40        )
41
42    def run(self, path=None):
43        if path:
44            self.include_file(path)
45
46        for dep in self._depends.values():
47            self._check_dependencies(dep)
48
49    def _raise_from(self, exception, obj, line=0):
50        """
51        Raises the given exception as if it were emitted from the given
52        location.
53
54        The location is determined from the values of obj and line.
55        - `obj` can be a function or DependsFunction, in which case
56          `line` corresponds to the line within the function the exception
57          will be raised from (as an offset from the function's firstlineno).
58        - `obj` can be a stack frame, in which case `line` is ignored.
59        """
60
61        def thrower(e):
62            raise e
63
64        if isinstance(obj, DependsFunction):
65            obj, _ = self.unwrap(obj._func)
66
67        if inspect.isfunction(obj):
68            funcname = obj.__name__
69            filename = obj.__code__.co_filename
70            firstline = obj.__code__.co_firstlineno
71            line += firstline
72        elif inspect.isframe(obj):
73            funcname = obj.f_code.co_name
74            filename = obj.f_code.co_filename
75            firstline = obj.f_code.co_firstlineno
76            line = obj.f_lineno
77        else:
78            # Don't know how to handle the given location, still raise the
79            # exception.
80            raise exception
81
82        # Create a new function from the above thrower that pretends
83        # the `def` line is on the first line of the function given as
84        # argument, and the `raise` line is on the line given as argument.
85
86        offset = line - firstline
87        # co_lnotab is a string where each pair of consecutive character is
88        # (chr(byte_increment), chr(line_increment)), mapping bytes in co_code
89        # to line numbers relative to co_firstlineno.
90        # If the offset we need to encode is larger than what fits in a 8-bit
91        # signed integer, we need to split it.
92        co_lnotab = bytes([0, 127] * (offset // 127) + [0, offset % 127])
93        code = thrower.__code__
94        codetype_args = [
95            code.co_argcount,
96            code.co_kwonlyargcount,
97            code.co_nlocals,
98            code.co_stacksize,
99            code.co_flags,
100            code.co_code,
101            code.co_consts,
102            code.co_names,
103            code.co_varnames,
104            filename,
105            funcname,
106            firstline,
107            co_lnotab,
108        ]
109        if hasattr(code, "co_posonlyargcount"):
110            # co_posonlyargcount was introduced in Python 3.8.
111            codetype_args.insert(1, code.co_posonlyargcount)
112
113        code = types.CodeType(*codetype_args)
114        thrower = types.FunctionType(
115            code,
116            thrower.__globals__,
117            funcname,
118            thrower.__defaults__,
119            thrower.__closure__,
120        )
121        thrower(exception)
122
123    def _check_dependencies(self, obj):
124        if isinstance(obj, CombinedDependsFunction) or obj in (
125            self._always,
126            self._never,
127        ):
128            return
129        func, glob = self.unwrap(obj._func)
130        func_args = inspect.getfullargspec(func)
131        if func_args.varkw:
132            e = ConfigureError(
133                "Keyword arguments are not allowed in @depends functions"
134            )
135            self._raise_from(e, func)
136
137        all_args = list(func_args.args)
138        if func_args.varargs:
139            all_args.append(func_args.varargs)
140        used_args = set()
141
142        for instr in Bytecode(func):
143            if instr.opname in ("LOAD_FAST", "LOAD_CLOSURE"):
144                if instr.argval in all_args:
145                    used_args.add(instr.argval)
146
147        for num, arg in enumerate(all_args):
148            if arg not in used_args:
149                dep = obj.dependencies[num]
150                if dep != self._help_option or not self._need_help_dependency(obj):
151                    if isinstance(dep, DependsFunction):
152                        dep = dep.name
153                    else:
154                        dep = dep.option
155                    e = ConfigureError("The dependency on `%s` is unused" % dep)
156                    self._raise_from(e, func)
157
158    def _need_help_dependency(self, obj):
159        if isinstance(obj, (CombinedDependsFunction, TrivialDependsFunction)):
160            return False
161        if isinstance(obj, DependsFunction):
162            if obj in (self._always, self._never):
163                return False
164            func, glob = self.unwrap(obj._func)
165            # We allow missing --help dependencies for functions that:
166            # - don't use @imports
167            # - don't have a closure
168            # - don't use global variables
169            if func in self._has_imports or func.__closure__:
170                return True
171            for instr in Bytecode(func):
172                if instr.opname in ("LOAD_GLOBAL", "STORE_GLOBAL"):
173                    # There is a fake os module when one is not imported,
174                    # and it's allowed for functions without a --help
175                    # dependency.
176                    if instr.argval == "os" and glob.get("os") is self.OS:
177                        continue
178                    if instr.argval in self.BUILTINS:
179                        continue
180                    if instr.argval in "namespace":
181                        continue
182                    return True
183        return False
184
185    def _missing_help_dependency(self, obj):
186        if isinstance(obj, DependsFunction) and self._help_option in obj.dependencies:
187            return False
188        return self._need_help_dependency(obj)
189
190    @memoize
191    def _value_for_depends(self, obj):
192        with_help = self._help_option in obj.dependencies
193        if with_help:
194            for arg in obj.dependencies:
195                if self._missing_help_dependency(arg):
196                    e = ConfigureError(
197                        "Missing '--help' dependency because `%s` depends on "
198                        "'--help' and `%s`" % (obj.name, arg.name)
199                    )
200                    self._raise_from(e, arg)
201        elif self._missing_help_dependency(obj):
202            e = ConfigureError("Missing '--help' dependency")
203            self._raise_from(e, obj)
204        return super(LintSandbox, self)._value_for_depends(obj)
205
206    def option_impl(self, *args, **kwargs):
207        result = super(LintSandbox, self).option_impl(*args, **kwargs)
208        when = self._conditions.get(result)
209        if when:
210            self._value_for(when)
211
212        self._check_option(result, *args, **kwargs)
213
214        return result
215
216    def _check_option(self, option, *args, **kwargs):
217        if "default" not in kwargs:
218            return
219        if len(args) == 0:
220            return
221
222        self._check_prefix_for_bool_option(*args, **kwargs)
223        self._check_help_for_option_with_func_default(option, *args, **kwargs)
224
225    def _check_prefix_for_bool_option(self, *args, **kwargs):
226        name = args[0]
227        default = kwargs["default"]
228
229        if type(default) != bool:
230            return
231
232        table = {
233            True: {
234                "enable": "disable",
235                "with": "without",
236            },
237            False: {
238                "disable": "enable",
239                "without": "with",
240            },
241        }
242        for prefix, replacement in table[default].items():
243            if name.startswith("--{}-".format(prefix)):
244                frame = inspect.currentframe()
245                while frame and frame.f_code.co_name != self.option_impl.__name__:
246                    frame = frame.f_back
247                e = ConfigureError(
248                    "{} should be used instead of "
249                    "{} with default={}".format(
250                        name.replace(
251                            "--{}-".format(prefix), "--{}-".format(replacement)
252                        ),
253                        name,
254                        default,
255                    )
256                )
257                self._raise_from(e, frame.f_back if frame else None)
258
259    def _check_help_for_option_with_func_default(self, option, *args, **kwargs):
260        default = kwargs["default"]
261
262        if not isinstance(default, SandboxDependsFunction):
263            return
264
265        if not option.prefix:
266            return
267
268        default = self._resolve(default)
269        if type(default) is str:
270            return
271
272        help = kwargs["help"]
273        match = re.search(HelpFormatter.RE_FORMAT, help)
274        if match:
275            return
276
277        if option.prefix in ("enable", "disable"):
278            rule = "{Enable|Disable}"
279        else:
280            rule = "{With|Without}"
281
282        frame = inspect.currentframe()
283        while frame and frame.f_code.co_name != self.option_impl.__name__:
284            frame = frame.f_back
285        e = ConfigureError(
286            '`help` should contain "{}" because of non-constant default'.format(rule)
287        )
288        self._raise_from(e, frame.f_back if frame else None)
289
290    def unwrap(self, func):
291        glob = func.__globals__
292        while func in self._wrapped:
293            if isinstance(func.__globals__, SandboxedGlobal):
294                glob = func.__globals__
295            func = self._wrapped[func]
296        return func, glob
297
298    def wraps(self, func):
299        def do_wraps(wrapper):
300            self._wrapped[wrapper] = func
301            return wraps(func)(wrapper)
302
303        return do_wraps
304
305    def imports_impl(self, _import, _from=None, _as=None):
306        wrapper = super(LintSandbox, self).imports_impl(_import, _from=_from, _as=_as)
307
308        def decorator(func):
309            self._has_imports.add(func)
310            return wrapper(func)
311
312        return decorator
313
314    def _prepare_function(self, func, update_globals=None):
315        wrapped = super(LintSandbox, self)._prepare_function(func, update_globals)
316        _, glob = self.unwrap(wrapped)
317        imports = set()
318        for _from, _import, _as in self._imports.get(func, ()):
319            if _as:
320                imports.add(_as)
321            else:
322                what = _import.split(".")[0]
323                imports.add(what)
324            if _from == "__builtin__" and _import in glob["__builtins__"]:
325                e = NameError(
326                    "builtin '{}' doesn't need to be imported".format(_import)
327                )
328                self._raise_from(e, func)
329        for instr in Bytecode(func):
330            code = func.__code__
331            if (
332                instr.opname == "LOAD_GLOBAL"
333                and instr.argval not in glob
334                and instr.argval not in imports
335                and instr.argval not in glob["__builtins__"]
336                and instr.argval not in code.co_varnames[: code.co_argcount]
337            ):
338                # Raise the same kind of error as what would happen during
339                # execution.
340                e = NameError("global name '{}' is not defined".format(instr.argval))
341                if instr.starts_line is None:
342                    self._raise_from(e, func)
343                else:
344                    self._raise_from(e, func, instr.starts_line - code.co_firstlineno)
345
346        return wrapped
347