diff --git a/fdroidserver/__main__.py b/fdroidserver/__main__.py index 7ffc6b7e..770d8c13 100755 --- a/fdroidserver/__main__.py +++ b/fdroidserver/__main__.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # # fdroidserver/__main__.py - part of the FDroid server tools +# Copyright (C) 2020 Michael Pöhn # Copyright (C) 2010-2015, Ciaran Gultnieks, ciaran@ciarang.com # Copyright (C) 2013-2014 Daniel Marti # @@ -17,9 +18,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import re import sys import os import locale +import pkgutil import logging import fdroidserver.common @@ -29,7 +32,7 @@ from argparse import ArgumentError from collections import OrderedDict -commands = OrderedDict([ +COMMANDS = OrderedDict([ ("build", _("Build a package from source")), ("init", _("Quickly start a new repository")), ("publish", _("Sign and place packages in the repo")), @@ -54,25 +57,85 @@ commands = OrderedDict([ ]) -def print_help(): +def print_help(available_plugins=None): print(_("usage: ") + _("fdroid [] [-h|--help|--version|]")) print("") print(_("Valid commands are:")) - for cmd, summary in commands.items(): + for cmd, summary in COMMANDS.items(): print(" " + cmd + ' ' * (15 - len(cmd)) + summary) + if available_plugins: + print(_('commands from plugin modules:')) + for command in sorted(available_plugins.keys()): + print(' {:15}{}'.format(command, available_plugins[command]['summary'])) print("") +def preparse_plugin(module_name, module_dir): + """simple regex based parsing for plugin scripts, + so we don't have to import them when we just need the summary, + but not plan on executing this particular plugin.""" + if '.' in module_name: + raise ValueError("No '.' allowed in fdroid plugin modules: '{}'" + .format(module_name)) + path = os.path.join(module_dir, module_name + '.py') + if not os.path.isfile(path): + path = os.path.join(module_dir, module_name, '__main__.py') + if not os.path.isfile(path): + raise ValueError("unable to find main plugin script " + "for module '{n}' ('{d}')" + .format(n=module_name, + d=module_dir)) + summary = None + main = None + with open(path, 'r', encoding='utf-8') as f: + re_main = re.compile(r'^(\s*def\s+main\s*\(.*\)\s*:' + r'|\s*main\s*=\s*lambda\s*:.+)$') + re_summary = re.compile(r'^\s*fdroid_summary\s*=\s["\'](?P.+)["\']$') + for line in f: + m_summary = re_summary.match(line) + if m_summary: + summary = m_summary.group('text') + if re_main.match(line): + main = True + + if summary is None: + raise NameError("could not find 'fdroid_summary' in: '{}' plugin" + .format(module_name)) + if main is None: + raise NameError("could not find 'main' function in: '{}' plugin" + .format(module_name)) + return {'name': module_name, 'summary': summary} + + +def find_plugins(): + found_plugins = [{'name': x[1], 'dir': x[0].path} for x in pkgutil.iter_modules() if x[1].startswith('fdroid_')] + plugin_infos = {} + for plugin_def in found_plugins: + command_name = plugin_def['name'][7:] + try: + plugin_infos[command_name] = preparse_plugin(plugin_def['name'], + plugin_def['dir']) + except Exception as e: + # We need to keep module lookup fault tolerant because buggy + # modules must not prevent fdroidserver from functioning + if len(sys.argv) > 1 and sys.argv[1] == command_name: + # only raise exeption when a user specifies the broken + # plugin in explicitly in command line + raise e + return plugin_infos + + def main(): + available_plugins = find_plugins() if len(sys.argv) <= 1: - print_help() + print_help(available_plugins=available_plugins) sys.exit(0) command = sys.argv[1] - if command not in commands: + if command not in COMMANDS and command not in available_plugins.keys(): if command in ('-h', '--help'): - print_help() + print_help(available_plugins=available_plugins) sys.exit(0) elif command == '--version': output = _('no version info found!') @@ -99,11 +162,11 @@ def main(): else: from pkg_resources import get_distribution output = get_distribution('fdroidserver').version + '\n' - print(output), + print(output) sys.exit(0) else: print(_("Command '%s' not recognised.\n" % command)) - print_help() + print_help(available_plugins=available_plugins) sys.exit(1) verbose = any(s in sys.argv for s in ['-v', '--verbose']) @@ -133,7 +196,10 @@ def main(): sys.argv[0] += ' ' + command del sys.argv[1] - mod = __import__('fdroidserver.' + command, None, None, [command]) + if command in COMMANDS.keys(): + mod = __import__('fdroidserver.' + command, None, None, [command]) + else: + mod = __import__(available_plugins[command]['name'], None, None, [command]) system_langcode, system_encoding = locale.getdefaultlocale() if system_encoding is None or system_encoding.lower() not in ('utf-8', 'utf8'): diff --git a/tests/main.TestCase b/tests/main.TestCase index 8621cfcc..e05efd95 100755 --- a/tests/main.TestCase +++ b/tests/main.TestCase @@ -4,8 +4,12 @@ import inspect import optparse import os import sys +import pkgutil +import textwrap import unittest +import tempfile from unittest import mock +from testcommon import TmpCwd, TmpPyPath localmodule = os.path.realpath( os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '..')) @@ -17,12 +21,12 @@ from fdroidserver import common import fdroidserver.__main__ -class FdroidTest(unittest.TestCase): +class MainTest(unittest.TestCase): '''this tests fdroid.py''' - def test_commands(self): + def test_COMMANDS_check(self): """make sure the built in sub-command defs didn't change unintentionally""" - self.assertListEqual([x for x in fdroidserver.__main__.commands.keys()], + self.assertListEqual([x for x in fdroidserver.__main__.COMMANDS.keys()], ['build', 'init', 'publish', @@ -49,18 +53,169 @@ class FdroidTest(unittest.TestCase): co = mock.Mock() with mock.patch('sys.argv', ['', 'init', '-h']): with mock.patch('fdroidserver.init.main', co): - with mock.patch('sys.exit', lambda x: None): + with mock.patch('sys.exit') as exit_mock: fdroidserver.__main__.main() + # note: this is sloppy, if `init` changes + # this might need changing too + exit_mock.assert_called_once_with(0) co.assert_called_once_with() def test_call_deploy(self): co = mock.Mock() with mock.patch('sys.argv', ['', 'deploy', '-h']): with mock.patch('fdroidserver.server.main', co): - with mock.patch('sys.exit', lambda x: None): + with mock.patch('sys.exit') as exit_mock: fdroidserver.__main__.main() + # note: this is sloppy, if `deploy` changes + # this might need changing too + exit_mock.assert_called_once_with(0) co.assert_called_once_with() + def test_find_plugins(self): + with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): + with open('fdroid_testy1.py', 'w') as f: + f.write(textwrap.dedent("""\ + fdroid_summary = "ttt" + main = lambda: 'all good'""")) + with TmpPyPath(tmpdir): + plugins = fdroidserver.__main__.find_plugins() + self.assertIn('testy1', plugins.keys()) + self.assertEqual(plugins['testy1']['summary'], 'ttt') + self.assertEqual(__import__(plugins['testy1']['name'], + None, + None, + ['testy1']) + .main(), + 'all good') + + def test_main_plugin_lambda(self): + with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): + with open('fdroid_testy2.py', 'w') as f: + f.write(textwrap.dedent("""\ + fdroid_summary = "ttt" + main = lambda: print('all good')""")) + with TmpPyPath(tmpdir): + with mock.patch('sys.argv', ['', 'testy2']): + with mock.patch('sys.exit') as exit_mock: + fdroidserver.__main__.main() + exit_mock.assert_called_once_with(0) + + def test_main_plugin_def(self): + with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): + with open('fdroid_testy3.py', 'w') as f: + f.write(textwrap.dedent("""\ + fdroid_summary = "ttt" + def main(): + print('all good')""")) + with TmpPyPath(tmpdir): + with mock.patch('sys.argv', ['', 'testy3']): + with mock.patch('sys.exit') as exit_mock: + fdroidserver.__main__.main() + exit_mock.assert_called_once_with(0) + + def test_main_broken_plugin(self): + """making sure broken plugins get their exceptions through""" + with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): + with open('fdroid_testy4.py', 'w') as f: + f.write(textwrap.dedent("""\ + fdroid_summary = "ttt" + def main(): + raise Exception("this plugin is broken")""")) + with TmpPyPath(tmpdir): + with mock.patch('sys.argv', ['', 'testy4']): + with self.assertRaisesRegex(Exception, "this plugin is broken"): + fdroidserver.__main__.main() + + def test_main_malicious_plugin(self): + """The purpose of this test is to make sure code in plugins + doesn't get executed unintentionally. + """ + with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): + with open('fdroid_testy5.py', 'w') as f: + f.write(textwrap.dedent("""\ + fdroid_summary = "ttt" + raise Exception("this plugin is malicious") + def main(): + print("evil things")""")) + with TmpPyPath(tmpdir): + with mock.patch('sys.argv', ['', 'lint']): + with mock.patch('sys.exit') as exit_mock: + fdroidserver.__main__.main() + # note: this is sloppy, if `lint` changes + # this might need changing too + exit_mock.assert_called_once_with(0) + + def test_main_prevent_plugin_override(self): + """making sure build-in subcommands cannot be overridden by plugins + """ + with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): + with open('fdroid_signatures.py', 'w') as f: + f.write(textwrap.dedent("""\ + fdroid_summary = "ttt" + def main(): + raise("plugin overrides don't get prevent!")""")) + with TmpPyPath(tmpdir): + with mock.patch('sys.argv', ['', 'signatures']): + with mock.patch('sys.exit') as exit_mock: + fdroidserver.__main__.main() + # note: this is sloppy, if `signatures` changes + # this might need changing too + self.assertEqual(exit_mock.call_count, 2) + + def test_preparse_plugin_lookup_bad_name(self): + self.assertRaises(ValueError, + fdroidserver.__main__.preparse_plugin, + "some.package", "/non/existent/module/path") + + def test_preparse_plugin_lookup_bad_path(self): + self.assertRaises(ValueError, + fdroidserver.__main__.preparse_plugin, + "fake_module_name", "/non/existent/module/path") + + def test_preparse_plugin_lookup_summary_missing(self): + with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): + with open('fdroid_testy6.py', 'w') as f: + f.write(textwrap.dedent("""\ + main = lambda: print('all good')""")) + with TmpPyPath(tmpdir): + p = [x for x in pkgutil.iter_modules() if x[1].startswith('fdroid_')] + module_dir = p[0][0].path + module_name = p[0][1] + self.assertRaises(NameError, + fdroidserver.__main__.preparse_plugin, + module_name, module_dir) + + def test_preparse_plugin_lookup_module_file(self): + with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): + with open('fdroid_testy7.py', 'w') as f: + f.write(textwrap.dedent("""\ + fdroid_summary = "ttt" + main = lambda: pritn('all good')""")) + with TmpPyPath(tmpdir): + p = [x for x in pkgutil.iter_modules() if x[1].startswith('fdroid_')] + module_path = p[0][0].path + module_name = p[0][1] + d = fdroidserver.__main__.preparse_plugin(module_name, module_path) + self.assertDictEqual(d, {'name': 'fdroid_testy7', + 'summary': 'ttt'}) + + def test_preparse_plugin_lookup_module_dir(self): + with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): + os.mkdir(os.path.join(tmpdir, 'fdroid_testy8')) + with open('fdroid_testy8/__main__.py', 'w') as f: + f.write(textwrap.dedent("""\ + fdroid_summary = "ttt" + main = lambda: print('all good')""")) + with open('fdroid_testy8/__init__.py', 'w') as f: + pass + with TmpPyPath(tmpdir): + p = [x for x in pkgutil.iter_modules() if x[1].startswith('fdroid_')] + module_path = p[0][0].path + module_name = p[0][1] + d = fdroidserver.__main__.preparse_plugin(module_name, module_path) + self.assertDictEqual(d, {'name': 'fdroid_testy8', + 'summary': 'ttt'}) + if __name__ == "__main__": os.chdir(os.path.dirname(__file__)) @@ -71,5 +226,5 @@ if __name__ == "__main__": (common.options, args) = parser.parse_args(['--verbose']) newSuite = unittest.TestSuite() - newSuite.addTest(unittest.makeSuite(FdroidTest)) + newSuite.addTest(unittest.makeSuite(MainTest)) unittest.main(failfast=False) diff --git a/tests/testcommon.py b/tests/testcommon.py index a637012e..2557bd61 100644 --- a/tests/testcommon.py +++ b/tests/testcommon.py @@ -16,6 +16,7 @@ # along with this program. If not, see . import os +import sys class TmpCwd(): @@ -32,3 +33,18 @@ class TmpCwd(): def __exit__(self, a, b, c): os.chdir(self.orig_cwd) + + +class TmpPyPath(): + """Context-manager for temporarily changing the current working + directory. + """ + + def __init__(self, additional_path): + self.additional_path = additional_path + + def __enter__(self): + sys.path.append(self.additional_path) + + def __exit__(self, a, b, c): + sys.path.remove(self.additional_path)