• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zopefoundation / RestrictedPython / 11230727746

08 Oct 2024 07:33AM UTC coverage: 98.197% (-0.7%) from 98.863%
11230727746

Pull #289

github

dataflake
- fix outdated Python version for tox coverage test
Pull Request #289: Support Python 3.13

380 of 409 branches covered (92.91%)

2505 of 2551 relevant lines covered (98.2%)

0.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.51
/src/RestrictedPython/transformer.py
1
##############################################################################
2
#
3
# Copyright (c) 2002 Zope Foundation and Contributors.
4
#
5
# This software is subject to the provisions of the Zope Public License,
6
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
7
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
8
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
9
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
10
# FOR A PARTICULAR PURPOSE
11
#
12
##############################################################################
13
"""
14
transformer module:
15

16
uses Python standard library ast module and its containing classes to transform
17
the parsed python code to create a modified AST for a byte code generation.
18
"""
19

20

21
import ast
1✔
22
import contextlib
1✔
23
import textwrap
1✔
24

25
from ._compat import IS_PY38_OR_GREATER
1✔
26

27

28
# Avoid DeprecationWarnings under Python 3.12 and up
29
if IS_PY38_OR_GREATER:
1✔
30
    astStr = ast.Constant
1✔
31
    astNum = ast.Constant
1✔
32
else:  # pragma: no cover
33
    astStr = ast.Str
34
    astNum = ast.Num
35

36
# For AugAssign the operator must be converted to a string.
37
IOPERATOR_TO_STR = {
1✔
38
    ast.Add: '+=',
39
    ast.Sub: '-=',
40
    ast.Mult: '*=',
41
    ast.Div: '/=',
42
    ast.Mod: '%=',
43
    ast.Pow: '**=',
44
    ast.LShift: '<<=',
45
    ast.RShift: '>>=',
46
    ast.BitOr: '|=',
47
    ast.BitXor: '^=',
48
    ast.BitAnd: '&=',
49
    ast.FloorDiv: '//=',
50
    ast.MatMult: '@=',
51
}
52

53
# For creation allowed magic method names. See also
54
# https://docs.python.org/3/reference/datamodel.html#special-method-names
55
ALLOWED_FUNC_NAMES = frozenset([
1✔
56
    '__init__',
57
    '__contains__',
58
    '__lt__',
59
    '__le__',
60
    '__eq__',
61
    '__ne__',
62
    '__gt__',
63
    '__ge__',
64
])
65

66

67
FORBIDDEN_FUNC_NAMES = frozenset([
1✔
68
    'print',
69
    'printed',
70
    'builtins',
71
    'breakpoint',
72
])
73

74
# Attributes documented in the `inspect` module, but defined on the listed
75
# objects. See also https://docs.python.org/3/library/inspect.html
76
INSPECT_ATTRIBUTES = frozenset([
1✔
77
    # on traceback objects:
78
    "tb_frame",
79
    # "tb_lasti",  # int
80
    # "tb_lineno",  # int
81
    "tb_next",
82
    # on frame objects:
83
    "f_back",
84
    "f_builtins",
85
    "f_code",
86
    "f_globals",
87
    # "f_lasti",  # int
88
    # "f_lineno",  # int
89
    "f_locals",
90
    "f_trace",
91
    # on code objects:
92
    # "co_argcount",  # int
93
    "co_code",
94
    # "co_cellvars",  # tuple of str
95
    # "co_consts",   # tuple of str
96
    # "co_filename",  # str
97
    # "co_firstlineno",  # int
98
    # "co_flags",  # int
99
    # "co_lnotab",  # mapping between ints and indices
100
    # "co_freevars",  # tuple of strings
101
    # "co_posonlyargcount",  # int
102
    # "co_kwonlyargcount",  # int
103
    # "co_name",  # str
104
    # "co_qualname",  # str
105
    # "co_names",  # str
106
    # "co_nlocals",  # int
107
    # "co_stacksize",  # int
108
    # "co_varnames",  # tuple of str
109
    # on generator objects:
110
    "gi_frame",
111
    # "gi_running",  # bool
112
    "gi_code",
113
    "gi_yieldfrom",
114
    # on coroutine objects:
115
    "cr_await",
116
    "cr_frame",
117
    # "cr_running",  # bool
118
    "cr_code",
119
    "cr_origin",
120
])
121

122

123
# When new ast nodes are generated they have no 'lineno', 'end_lineno',
124
# 'col_offset' and 'end_col_offset'. This function copies these fields from the
125
# incoming node:
126
def copy_locations(new_node, old_node):
1✔
127
    assert 'lineno' in new_node._attributes
1✔
128
    new_node.lineno = old_node.lineno
1✔
129

130
    if IS_PY38_OR_GREATER:
1!
131
        assert 'end_lineno' in new_node._attributes
1✔
132
        new_node.end_lineno = old_node.end_lineno
1✔
133

134
    assert 'col_offset' in new_node._attributes
1✔
135
    new_node.col_offset = old_node.col_offset
1✔
136

137
    if IS_PY38_OR_GREATER:
1!
138
        assert 'end_col_offset' in new_node._attributes
1✔
139
        new_node.end_col_offset = old_node.end_col_offset
1✔
140

141
    ast.fix_missing_locations(new_node)
1✔
142

143

144
class PrintInfo:
1✔
145
    def __init__(self):
1✔
146
        self.print_used = False
1✔
147
        self.printed_used = False
1✔
148

149
    @contextlib.contextmanager
1✔
150
    def new_print_scope(self):
1✔
151
        old_print_used = self.print_used
1✔
152
        old_printed_used = self.printed_used
1✔
153

154
        self.print_used = False
1✔
155
        self.printed_used = False
1✔
156

157
        try:
1✔
158
            yield
1✔
159
        finally:
160
            self.print_used = old_print_used
1✔
161
            self.printed_used = old_printed_used
1✔
162

163

164
class RestrictingNodeTransformer(ast.NodeTransformer):
1✔
165

166
    def __init__(self, errors=None, warnings=None, used_names=None):
1✔
167
        super().__init__()
1✔
168
        self.errors = [] if errors is None else errors
1✔
169
        self.warnings = [] if warnings is None else warnings
1✔
170

171
        # All the variables used by the incoming source.
172
        # Internal names/variables, like the ones from 'gen_tmp_name', don't
