|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +"""#2306: every ``bash`` a test starts skips the startup files, as a runner's does. |
| 3 | +
|
| 4 | +A ``bash -c`` whose parent is a top-level shell sources :file:`~/.bashrc` when |
| 5 | +``SSH_CLIENT`` is set or its stdin is a socket. Run from an SSH session, that |
| 6 | +file's output and ``PATH`` leak into the step under test: a stray line in output a |
| 7 | +test compares, or the real ``gh`` found ahead of a stub. GitHub Actions runs a |
| 8 | +``shell: bash`` step as ``bash --noprofile --norc``, and the tests run theirs |
| 9 | +through :func:`tests._support.bash_argv`, which does the same. Pinned here: |
| 10 | +
|
| 11 | +* :class:`TestTheHelperSkipsTheStartupFiles`: with a socket on stdin and |
| 12 | + ``SSH_CLIENT`` set, a plain ``bash -c`` sources a stand-in :file:`~/.bashrc`, and |
| 13 | + the helper's argv does not; |
| 14 | +* :class:`TestEveryBashGoesThroughTheHelper`: no module under :file:`tests/` spells |
| 15 | + out a ``bash`` argv of its own. This module is exempt, since its control run is |
| 16 | + the plain ``bash -c`` the others must not be. |
| 17 | +
|
| 18 | +""" |
| 19 | + |
| 20 | +from __future__ import annotations |
| 21 | + |
| 22 | +import ast |
| 23 | +import os |
| 24 | +import pathlib |
| 25 | +import shutil |
| 26 | +import socket |
| 27 | +import subprocess # nosec: B404 |
| 28 | +import tempfile |
| 29 | +import unittest |
| 30 | + |
| 31 | +from tests import _support |
| 32 | + |
| 33 | +#: Test tier (#1958): reads the test sources and runs ``bash``. |
| 34 | +__tier__ = 'LATEST' |
| 35 | + |
| 36 | +TESTS = pathlib.Path(__file__).resolve().parents[1] |
| 37 | +BASH = shutil.which('bash') |
| 38 | + |
| 39 | +#: What the stand-in :file:`~/.bashrc` prints, so a run that sourced it shows it. |
| 40 | +MARKER = 'pcapkit-2306-bashrc-ran' |
| 41 | + |
| 42 | + |
| 43 | +@unittest.skipIf(os.name == 'nt' or BASH is None, 'needs bash and a POSIX socket on stdin') |
| 44 | +class TestTheHelperSkipsTheStartupFiles(unittest.TestCase): |
| 45 | + """The flags :func:`~tests._support.bash_argv` adds are what keeps the bashrc out.""" |
| 46 | + |
| 47 | + def setUp(self) -> None: |
| 48 | + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-2306-') # pylint: disable=consider-using-with |
| 49 | + self.addCleanup(tmp.cleanup) |
| 50 | + home = pathlib.Path(tmp.name) |
| 51 | + (home / '.bashrc').write_text(f'echo {MARKER}\n', encoding='utf-8') |
| 52 | + # SHLVL 0 makes the child a top-level shell, as one sshd starts is. |
| 53 | + self.env = {'HOME': str(home), 'PATH': os.environ.get('PATH', ''), |
| 54 | + 'SSH_CLIENT': '192.0.2.1 50000 22', 'SHLVL': '0'} |
| 55 | + |
| 56 | + def run_bash(self, argv: 'list[str]') -> 'str': |
| 57 | + ours, theirs = socket.socketpair() |
| 58 | + self.addCleanup(ours.close) |
| 59 | + self.addCleanup(theirs.close) |
| 60 | + result = subprocess.run(argv, stdin=theirs, env=self.env, capture_output=True, # nosec: B603 |
| 61 | + text=True, check=False, timeout=_support.scale_timeout(30)) |
| 62 | + self.assertEqual(result.returncode, 0, result.stderr) |
| 63 | + return result.stdout |
| 64 | + |
| 65 | + def test_a_plain_bash_sources_the_bashrc(self) -> None: |
| 66 | + self.assertIn(MARKER, self.run_bash([str(BASH), '-c', 'echo step'])) |
| 67 | + |
| 68 | + def test_the_helper_does_not(self) -> None: |
| 69 | + self.assertEqual(self.run_bash(_support.bash_argv('-e', '-c', 'echo step', executable=str(BASH))), 'step\n') |
| 70 | + |
| 71 | + |
| 72 | +def names_bash(node: 'ast.expr') -> 'bool': |
| 73 | + """Whether ``node`` is ``'bash'``, a path to it, or ``BASH`` as the tests resolve it.""" |
| 74 | + if isinstance(node, ast.Constant) and isinstance(node.value, str): |
| 75 | + return node.value.replace('\\', '/').rsplit('/', 1)[-1] in ('bash', 'bash.exe') |
| 76 | + if isinstance(node, ast.Name): |
| 77 | + return node.id == 'BASH' |
| 78 | + if isinstance(node, ast.Call) and node.args: |
| 79 | + callee = node.func.id if isinstance(node.func, ast.Name) else getattr(node.func, 'attr', None) |
| 80 | + return callee in ('str', 'which') and names_bash(node.args[0]) |
| 81 | + if isinstance(node, ast.BoolOp): |
| 82 | + return any(names_bash(value) for value in node.values) |
| 83 | + return False |
| 84 | + |
| 85 | + |
| 86 | +def is_bash_argv(node: 'ast.AST') -> 'bool': |
| 87 | + """A list or tuple opening with a ``bash``, and not a list of tool names.""" |
| 88 | + if not (isinstance(node, (ast.List, ast.Tuple)) and len(node.elts) > 1 and names_bash(node.elts[0])): |
| 89 | + return False |
| 90 | + return not all(isinstance(item, ast.Constant) |
| 91 | + and (item.value is None or isinstance(item.value, str) and not item.value.startswith('-')) |
| 92 | + for item in node.elts[1:]) |
| 93 | + |
| 94 | + |
| 95 | +class TestEveryBashGoesThroughTheHelper(unittest.TestCase): |
| 96 | + """A ``bash`` argv written out by hand is one that can drop the flags.""" |
| 97 | + |
| 98 | + def test_no_module_spells_out_a_bash_argv(self) -> None: |
| 99 | + found = [] |
| 100 | + for path in sorted(TESTS.rglob('*.py')): |
| 101 | + if path.resolve() == pathlib.Path(__file__).resolve(): |
| 102 | + continue |
| 103 | + tree = ast.parse(path.read_text(encoding='utf-8'), str(path)) |
| 104 | + found.extend(f'{path.relative_to(TESTS.parent).as_posix()}:{node.lineno}: {ast.unparse(node)}' |
| 105 | + for node in ast.walk(tree) if is_bash_argv(node)) |
| 106 | + self.assertEqual(found, [], 'build these with tests._support.bash_argv') |
| 107 | + |
| 108 | + def test_the_scan_sees_the_shapes_it_replaced(self) -> None: |
| 109 | + for source in ("['bash', '-e', '-c', script]", "(str(BASH), '-e')", "[BASH or 'bash', *flags]", |
| 110 | + "['/bin/bash', str(path), py]", "[str(shutil.which('bash')), '-c', 'true']"): |
| 111 | + with self.subTest(source=source): |
| 112 | + self.assertTrue(is_bash_argv(ast.parse(source, mode='eval').body)) |
| 113 | + for source in ("('bash', 'timeout', 'prlimit')", "('bash', None)", "bash_argv('-e', '-c', script)"): |
| 114 | + with self.subTest(source=source): |
| 115 | + self.assertFalse(is_bash_argv(ast.parse(source, mode='eval').body)) |
| 116 | + |
| 117 | + |
| 118 | +if __name__ == '__main__': |
| 119 | + unittest.main() |
0 commit comments