| #!/usr/bin/env fuchsia-vendored-python |
| # Copyright 2023 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import argparse |
| import contextlib |
| import io |
| import os |
| import re |
| import subprocess |
| import sys |
| import tempfile |
| import unittest |
| from pathlib import Path |
| from unittest import mock |
| |
| import output_leak_scanner |
| |
| from typing import Any, Sequence |
| |
| |
| def _strs(items: Sequence[Any]) -> Sequence[str]: |
| return [str(i) for i in items] |
| |
| |
| def _paths(items: Sequence[Any]) -> Sequence[Path]: |
| return [Path(i) for i in items] |
| |
| |
| def _write_text_file_contents(path: Path, contents: str): |
| with open(path, 'w') as f: |
| f.write(contents) |
| |
| |
| def _write_binary_file_contents(path: Path, contents: bytearray): |
| with open(path, 'wb') as f: |
| f.write(contents) |
| |
| |
| class PathPatternTests(unittest.TestCase): |
| |
| def test_init(self): |
| path = Path('over/the/rainbow') |
| p = output_leak_scanner.PathPattern(path) |
| self.assertEqual(p.text, str(path)) |
| self.assertTrue(p.re_text.search('somewhere/over/the/rainbow')) |
| self.assertTrue(p.re_bin.search(b'somewhere/over/the/rainbow')) |
| |
| def test_equal(self): |
| # different, but equivalent objects |
| self.assertEqual( |
| output_leak_scanner.PathPattern(Path('foo/bar')), |
| output_leak_scanner.PathPattern(Path('foo/bar'))) |
| |
| def test_not_equal(self): |
| self.assertNotEqual( |
| output_leak_scanner.PathPattern(Path('foo/bar')), |
| output_leak_scanner.PathPattern(Path('bar/foo'))) |
| |
| def test_dot_should_never_be_used(self): |
| with self.assertRaises(ValueError): |
| output_leak_scanner.PathPattern(Path()) |
| with self.assertRaises(ValueError): |
| output_leak_scanner.PathPattern(Path('.')) |
| |
| def test_dots_in_paths_are_literal(self): |
| path = Path('build.obj.stuff') |
| p = output_leak_scanner.PathPattern(path) |
| self.assertEqual(p.text, str(path)) |
| self.assertTrue(p.re_text.search('foo/build.obj.stuff/bar')) |
| self.assertFalse(p.re_text.search('foo/build_obj_stuff/bar')) |
| |
| |
| class MainArgParserTests(unittest.TestCase): |
| |
| def test_empty(self): |
| parser = output_leak_scanner._MAIN_ARG_PARSER |
| args = parser.parse_args([]) |
| self.assertIsNone(args.label) |
| self.assertTrue(args.execute) |
| self.assertEqual(args.outputs, []) |
| |
| def test_label(self): |
| label = '//foo:bar' |
| parser = output_leak_scanner._MAIN_ARG_PARSER |
| args = parser.parse_args(['--label', label]) |
| self.assertEqual(args.label, label) |
| |
| def test_execute(self): |
| parser = output_leak_scanner._MAIN_ARG_PARSER |
| args = parser.parse_args(['--execute']) |
| self.assertTrue(args.execute) |
| |
| def test_no_execute(self): |
| parser = output_leak_scanner._MAIN_ARG_PARSER |
| args = parser.parse_args(['--no-execute']) |
| self.assertFalse(args.execute) |
| |
| def test_outputs(self): |
| outputs = ['foo/bar.txt', 'bar/foo.o'] |
| parser = output_leak_scanner._MAIN_ARG_PARSER |
| args = parser.parse_args(outputs) # positional |
| self.assertEqual(args.outputs, _paths(outputs)) |
| |
| |
| class ErrorMsgTests(unittest.TestCase): |
| |
| def test_no_label(self): |
| s = io.StringIO() |
| with contextlib.redirect_stdout(s): |
| output_leak_scanner.error_msg('oops') |
| self.assertIn('oops', s.getvalue()) |
| |
| def test_label(self): |
| s = io.StringIO() |
| with contextlib.redirect_stdout(s): |
| output_leak_scanner.error_msg('oops', label='LABEL') |
| self.assertIn('LABEL', s.getvalue()) |
| self.assertIn('oops', s.getvalue()) |
| |
| |
| class WholeWordPatternTests(unittest.TestCase): |
| |
| def test_add_boundaries(self): |
| self.assertEqual( |
| output_leak_scanner._whole_word_pattern('zyx'), r'\bzyx\b') |
| |
| def test_no_extra_boundaries(self): |
| self.assertEqual( |
| output_leak_scanner._whole_word_pattern(r'\bqwer\b'), r'\bqwer\b') |
| |
| |
| class FileContainsSubpathTests(unittest.TestCase): |
| |
| def test_ignore_nonexistent(self): |
| with tempfile.TemporaryDirectory() as td: |
| self.assertFalse( |
| output_leak_scanner.file_contains_subpath( |
| Path(td) / 'nonexistent', |
| output_leak_scanner.PathPattern(Path('nonexistent')))) |
| |
| def test_ignore_dir(self): |
| with tempfile.TemporaryDirectory() as td: |
| self.assertFalse( |
| output_leak_scanner.file_contains_subpath( |
| Path(td), output_leak_scanner.PathPattern(Path('other')))) |
| |
| def test_text_no_match(self): |
| with tempfile.TemporaryDirectory() as td: |
| tf = (Path(td) / __name__).with_suffix('.txt') |
| _write_text_file_contents(tf, 'a\nb\nc\n') |
| self.assertFalse( |
| output_leak_scanner.file_contains_subpath( |
| tf, output_leak_scanner.PathPattern(Path('def')))) |
| |
| def test_text_match(self): |
| with tempfile.TemporaryDirectory() as td: |
| tf = (Path(td) / __name__).with_suffix('.txt') |
| _write_text_file_contents(tf, 'a\nb\nc\n') |
| self.assertTrue( |
| output_leak_scanner.file_contains_subpath( |
| tf, output_leak_scanner.PathPattern(Path('b')))) |
| |
| def test_text_no_match_partial_word(self): |
| with tempfile.TemporaryDirectory() as td: |
| tf = (Path(td) / __name__).with_suffix('.txt') |
| _write_text_file_contents(tf, 'ab\nbb\nbc\n') |
| self.assertFalse( |
| output_leak_scanner.file_contains_subpath( |
| tf, output_leak_scanner.PathPattern(Path('b')))) |
| self.assertTrue( |
| output_leak_scanner.file_contains_subpath( |
| tf, output_leak_scanner.PathPattern(Path('bb')))) |
| |
| def test_binary_no_match(self): |
| with tempfile.TemporaryDirectory() as td: |
| tf = (Path(td) / __name__).with_suffix('.txt') |
| _write_binary_file_contents(tf, b'\xcc\n\xdd\xee\n\xff\n+abc+\n') |
| self.assertFalse( |
| output_leak_scanner.file_contains_subpath( |
| tf, output_leak_scanner.PathPattern(Path('pdq')))) |
| |
| def test_binary_match(self): |
| with tempfile.TemporaryDirectory() as td: |
| tf = (Path(td) / __name__).with_suffix('.txt') |
| _write_binary_file_contents(tf, b'\xcc\n\xdd\xee\n\xff\n+abc+\n') |
| self.assertTrue( |
| output_leak_scanner.file_contains_subpath( |
| tf, output_leak_scanner.PathPattern(Path('abc')))) |
| |
| def test_binary_no_match_partial(self): |
| with tempfile.TemporaryDirectory() as td: |
| tf = (Path(td) / __name__).with_suffix('.txt') |
| _write_binary_file_contents(tf, b'\xcc\n\xdd\xee\n\xff\n+abc+\n') |
| self.assertFalse( |
| output_leak_scanner.file_contains_subpath( |
| tf, output_leak_scanner.PathPattern(Path('b')))) |
| |
| |
| class PathsWithBuildDirLeaksTests(unittest.TestCase): |
| |
| def test_negatives(self): |
| build_dir = Path('build/me/here') |
| cases = _paths( |
| [ |
| 'build/me/there', 'out/default', 'rebuild/me/here', |
| 'build/me/heretic' |
| ]) |
| pattern = output_leak_scanner.PathPattern(build_dir) |
| actual = list( |
| output_leak_scanner.paths_with_build_dir_leaks( |
| cases, pattern.re_text)) |
| self.assertEqual(actual, []) |
| |
| def test_positives(self): |
| build_dir = Path('build/me/here') |
| cases = _paths( |
| [ |
| '/tmp/build/me/here', 'build/me/here', |
| 'build/me/here/foo/bar.txt' |
| ]) |
| pattern = output_leak_scanner.PathPattern(build_dir) |
| actual = set( |
| output_leak_scanner.paths_with_build_dir_leaks( |
| cases, pattern.re_text)) |
| self.assertEqual(actual, set(cases)) |
| |
| |
| class TokensWithBuildDirLeaks(unittest.TestCase): |
| |
| def test_negatives(self): |
| build_dir = Path('build/here') |
| cases = [ |
| '-f', 'build/not/here', 'out/default', 'rebuild/here', |
| 'build/heretic' |
| ] |
| pattern = output_leak_scanner.PathPattern(build_dir) |
| actual = list( |
| output_leak_scanner.tokens_with_build_dir_leaks( |
| cases, pattern.re_text)) |
| self.assertEqual(actual, []) |
| |
| def test_positives(self): |
| build_dir = Path('build/here') |
| cases = ['/work/out/build/here', 'build/here', 'build/here/out.txt'] |
| pattern = output_leak_scanner.PathPattern(build_dir) |
| actual = list( |
| output_leak_scanner.tokens_with_build_dir_leaks( |
| cases, pattern.re_text)) |
| self.assertEqual(actual, cases) |
| |
| def test_exceptions(self): |
| build_dir = Path('build/here') |
| cases = [ |
| f'-fdebug-prefix-map=/some/where/{build_dir}', |
| f'-ffile-prefix-map=/some/where/{build_dir}/foo', |
| f'-fmacro-prefix-map=/some/where/{build_dir}/bar', |
| f'-fcoverage-prefix-map=/some/where/{build_dir}', |
| ] |
| pattern = output_leak_scanner.PathPattern(build_dir) |
| actual = list( |
| output_leak_scanner.tokens_with_build_dir_leaks( |
| cases, pattern.re_text)) |
| self.assertEqual(actual, []) |
| |
| |
| class PreflightChecksTests(unittest.TestCase): |
| |
| def test_no_findings(self): |
| build_dir_pattern = output_leak_scanner.PathPattern(Path('any')) |
| stdout = io.StringIO() |
| with contextlib.redirect_stdout(stdout): |
| return_code = output_leak_scanner.preflight_checks( |
| paths=[], command=[], pattern=build_dir_pattern) |
| self.assertEqual(return_code, 0) |
| self.assertEqual(stdout.getvalue(), '') # quiet |
| |
| def test_path_leak(self): |
| build_dir = Path('as/df') |
| build_dir_pattern = output_leak_scanner.PathPattern(build_dir) |
| stdout = io.StringIO() |
| bad = build_dir / 'g.h' |
| with contextlib.redirect_stdout(stdout): |
| return_code = output_leak_scanner.preflight_checks( |
| paths=[bad], command=[], pattern=build_dir_pattern) |
| self.assertEqual(return_code, 1) |
| message = stdout.getvalue() |
| self.assertIn("Error", message) |
| self.assertIn(str(bad), message) |
| |
| def test_command_leak(self): |
| build_dir = Path('zs/df') |
| build_dir_pattern = output_leak_scanner.PathPattern(build_dir) |
| stdout = io.StringIO() |
| bad = build_dir / 'g.h' |
| with contextlib.redirect_stdout(stdout): |
| return_code = output_leak_scanner.preflight_checks( |
| paths=[], |
| command=_strs(['touch', bad]), |
| pattern=build_dir_pattern) |
| self.assertEqual(return_code, 1) |
| message = stdout.getvalue() |
| self.assertIn("Error", message) |
| self.assertIn(str(bad), message) |
| |
| |
| class PostflightChecksTests(unittest.TestCase): |
| |
| def test_no_findings(self): |
| build_dir_pattern = output_leak_scanner.PathPattern(Path('out/f/b')) |
| stdout = io.StringIO() |
| with contextlib.redirect_stdout(stdout): |
| with mock.patch.object(output_leak_scanner, 'file_contains_subpath', |
| return_value=False) as mock_search: |
| return_code = output_leak_scanner.postflight_checks( |
| outputs=['foo.o'], subpath=build_dir_pattern) |
| self.assertEqual(return_code, 0) |
| mock_search.assert_called_once() |
| self.assertEqual(stdout.getvalue(), '') |
| |
| def test_has_findings(self): |
| build_dir = Path('out/f/b') |
| build_dir_pattern = output_leak_scanner.PathPattern(build_dir) |
| stdout = io.StringIO() |
| with contextlib.redirect_stdout(stdout): |
| with mock.patch.object(output_leak_scanner, 'file_contains_subpath', |
| return_value=True) as mock_search: |
| return_code = output_leak_scanner.postflight_checks( |
| outputs=['foo.o'], subpath=build_dir_pattern) |
| self.assertEqual(return_code, 1) |
| mock_search.assert_called_once() |
| message = stdout.getvalue() |
| self.assertIn("Error", message) |
| self.assertIn(str(build_dir), message) |
| |
| |
| class ScanLeaksTests(unittest.TestCase): |
| |
| def test_missing_ddash(self): |
| stdout = io.StringIO() |
| with contextlib.redirect_stdout(stdout): |
| # --help will trigger call to sys.exit, which we |
| # want to avoid in testing. |
| with mock.patch.object(sys, 'exit') as mock_exit: |
| return_code = output_leak_scanner.scan_leaks( |
| [], |
| exec_root=Path('/home'), |
| working_dir=Path('/home/build'), |
| ) |
| mock_exit.assert_called_once() |
| self.assertEqual(return_code, 1) |
| message = stdout.getvalue() |
| self.assertIn("Error", message) |
| self.assertIn("'--' is missing", message) |
| |
| def test_skip_checking_subdir_dot(self): |
| stdout = io.StringIO() |
| with contextlib.redirect_stdout(stdout): |
| with mock.patch.object(output_leak_scanner, 'preflight_checks', |
| return_value=0) as mock_pre_check: |
| with mock.patch.object(output_leak_scanner, 'postflight_checks', |
| return_value=0) as mock_post_check: |
| with mock.patch.object(subprocess, 'call', |
| return_value=0) as mock_call: |
| return_code = output_leak_scanner.scan_leaks( |
| ['--', 'touch', 'down.txt'], |
| exec_root=Path('/home/project'), |
| working_dir=Path('/home/project'), |
| ) |
| mock_pre_check.assert_not_called() |
| mock_post_check.assert_not_called() |
| mock_call.assert_called_once() |
| self.assertEqual(return_code, 0) |
| message = stdout.getvalue() |
| self.assertEqual(message, '') |
| |
| def test_no_execute(self): |
| stdout = io.StringIO() |
| with contextlib.redirect_stdout(stdout): |
| with mock.patch.object(output_leak_scanner, 'preflight_checks', |
| return_value=0) as mock_pre_check: |
| with mock.patch.object(subprocess, 'call', |
| return_value=1) as mock_call: |
| return_code = output_leak_scanner.scan_leaks( |
| ['--no-execute', '--', 'touch', 'down.txt'], |
| exec_root=Path('/home'), |
| working_dir=Path('/home/build'), |
| ) |
| mock_pre_check.assert_called_once() |
| mock_call.assert_not_called() |
| self.assertEqual(return_code, 0) |
| message = stdout.getvalue() |
| self.assertEqual(message, '') |
| |
| def test_execute_command_error(self): |
| stdout = io.StringIO() |
| output = 'down.txt' |
| with contextlib.redirect_stdout(stdout): |
| with mock.patch.object(subprocess, 'call', |
| return_value=2) as mock_call: |
| return_code = output_leak_scanner.scan_leaks( |
| [output, '--', 'touch', output], |
| exec_root=Path('/home'), |
| working_dir=Path('/home/build'), |
| ) |
| mock_call.assert_called_once() |
| self.assertEqual(return_code, 2) |
| message = stdout.getvalue() |
| self.assertEqual(message, '') |
| |
| def test_execute_success_preflight_error(self): |
| stdout = io.StringIO() |
| output = 'down.txt' |
| with contextlib.redirect_stdout(stdout): |
| with mock.patch.object(output_leak_scanner, 'preflight_checks', |
| return_value=1) as mock_pre_check: |
| with mock.patch.object(output_leak_scanner, 'postflight_checks', |
| return_value=0) as mock_post_check: |
| with mock.patch.object(subprocess, 'call', |
| return_value=0) as mock_call: |
| return_code = output_leak_scanner.scan_leaks( |
| [output, '--', 'touch', output], |
| exec_root=Path('/home'), |
| working_dir=Path('/home/build'), |
| ) |
| mock_pre_check.assert_called_once() |
| mock_call.assert_called_once() |
| mock_post_check.assert_called_once() |
| self.assertEqual(return_code, 1) |
| |
| def test_execute_success_postflight_error(self): |
| stdout = io.StringIO() |
| output = Path('down.txt') |
| with contextlib.redirect_stdout(stdout): |
| with mock.patch.object(output_leak_scanner, 'preflight_checks', |
| return_value=0) as mock_pre_check: |
| with mock.patch.object(output_leak_scanner, 'postflight_checks', |
| return_value=1) as mock_post_check: |
| with mock.patch.object(subprocess, 'call', |
| return_value=0) as mock_call: |
| return_code = output_leak_scanner.scan_leaks( |
| _strs([output, '--', 'touch', output]), |
| exec_root=Path('/home'), |
| working_dir=Path('/home/build'), |
| ) |
| mock_pre_check.assert_called_once() |
| mock_call.assert_called_once() |
| mock_post_check.assert_called_once() |
| self.assertEqual(return_code, 1) |
| |
| def test_execute_success_scans_clean(self): |
| stdout = io.StringIO() |
| output = Path('down.txt') |
| with contextlib.redirect_stdout(stdout): |
| with mock.patch.object(output_leak_scanner, 'preflight_checks', |
| return_value=0) as mock_pre_check: |
| with mock.patch.object(output_leak_scanner, 'postflight_checks', |
| return_value=0) as mock_post_check: |
| with mock.patch.object(subprocess, 'call', |
| return_value=0) as mock_call: |
| return_code = output_leak_scanner.scan_leaks( |
| _strs([output, '--', 'touch', output]), |
| exec_root=Path('/home'), |
| working_dir=Path('/home/build'), |
| ) |
| mock_pre_check.assert_called_once() |
| mock_call.assert_called_once() |
| mock_post_check.assert_called_once() |
| self.assertEqual(return_code, 0) |
| |
| |
| if __name__ == '__main__': |
| unittest.main() |