173
        # have to be added.
174
        # 'used_names' is for example needed by 'RestrictionCapableEval' to
175
        # know wich names it has to supply when calling the final code.
176
        self.used_names = {} if used_names is None else used_names
1✔
177

178
        # Global counter to construct temporary variable names.
179
        self._tmp_idx = 0
1✔
180

181
        self.print_info = PrintInfo()
1✔
182

183
    def gen_tmp_name(self):
1✔
184
        # 'check_name' ensures that no variable is prefixed with '_'.
185
        # => Its safe to use '_tmp..' as a temporary variable.
186
        name = '_tmp%i' % self._tmp_idx
1✔
187
        self._tmp_idx += 1
1✔
188
        return name
1✔
189

190
    def error(self, node, info):
1✔
191
        """Record a security error discovered during transformation."""
192
        lineno = getattr(node, 'lineno', None)
1✔
193
        self.errors.append(
1✔
194
            f'Line {lineno}: {info}')
195

196
    def warn(self, node, info):
1✔
197
        """Record a security error discovered during transformation."""
198
        lineno = getattr(node, 'lineno', None)
1✔
199
        self.warnings.append(
1✔
200
            f'Line {lineno}: {info}')
201

202
    def guard_iter(self, node):
1✔
203
        """
204
        Converts:
205
            for x in expr
206
        to
207
            for x in _getiter_(expr)
208

209
        Also used for
210
        * list comprehensions
211
        * dict comprehensions
212
        * set comprehensions
213
        * generator expresions
214
        """
215
        node = self.node_contents_visit(node)
1✔
216

217
        if isinstance(node.target, ast.Tuple):
1✔
218
            spec = self.gen_unpack_spec(node.target)
1✔
219
            new_iter = ast.Call(
1✔
220
                func=ast.Name('_iter_unpack_sequence_', ast.Load()),
221
                args=[node.iter, spec, ast.Name('_getiter_', ast.Load())],
222
                keywords=[])
223
        else:
224
            new_iter = ast.Call(
1✔
225
                func=ast.Name("_getiter_", ast.Load()),
226
                args=[node.iter],
227
                keywords=[])
228

229
        copy_locations(new_iter, node.iter)
1✔
230
        node.iter = new_iter
1✔
231
        return node
1✔
232

233
    def is_starred(self, ob):
1✔
234
        return isinstance(ob, ast.Starred)
1✔
235

236
    def gen_unpack_spec(self, tpl):
1✔
237
        """Generate a specification for 'guarded_unpack_sequence'.
238

239
        This spec is used to protect sequence unpacking.
240
        The primary goal of this spec is to tell which elements in a sequence
241
        are sequences again. These 'child' sequences have to be protected
242
        again.
243

244
        For example there is a sequence like this:
245
            (a, (b, c), (d, (e, f))) = g
246

247
        On a higher level the spec says:
248
            - There is a sequence of len 3
249
            - The element at index 1 is a sequence again with len 2
250
            - The element at index 2 is a sequence again with len 2
251
              - The element at index 1 in this subsequence is a sequence again
252
                with len 2
253

254
        With this spec 'guarded_unpack_sequence' does something like this for
255
        protection (len checks are omitted):
256

257
            t = list(_getiter_(g))
258
            t[1] = list(_getiter_(t[1]))
259
            t[2] = list(_getiter_(t[2]))
260
            t[2][1] = list(_getiter_(t[2][1]))
261
            return t
262

263
        The 'real' spec for the case above is then:
264
            spec = {
265
                'min_len': 3,
266
                'childs': (
267
                    (1, {'min_len': 2, 'childs': ()}),
268
                    (2, {
269
                            'min_len': 2,
270
                            'childs': (
271
                                (1, {'min_len': 2, 'childs': ()})
272
                            )
273
                        }
274
                    )
275
                )
276
            }
277

278
        So finally the assignment above is converted into:
279
            (a, (b, c), (d, (e, f))) = guarded_unpack_sequence(g, spec)
280
        """
281
        spec = ast.Dict(keys=[], values=[])
1✔
282

283
        spec.keys.append(astStr('childs'))
1✔
284
        spec.values.append(ast.Tuple([], ast.Load()))
1✔
285

286
        # starred elements in a sequence do not contribute into the min_len.
287
        # For example a, b, *c = g
288
        # g must have at least 2 elements, not 3. 'c' is empyt if g has only 2.
289
        min_len = len([ob for ob in tpl.elts if not self.is_starred(ob)])
1✔
290
        offset = 0
1✔
291

292
        for idx, val in enumerate(tpl.elts):
1✔
293
            # After a starred element specify the child index from the back.
294
            # Since it is unknown how many elements from the sequence are
295
            # consumed by the starred element.
296
            # For example a, *b, (c, d) = g
297
            # Then (c, d) has the index '-1'
298
            if self.is_starred(val):
1✔
299
                offset = min_len + 1
1✔
300

301
            elif isinstance(val, ast.Tuple):
1✔
302
                el = ast.Tuple([], ast.Load())
1✔
303
                el.elts.append(astNum(idx - offset))
1✔
304
                el.elts.append(self.gen_unpack_spec(val))
1✔
305
                spec.values[0].elts.append(el)
1✔
306

307
        spec.keys.append(astStr('min_len'))
1✔
308
        spec.values.append(astNum(min_len))
1✔
309

310
        return spec
1✔
311

312
    def protect_unpack_sequence(self, target, value):
1✔
313
        spec = self.gen_unpack_spec(target)
1✔
314
        return ast.Call(
1✔
315
            func=ast.Name('_unpack_sequence_', ast.Load()),
316
            args=[value, spec, ast.Name('_getiter_', ast.Load())],
317
            keywords=[])
318

319
    def gen_unpack_wrapper(self, node, target):
1✔
320
        """Helper function to protect tuple unpacks.
321

322
        node: used to copy the locations for the new nodes.
323
        target: is the tuple which must be protected.
324

325
        It returns a tuple with two element.
326

327
        Element 1: Is a temporary name node which must be used to
328
                   replace the target.
329
                   The context (store, param) is defined
330
                   by the 'ctx' parameter..
331

332
        Element 2: Is a try .. finally where the body performs the
333
                   protected tuple unpack of the temporary variable
334
                   into the original target.
335
        """
336

337
        # Generate a tmp name to replace the tuple with.
338
        tmp_name = self.gen_tmp_name()
1✔
339

340
        # Generates an expressions which protects the unpack.
341
        # converter looks like 'wrapper(tmp_name)'.
342
        # 'wrapper' takes care to protect sequence unpacking with _getiter_.
343
        converter = self.protect_unpack_sequence(
1✔
344
            target,
345
            ast.Name(tmp_name, ast.Load()))
346

347
        # Assign the expression to the original names.
348
        # Cleanup the temporary variable.
349
        # Generates:
350
        # try:
351
        #     # converter is 'wrapper(tmp_name)'
352
        #     arg = converter
353
        # finally:
354
        #     del tmp_arg
355
        try_body = [ast.Assign(targets=[target], value=converter)]
1✔
356
        finalbody = [self.gen_del_stmt(tmp_name)]
1✔
357
        cleanup = ast.Try(
1✔
358
            body=try_body, finalbody=finalbody, handlers=[], orelse=[])
359

360
        # This node is used to catch the tuple in a tmp variable.
361
        tmp_target = ast.Name(tmp_name, ast.Store())
1✔
362

363
        copy_locations(tmp_target, node)
1✔
364
        copy_locations(cleanup, node)
1✔
365

366
        return (tmp_target, cleanup)
1✔
367

368
    def gen_none_node(self):
1✔
369
        return ast.NameConstant(value=None)
×
370

371
    def gen_del_stmt(self, name_to_del):
1✔
372
        return ast.Delete(targets=[ast.Name(name_to_del, ast.Del())])
1✔
373

374
    def transform_slice(self, slice_):
1✔
375
        """Transform slices into function parameters.
376

377
        ast.Slice nodes are only allowed within a ast.Subscript node.
378
        To use a slice as an argument of ast.Call it has to be converted.
379
        Conversion is done by calling the 'slice' function from builtins
380
        """
381

382
        if isinstance(slice_, ast.expr):
1!
383
            # Python 3.9+
384
            return slice_
1✔
385

386
        elif isinstance(slice_, ast.Index):
×
387
            return slice_.value
×
388

389
        elif isinstance(slice_, ast.Slice):
×
390
            # Create a python slice object.
391
            args = []
×
392

393
            if slice_.lower:
×
394
                args.append(slice_.lower)
×
395
            else:
396
                args.append(self.gen_none_node())
×
397

398
            if slice_.upper:
×
399
                args.append(slice_.upper)
×
400
            else:
401
                args.append(self.gen_none_node())
×
402

403
            if slice_.step:
×
404
                args.append(slice_.step)
×
405
            else:
406
                args.append(self.gen_none_node())
×
407

408
            return ast.Call(
×
409
                func=ast.Name('slice', ast.Load()),
410
                args=args,
411
                keywords=[])
412

413
        elif isinstance(slice_, ast.ExtSlice):
×
414
            dims = ast.Tuple([], ast.Load())
×
415
            for item in slice_.dims:
×
416
                dims.elts.append(self.transform_slice(item))
×
417
            return dims
×
418

419
        else:  # pragma: no cover
420
            # Index, Slice and ExtSlice are only defined Slice types.
421
            raise NotImplementedError(f"Unknown slice type: {slice_}")
422

423
    def check_name(self, node, name, allow_magic_methods=False):
1✔
424
        """Check names if they are allowed.
425

426
        If ``allow_magic_methods is True`` names in `ALLOWED_FUNC_NAMES`
427
        are additionally allowed although their names start with `_`.
428

429
        """
430
        if name is None:
1✔
431
            return
1✔
432

433
        if (name.startswith('_')
1✔
434
                and name != '_'
435
                and not (allow_magic_methods
436
                         and name in ALLOWED_FUNC_NAMES
437
                         and node.col_offset != 0)):
438
            self.error(
1✔
439
                node,
440
                '"{name}" is an invalid variable name because it '
441
                'starts with "_"'.format(name=name))
442
        elif name.endswith('__roles__'):
1✔
443
            self.error(node, '"%s" is an invalid variable name because '
1✔
444
                       'it ends with "__roles__".' % name)
445
        elif name in FORBIDDEN_FUNC_NAMES:
1✔
446
            self.error(node, f'"{name}" is a reserved name.')
1✔
447

448
    def check_function_argument_names(self, node):
1✔
449
        for arg in node.args.args:
1✔
450
            self.check_name(node, arg.arg)
1✔
451

452
        if node.args.vararg:
1✔
453
            self.check_name(node, node.args.vararg.arg)
1✔
454

455
        if node.args.kwarg:
1✔
456
            self.check_name(node, node.args.kwarg.arg)
1✔
457

458
        for arg in node.args.kwonlyargs:
1✔
459
            self.check_name(node, arg.arg)
1✔
460

461
    def check_import_names(self, node):
1✔
462
        """Check the names being imported.
463

464
        This is a protection against rebinding dunder names like
465
        _getitem_, _write_ via imports.
466

467
        => 'from _a import x' is ok, because '_a' is not added to the scope.
468
        """
469
        for name in node.names:
1✔
470
            if '*' in name.name:
1✔
471
                self.error(node, '"*" imports are not allowed.')
1✔
472
            self.check_name(node, name.name)
1✔
473
            if name.asname:
1✔
474
                self.check_name(node, name.asname)
1✔
475

476
        return self.node_contents_visit(node)
1✔
477

478
    def inject_print_collector(self, node, position=0):
1✔
479
        print_used = self.print_info.print_used
1✔
480
        printed_used = self.print_info.printed_used
1✔
481

482
        if print_used or printed_used:
1✔
483
            # Add '_print = _print_(_getattr_)' add the top of a
484
            # function/module.
485
            _print = ast.Assign(
1✔
486
                targets=[ast.Name('_print', ast.Store())],
487
                value=ast.Call(
488
                    func=ast.Name("_print_", ast.Load()),
489
                    args=[ast.Name("_getattr_", ast.Load())],
490
                    keywords=[]))
491

492
            if isinstance(node, ast.Module):
1✔
493
                _print.lineno = position
1✔
494
                _print.col_offset = position
1✔
495
                if IS_PY38_OR_GREATER:
1!
496
                    _print.end_lineno = position
1✔
497
                    _print.end_col_offset = position
1✔
498
                ast.fix_missing_locations(_print)
1✔
499
            else:
500
                copy_locations(_print, node)
1✔
501

502
            node.body.insert(position, _print)
1✔
503

504
            if not printed_used:
1✔
505
                self.warn(node, "Prints, but never reads 'printed' variable.")
1✔
506

507
            elif not print_used:
1✔
508
                self.warn(node, "Doesn't print, but reads 'printed' variable.")
1✔
509

510
    # Special Functions for an ast.NodeTransformer
511

512
    def generic_visit(self, node):
1✔
513
        """Reject ast nodes which do not have a corresponding `visit_` method.
514

515
        This is needed to prevent new ast nodes from new Python versions to be
516
        trusted before any security review.
517

518
        To access `generic_visit` on the super class use `node_contents_visit`.
519
        """
520
        self.warn(
1✔
521
            node,
522
            '{0.__class__.__name__}'
523
            ' statement is not known to RestrictedPython'.format(node)
524
        )
525
        self.not_allowed(node)
1✔
526

527
    def not_allowed(self, node):
1✔
528
        self.error(
1✔
529
            node,
530
            f'{node.__class__.__name__} statements are not allowed.')
531

532
    def node_contents_visit(self, node):
1✔
533
        """Visit the contents of a node."""
534
        return super().generic_visit(node)
1✔
535

536
    # ast for Literals
537

538
    if IS_PY38_OR_GREATER:
1!
539

540
        def visit_Constant(self, node):
1✔
541
            """Allow constant literals with restriction for Ellipsis.
542

543
            Constant replaces Num, Str, Bytes, NameConstant and Ellipsis in
544
            Python 3.8+.
545
            :see: https://docs.python.org/dev/whatsnew/3.8.html#deprecated
546
            """
547
            if node.value is Ellipsis:
1✔
548
                # Deny using `...`.
549
                # Special handling necessary as ``self.not_allowed(node)``
550
                # would return the Error Message:
551
                # 'Constant statements are not allowed.'
552
                # which is only partial true.
553
                self.error(node, 'Ellipsis statements are not allowed.')
1✔
554
                return
1✔
555
            return self.node_contents_visit(node)
1✔
556

557
    else:
558

559
        def visit_Num(self, node):
×
560
            """Allow integer numbers without restrictions.
561

562
            Replaced by Constant in Python 3.8.
563
            """
564
            return self.node_contents_visit(node)
×
565

566
        def visit_Str(self, node):
×
567
            """Allow string literals without restrictions.
568

569
            Replaced by Constant in Python 3.8.
570
            """
571
            return self.node_contents_visit(node)
×
572

573
        def visit_Bytes(self, node):
×
574
            """Allow bytes literals without restrictions.
575

576
            Replaced by Constant in Python 3.8.
577
            """
578
            return self.node_contents_visit(node)
×
579

580
        def visit_Ellipsis(self, node):
×
581
            """Deny using `...`.
582

583
            Replaced by Constant in Python 3.8.
584
            """
585
            return self.not_allowed(node)
×
586

587
        def visit_NameConstant(self, node):
×
588
            """Allow constant literals (True, False, None) without ...
589

590
            restrictions.
591

592
            Replaced by Constant in Python 3.8.
593
            """
594
            return self.node_contents_visit(node)
×
595

596
    def visit_Interactive(self, node):
1✔
597
        """Allow single mode without restrictions."""
598
        return self.node_contents_visit(node)
1✔
599

600
    def visit_List(self, node):
1✔
601
        """Allow list literals without restrictions."""
602
        return self.node_contents_visit(node)
1✔
603

604
    def visit_Tuple(self, node):
1✔
605
        """Allow tuple literals without restrictions."""
606
        return self.node_contents_visit(node)
1✔
607

608
    def visit_Set(self, node):
1✔
609
        """Allow set literals without restrictions."""
610
        return self.node_contents_visit(node)
1✔
611

612
    def visit_Dict(self, node):
1✔
613
        """Allow dict literals without restrictions."""
614
        return self.node_contents_visit(node)
1✔
615

616
    def visit_FormattedValue(self, node):
1✔
617
        """Allow f-strings without restrictions."""
618
        return self.node_contents_visit(node)
1✔
619

620
    def visit_JoinedStr(self, node):
1✔
621
        """Allow joined string without restrictions."""
622
        return self.node_contents_visit(node)
1✔
623

624
    # ast for Variables
625

626
    def visit_Name(self, node):
1✔
627
        """Prevents access to protected names.
628

629
        Converts use of the name 'printed' to this expression: '_print()'
630
        """
631

632
        node = self.node_contents_visit(node)
1✔
633

634
        if isinstance(node.ctx, ast.Load):
1✔
635
            if node.id == 'printed':
1✔
636
                self.print_info.printed_used = True
1✔
637
                new_node = ast.Call(
1✔
638
                    func=ast.Name("_print", ast.Load()),
639
                    args=[],
640
                    keywords=[])
641

642
                copy_locations(new_node, node)
1✔
643
                return new_node
1✔
644

645
            elif node.id == 'print':
1✔
646
                self.print_info.print_used = True
1✔
647
                new_node = ast.Attribute(
1✔
648
                    value=ast.Name('_print', ast.Load()),
649
                    attr="_call_print",
650
                    ctx=ast.Load())
651

652
                copy_locations(new_node, node)
1✔
653
                return new_node
1✔
654

655
            self.used_names[node.id] = True
1✔
656

657
        self.check_name(node, node.id)
1✔
658
        return node
1✔
659

660
    def visit_Load(self, node):
1✔
661
        """
662

663
        """
664
        return self.node_contents_visit(node)
1✔
665

666
    def visit_Store(self, node):
1✔
667
        """
668

669
        """
670
        return self.node_contents_visit(node)
1✔
671

672
    def visit_Del(self, node):
1✔
673
        """
674

675
        """
676
        return self.node_contents_visit(node)
1✔
677

678
    def visit_Starred(self, node):
1✔
679
        """
680

681
        """
682
        return self.node_contents_visit(node)
1✔
683

684
    # Expressions
685

686
    def visit_Expression(self, node):
1✔
687
        """Allow Expression statements without restrictions.
688

689
        They are in the AST when using the `eval` compile mode.
690
        """
691
        return self.node_contents_visit(node)
1✔
692

693
    def visit_Expr(self, node):
1✔
694
        """Allow Expr statements (any expression) without restrictions."""
695
        return self.node_contents_visit(node)
1✔
696

697
    def visit_UnaryOp(self, node):
1✔
698
        """
699
        UnaryOp (Unary Operations) is the overall element for:
700
        * Not --> which should be allowed
701
        * UAdd --> Positive notation of variables (e.g. +var)
702
        * USub --> Negative notation of variables (e.g. -var)
703
        """
704
        return self.node_contents_visit(node)
1✔
705

706
    def visit_UAdd(self, node):
1✔
707
        """Allow positive notation of variables. (e.g. +var)"""
708
        return self.node_contents_visit(node)
1✔
709

710
    def visit_USub(self, node):
1✔
711
        """Allow negative notation of variables. (e.g. -var)"""
712
        return self.node_contents_visit(node)
1✔
713

714
    def visit_Not(self, node):
1✔
715
        """Allow the `not` operator."""
716
        return self.node_contents_visit(node)
1✔
717

718
    def visit_Invert(self, node):
1✔
719
        """Allow `~` expressions."""
720
        return self.node_contents_visit(node)
1✔
721

722
    def visit_BinOp(self, node):
1✔
723
        """Allow binary operations."""
724
        return self.node_contents_visit(node)
1✔
725

726
    def visit_Add(self, node):
1✔
727
        """Allow `+` expressions."""
728
        return self.node_contents_visit(node)
1✔
729

730
    def visit_Sub(self, node):
1✔
731
        """Allow `-` expressions."""
732
        return self.node_contents_visit(node)
1✔
733

734
    def visit_Mult(self, node):
1✔
735
        """Allow `*` expressions."""
736
        return self.node_contents_visit(node)
1✔
737

738
    def visit_Div(self, node):
1✔
739
        """Allow `/` expressions."""
740
        return self.node_contents_visit(node)
1✔
741

742
    def visit_FloorDiv(self, node):
1✔
743
        """Allow `//` expressions."""
744
        return self.node_contents_visit(node)
1✔
745

746
    def visit_Mod(self, node):
1✔
747
        """Allow `%` expressions."""
748
        return self.node_contents_visit(node)
1✔
749

750
    def visit_Pow(self, node):
1✔
751
        """Allow `**` expressions."""
752
        return self.node_contents_visit(node)
1✔
753

754
    def visit_LShift(self, node):
1✔
755
        """Allow `<<` expressions."""
756
        return self.node_contents_visit(node)
1✔
757

758
    def visit_RShift(self, node):
1✔
759
        """Allow `>>` expressions."""
760
        return self.node_contents_visit(node)
1✔
761

762
    def visit_BitOr(self, node):
1✔
763
        """Allow `|` expressions."""
764
        return self.node_contents_visit(node)
1✔
765

766
    def visit_BitXor(self, node):
1✔
767
        """Allow `^` expressions."""
768
        return self.node_contents_visit(node)
1✔
769

770
    def visit_BitAnd(self, node):
1✔
771
        """Allow `&` expressions."""
772
        return self.node_contents_visit(node)
1✔
773

774
    def visit_MatMult(self, node):
1✔
775
        """Allow multiplication (`@`)."""
776
        return self.node_contents_visit(node)
1✔
777

778
    def visit_BoolOp(self, node):
1✔
779
        """Allow bool operator without restrictions."""
780
        return self.node_contents_visit(node)
1✔
781

782
    def visit_And(self, node):
1✔
783
        """Allow bool operator `and` without restrictions."""
784
        return self.node_contents_visit(node)
1✔
785

786
    def visit_Or(self, node):
1✔
787
        """Allow bool operator `or` without restrictions."""
788
        return self.node_contents_visit(node)
1✔
789

790
    def visit_Compare(self, node):
1✔
791
        """Allow comparison expressions without restrictions."""
792
        return self.node_contents_visit(node)
1✔
793

794
    def visit_Eq(self, node):
1✔
795
        """Allow == expressions."""
796
        return self.node_contents_visit(node)
1✔
797

798
    def visit_NotEq(self, node):
1✔
799
        """Allow != expressions."""
800
        return self.node_contents_visit(node)
1✔
801

802
    def visit_Lt(self, node):
1✔
803
        """Allow < expressions."""
804
        return self.node_contents_visit(node)
1✔
805

806
    def visit_LtE(self, node):
1✔
807
        """Allow <= expressions."""
808
        return self.node_contents_visit(node)
1✔
809

810
    def visit_Gt(self, node):
1✔
811
        """Allow > expressions."""
812
        return self.node_contents_visit(node)
1✔
813

814
    def visit_GtE(self, node):
1✔
815
        """Allow >= expressions."""
816
        return self.node_contents_visit(node)
1✔
817

818
    def visit_Is(self, node):
1✔
819
        """Allow `is` expressions."""
820
        return self.node_contents_visit(node)
1✔
821

822
    def visit_IsNot(self, node):
1✔
823
        """Allow `is not` expressions."""
824
        return self.node_contents_visit(node)
1✔
825

826
    def visit_In(self, node):
1✔
827
        """Allow `in` expressions."""
828
        return self.node_contents_visit(node)
1✔
829

830
    def visit_NotIn(self, node):
1✔
831
        """Allow `not in` expressions."""
832
        return self.node_contents_visit(node)
1✔
833

834
    def visit_Call(self, node):
1✔
835
        """Checks calls with '*args' and '**kwargs'.
836

837
        Note: The following happens only if '*args' or '**kwargs' is used.
838

839
        Transfroms 'foo(<all the possible ways of args>)' into
840
        _apply_(foo, <all the possible ways for args>)
841

842
        The thing is that '_apply_' has only '*args', '**kwargs', so it gets
843
        Python to collapse all the myriad ways to call functions
844
        into one manageable from.
845

846
        From there, '_apply_()' wraps args and kws in guarded accessors,
847
        then calls the function, returning the value.
848
        """
849

850
        if isinstance(node.func, ast.Name):
1✔
851
            if node.func.id == 'exec':
1✔
852
                self.error(node, 'Exec calls are not allowed.')
1✔
853
            elif node.func.id == 'eval':
1✔
854
                self.error(node, 'Eval calls are not allowed.')
1✔
855

856
        needs_wrap = False
1✔
857

858
        for pos_arg in node.args:
1✔
859
            if isinstance(pos_arg, ast.Starred):
1✔
860
                needs_wrap = True
1✔
861

862
        for keyword_arg in node.keywords:
1✔
863
            if keyword_arg.arg is None:
1✔
864
                needs_wrap = True
1✔
865

866
        node = self.node_contents_visit(node)
1✔
867

868
        if not needs_wrap:
1✔
869
            return node
1✔
870

871
        node.args.insert(0, node.func)
1✔
872
        node.func = ast.Name('_apply_', ast.Load())
1✔
873
        copy_locations(node.func, node.args[0])
1✔
874
        return node
1✔
875

876
    def visit_keyword(self, node):
1✔
877
        """
878

879
        """
880
        return self.node_contents_visit(node)
1✔
881

882
    def visit_IfExp(self, node):
1✔
883
        """Allow `if` expressions without restrictions."""
884
        return self.node_contents_visit(node)
1✔
885

886
    def visit_Attribute(self, node):
1✔
887
        """Checks and mutates attribute access/assignment.
888

889
        'a.b' becomes '_getattr_(a, "b")'
890
        'a.b = c' becomes '_write_(a).b = c'
891
        'del a.b' becomes 'del _write_(a).b'
892

893
        The _write_ function should return a security proxy.
894
        """
895
        if node.attr.startswith('_') and node.attr != '_':
1✔
896
            self.error(
1✔
897
                node,
898
                '"{name}" is an invalid attribute name because it starts '
899
                'with "_".'.format(name=node.attr))
900

901
        if node.attr.endswith('__roles__'):
1✔
902
            self.error(
1✔
903
                node,
904
                '"{name}" is an invalid attribute name because it ends '
905
                'with "__roles__".'.format(name=node.attr))
906

907
        if node.attr in INSPECT_ATTRIBUTES:
1✔
908
            self.error(
1✔
909
                node,
910
                f'"{node.attr}" is a restricted name,'
911
                ' that is forbidden to access in RestrictedPython.',
912
            )
913

914
        if isinstance(node.ctx, ast.Load):
1✔
915
            node = self.node_contents_visit(node)
1✔
916
            new_node = ast.Call(
1✔
917
                func=ast.Name('_getattr_', ast.Load()),
918
                args=[node.value, astStr(node.attr)],
919
                keywords=[])
920

921
            copy_locations(new_node, node)
1✔
922
            return new_node
1✔
923

924
        elif isinstance(node.ctx, (ast.Store, ast.Del)):
1✔
925
            node = self.node_contents_visit(node)
1✔
926
            new_value = ast.Call(
1✔
927
                func=ast.Name('_write_', ast.Load()),
928
                args=[node.value],
929
                keywords=[])
930

931
            copy_locations(new_value, node.value)
1✔
932
            node.value = new_value
1✔
933
            return node
1✔
934

935
        else:  # pragma: no cover
936
            # Impossible Case only ctx Load, Store and Del are defined in ast.
937
            raise NotImplementedError(
938
                f"Unknown ctx type: {type(node.ctx)}")
939

940
    # Subscripting
941

942
    def visit_Subscript(self, node):
1✔
943
        """Transforms all kinds of subscripts.
944

945
        'foo[bar]' becomes '_getitem_(foo, bar)'
946
        'foo[:ab]' becomes '_getitem_(foo, slice(None, ab, None))'
947
        'foo[ab:]' becomes '_getitem_(foo, slice(ab, None, None))'
948
        'foo[a:b]' becomes '_getitem_(foo, slice(a, b, None))'
949
        'foo[a:b:c]' becomes '_getitem_(foo, slice(a, b, c))'
950
        'foo[a, b:c] becomes '_getitem_(foo, (a, slice(b, c, None)))'
951
        'foo[a] = c' becomes '_write_(foo)[a] = c'
952
        'del foo[a]' becomes 'del _write_(foo)[a]'
953

954
        The _write_ function should return a security proxy.
955
        """
956
        node = self.node_contents_visit(node)
1✔
957

958
        # 'AugStore' and 'AugLoad' are defined in 'Python.asdl' as possible
959
        # 'expr_context'. However, according to Python/ast.c
960
        # they are NOT used by the implementation => No need to worry here.
961
        # Instead ast.c creates 'AugAssign' nodes, which can be visited.
962

963
        if isinstance(node.ctx, ast.Load):
1✔
964
            new_node = ast.Call(
1✔
965
                func=ast.Name('_getitem_', ast.Load()),
966
                args=[node.value, self.transform_slice(node.slice)],
967
                keywords=[])
968

969
            copy_locations(new_node, node)
1✔
970
            return new_node
1✔
971

972
        elif isinstance(node.ctx, (ast.Del, ast.Store)):
1✔
973
            new_value = ast.Call(
1✔
974
                func=ast.Name('_write_', ast.Load()),
975
                args=[node.value],
976
                keywords=[])
977

978
            copy_locations(new_value, node)
1✔
979
            node.value = new_value
1✔
980
            return node
1✔
981

982
        else:  # pragma: no cover
983
            # Impossible Case only ctx Load, Store and Del are defined in ast.
984
            raise NotImplementedError(
985
                f"Unknown ctx type: {type(node.ctx)}")
986

987
    def visit_Index(self, node):
1✔
988
        """
989

990
        """
991
        return self.node_contents_visit(node)
×
992

993
    def visit_Slice(self, node):
1✔
994
        """
995

996
        """
997
        return self.node_contents_visit(node)
1✔
998

999
    def visit_ExtSlice(self, node):
1✔
1000
        """
1001

1002
        """
1003
        return self.node_contents_visit(node)
×
1004

1005
    # Comprehensions
1006

1007
    def visit_ListComp(self, node):
1✔
1008
        """
1009

1010
        """
1011
        return self.node_contents_visit(node)
1✔
1012

1013
    def visit_SetComp(self, node):
1✔
1014
        """
1015

1016
        """
1017
        return self.node_contents_visit(node)
1✔
1018

1019
    def visit_GeneratorExp(self, node):
1✔
1020
        """
1021

1022
        """
1023
        return self.node_contents_visit(node)
1✔
1024

1025
    def visit_DictComp(self, node):
1✔
1026
        """
1027

1028
        """
1029
        return self.node_contents_visit(node)
1✔
1030

1031
    def visit_comprehension(self, node):
1✔
1032
        """
1033

1034
        """
1035
        return self.guard_iter(node)
1✔
1036

1037
    # Statements
1038

1039
    def visit_Assign(self, node):
1✔
1040
        """
1041

1042
        """
1043

1044
        node = self.node_contents_visit(node)
1✔
1045

1046
        if not any(isinstance(t, ast.Tuple) for t in node.targets):
1✔
1047
            return node
1✔
1048

1049
        # Handle sequence unpacking.
1050
        # For briefness this example omits cleanup of the temporary variables.
1051
        # Check 'transform_tuple_assign' how its done.
1052
        #
1053
        # - Single target (with nested support)
1054
        # (a, (b, (c, d))) = <exp>
1055
        # is converted to
1056
        # (a, t1) = _getiter_(<exp>)
1057
        # (b, t2) = _getiter_(t1)
1058
        # (c, d) = _getiter_(t2)
1059
        #
1060
        # - Multi targets
1061
        # (a, b) = (c, d) = <exp>
1062
        # is converted to
1063
        # (c, d) = _getiter_(<exp>)
1064
        # (a, b) = _getiter_(<exp>)
1065
        # Why is this valid ? The original bytecode for this multi targets
1066
        # behaves the same way.
1067

1068
        # ast.NodeTransformer works with list results.
1069
        # He injects it at the right place of the node's parent statements.
1070
        new_nodes = []
1✔
1071

1072
        # python fills the right most target first.
1073
        for target in reversed(node.targets):
1✔
1074
            if isinstance(target, ast.Tuple):
1✔
1075
                wrapper = ast.Assign(
1✔
1076
                    targets=[target],
1077
                    value=self.protect_unpack_sequence(target, node.value))
1078
                new_nodes.append(wrapper)
1✔
1079
            else:
1080
                new_node = ast.Assign(targets=[target], value=node.value)
1✔
1081
                new_nodes.append(new_node)
1✔
1082

1083
        for new_node in new_nodes:
1✔
1084
            copy_locations(new_node, node)
1✔
1085

1086
        return new_nodes
1✔
1087

1088
    def visit_AugAssign(self, node):
1✔
1089
        """Forbid certain kinds of AugAssign
1090

1091
        According to the language reference (and ast.c) the following nodes
1092
        are are possible:
1093
        Name, Attribute, Subscript
1094

1095
        Note that although augmented assignment of attributes and
1096
        subscripts is disallowed, augmented assignment of names (such
1097
        as 'n += 1') is allowed.
1098
        'n += 1' becomes 'n = _inplacevar_("+=", n, 1)'
1099
        """
1100

1101
        node = self.node_contents_visit(node)
1✔
1102

1103
        if isinstance(node.target, ast.Attribute):
1✔
1104
            self.error(
1✔
1105
                node,
1106
                "Augmented assignment of attributes is not allowed.")
1107
            return node
1✔
1108

1109
        elif isinstance(node.target, ast.Subscript):
1✔
1110
            self.error(
1✔
1111
                node,
1112
                "Augmented assignment of object items "
1113
                "and slices is not allowed.")
1114
            return node
1✔
1115

1116
        elif isinstance(node.target, ast.Name):
1✔
1117
            new_node = ast.Assign(
1✔
1118
                targets=[node.target],
1119
                value=ast.Call(
1120
                    func=ast.Name('_inplacevar_', ast.Load()),
1121
                    args=[
1122
                        astStr(IOPERATOR_TO_STR[type(node.op)]),
1123
                        ast.Name(node.target.id, ast.Load()),
1124
                        node.value
1125
                    ],
1126
                    keywords=[]))
1127

1128
            copy_locations(new_node, node)
1✔
1129
            return new_node
1✔
1130
        else:  # pragma: no cover
1131
            # Impossible Case - Only Node Types:
1132
            # * Name
1133
            # * Attribute
1134
            # * Subscript
1135
            # defined, those are checked before.
1136
            raise NotImplementedError(
1137
                f"Unknown target type: {type(node.target)}")
1138

1139
    def visit_Raise(self, node):
1✔
1140
        """Allow `raise` statements without restrictions."""
1141
        return self.node_contents_visit(node)
1✔
1142

1143
    def visit_Assert(self, node):
1✔
1144
        """Allow assert statements without restrictions."""
1145
        return self.node_contents_visit(node)
1✔
1146

1147
    def visit_Delete(self, node):
1✔
1148
        """Allow `del` statements without restrictions."""
1149
        return self.node_contents_visit(node)
1✔
1150

1151
    def visit_Pass(self, node):
1✔
1152
        """Allow `pass` statements without restrictions."""
1153
        return self.node_contents_visit(node)
1✔
1154

1155
    # Imports
1156

1157
    def visit_Import(self, node):
1✔
1158
        """Allow `import` statements with restrictions.
1159
        See check_import_names."""
1160
        return self.check_import_names(node)
1✔
1161

1162
    def visit_ImportFrom(self, node):
1✔
1163
        """Allow `import from` statements with restrictions.
1164
        See check_import_names."""
1165
        return self.check_import_names(node)
1✔
1166

1167
    def visit_alias(self, node):
1✔
1168
        """Allow `as` statements in import and import from statements."""
1169
        return self.node_contents_visit(node)
1✔
1170

1171
    # Control flow
1172

1173
    def visit_If(self, node):
1✔
1174
        """Allow `if` statements without restrictions."""
1175
        return self.node_contents_visit(node)
1✔
1176

1177
    def visit_For(self, node):
1✔
1178
        """Allow `for` statements with some restrictions."""
1179
        return self.guard_iter(node)
1✔
1180

1181
    def visit_While(self, node):
1✔
1182
        """Allow `while` statements."""
1183
        return self.node_contents_visit(node)
1✔
1184

1185
    def visit_Break(self, node):
1✔
1186
        """Allow `break` statements without restrictions."""
1187
        return self.node_contents_visit(node)
1✔
1188

1189
    def visit_Continue(self, node):
1✔
1190
        """Allow `continue` statements without restrictions."""
1191
        return self.node_contents_visit(node)
1✔
1192

1193
    def visit_Try(self, node):
1✔
1194
        """Allow `try` without restrictions."""
1195
        return self.node_contents_visit(node)
1✔
1196

1197
    def visit_TryStar(self, node):
1✔
1198
        """Allow `ExceptionGroup` without restrictions."""
1199
        return self.node_contents_visit(node)
1✔
1200

1201
    def visit_ExceptHandler(self, node):
1✔
1202
        """Protect exception handlers."""
1203
        node = self.node_contents_visit(node)
1✔
1204
        self.check_name(node, node.name)
1✔
1205
        return node
1✔
1206

1207
    def visit_With(self, node):
1✔
1208
        """Protect tuple unpacking on with statements."""
1209
        node = self.node_contents_visit(node)
1✔
1210

1211
        for item in reversed(node.items):
1✔
1212
            if isinstance(item.optional_vars, ast.Tuple):
1✔
1213
                tmp_target, unpack = self.gen_unpack_wrapper(
1✔
1214
                    node,
1215
                    item.optional_vars)
1216

1217
                item.optional_vars = tmp_target
1✔
1218
                node.body.insert(0, unpack)
1✔
1219

1220
        return node
1✔
1221

1222
    def visit_withitem(self, node):
1✔
1223
        """Allow `with` statements (context managers) without restrictions."""
1224
        return self.node_contents_visit(node)
1✔
1225

1226
    # Function and class definitions
1227

1228
    def visit_FunctionDef(self, node):
1✔
1229
        """Allow function definitions (`def`) with some restrictions."""
1230
        self.check_name(node, node.name, allow_magic_methods=True)
1✔
1231
        self.check_function_argument_names(node)
1✔
1232

1233
        with self.print_info.new_print_scope():
1✔
1234
            node = self.node_contents_visit(node)
1✔
1235
            self.inject_print_collector(node)
1✔
1236
        return node
1✔
1237

1238
    def visit_Lambda(self, node):
1✔
1239
        """Allow lambda with some restrictions."""
1240
        self.check_function_argument_names(node)
1✔
1241
        return self.node_contents_visit(node)
1✔
1242

1243
    def visit_arguments(self, node):
1✔
1244
        """
1245

1246
        """
1247
        return self.node_contents_visit(node)
1✔
1248

1249
    def visit_arg(self, node):
1✔
1250
        """
1251

1252
        """
1253
        return self.node_contents_visit(node)
1✔
1254

1255
    def visit_Return(self, node):
1✔
1256
        """Allow `return` statements without restrictions."""
1257
        return self.node_contents_visit(node)
1✔
1258

1259
    def visit_Yield(self, node):
1✔
1260
        """Allow `yield`statements without restrictions."""
1261
        return self.node_contents_visit(node)
1✔
1262

1263
    def visit_YieldFrom(self, node):
1✔
1264
        """Allow `yield`statements without restrictions."""
1265
        return self.node_contents_visit(node)
1✔
1266

1267
    def visit_Global(self, node):
1✔
1268
        """Allow `global` statements without restrictions."""
1269
        return self.node_contents_visit(node)
1✔
1270

1271
    def visit_Nonlocal(self, node):
1✔
1272
        """Deny `nonlocal` statements."""
1273
        self.not_allowed(node)
1✔
1274

1275
    def visit_ClassDef(self, node):
1✔
1276
        """Check the name of a class definition."""
1277
        self.check_name(node, node.name)
1✔
1278
        node = self.node_contents_visit(node)
1✔
1279
        if any(keyword.arg == 'metaclass' for keyword in node.keywords):
1✔
1280
            self.error(
1✔
1281
                node, 'The keyword argument "metaclass" is not allowed.')
1282
        CLASS_DEF = textwrap.dedent('''\
1✔
1283
            class {0.name}(metaclass=__metaclass__):
1284
                pass
1285
        '''.format(node))
1286
        new_class_node = ast.parse(CLASS_DEF).body[0]
1✔
1287
        new_class_node.body = node.body
1✔
1288
        new_class_node.bases = node.bases
1✔
1289
        new_class_node.decorator_list = node.decorator_list
1✔
1290
        return new_class_node
1✔
1291

1292
    def visit_Module(self, node):
1✔
1293
        """Add the print_collector (only if print is used) at the top."""
1294
        node = self.node_contents_visit(node)
1✔
1295

1296
        # Inject the print collector after 'from __future__ import ....'
1297
        position = 0
1✔
1298
        for position, child in enumerate(node.body):  # pragma: no branch
1✔
1299
            if not isinstance(child, ast.ImportFrom):
1✔
1300
                break
1✔
1301

1302
            if not child.module == '__future__':
1✔
1303
                break
1✔
1304

1305
        self.inject_print_collector(node, position)
1✔
1306
        return node
1✔
1307

1308
    # Async und await
1309

1310
    def visit_AsyncFunctionDef(self, node):
1✔
1311
        """Deny async functions."""
1312
        self.not_allowed(node)
1✔
1313

1314
    def visit_Await(self, node):
1✔
1315
        """Deny async functionality."""
1316
        self.not_allowed(node)
1✔
1317

1318
    def visit_AsyncFor(self, node):
1✔
1319
        """Deny async functionality."""
1320
        self.not_allowed(node)
1✔
1321

1322
    def visit_AsyncWith(self, node):
1✔
1323
        """Deny async functionality."""
1324
        self.not_allowed(node)
1✔
1325

1326
    # Assignment expressions (walrus operator ``:=``)
1327
    # New in 3.8
1328
    def visit_NamedExpr(self, node):
1✔
1329
        """Allow assignment expressions under some circumstances."""
1330
        # while the grammar requires ``node.target`` to be a ``Name``
1331
        # the abstract syntax is more permissive and allows an ``expr``.
1332
        # We support only a ``Name``.
1333
        # This is safe as the expression can only add/modify local
1334
        # variables. While this may hide global variables, an
1335
        # (implicitly performed) name check guarantees (as usual)
1336
        # that no essential global variable is hidden.
1337
        node = self.node_contents_visit(node)  # this checks ``node.target``
1✔
1338
        target = node.target
1✔
1339
        if not isinstance(target, ast.Name):
1✔
1340
            self.error(
1✔
1341
                node,
1342
                "Assignment expressions are only allowed for simple targets")
1343
        return node
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc