diff --git a/locales/en.json b/locales/en.json index 039e9a084..9fb31c663 100644 --- a/locales/en.json +++ b/locales/en.json @@ -13,6 +13,7 @@ "app_already_up_to_date": "{app:s} is already up-to-date", "app_argument_choice_invalid": "Use one of these choices '{choices:s}' for the argument '{name:s}'", "app_argument_invalid": "Pick a valid value for the argument '{name:s}': {error:s}", + "app_argument_password_no_default": "Error while parsing password argument '{name}': password argument can't have a default value for security reason", "app_argument_required": "Argument '{name:s}' is required", "app_change_url_failed_nginx_reload": "Could not reload NGINX. Here is the output of 'nginx -t':\n{nginx_errors:s}", "app_change_url_identical_domains": "The old and new domain/url_path are identical ('{domain:s}{path:s}'), nothing to do.", @@ -599,7 +600,6 @@ "user_unknown": "Unknown user: {user:s}", "user_update_failed": "Could not update user {user}: {error}", "user_updated": "User info changed", - "users_available": "Available users:", "yunohost_already_installed": "YunoHost is already installed", "yunohost_ca_creation_failed": "Could not create certificate authority", "yunohost_ca_creation_success": "Local certification authority created.", diff --git a/src/yunohost/app.py b/src/yunohost/app.py index dfea9dc52..97dc09621 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -2508,6 +2508,225 @@ def _parse_args_for_action(action, args={}): return _parse_args_in_yunohost_format(args, action_args) +class Question: + "empty class to store questions information" + + +class YunoHostArgumentFormatParser(object): + hide_user_input_in_prompt = False + + def parse_question(self, question, user_answers): + parsed_question = Question() + + parsed_question.name = question['name'] + parsed_question.default = question.get('default', None) + parsed_question.choices = question.get('choices', []) + parsed_question.optional = question.get('optional', False) + parsed_question.ask = question.get('ask') + parsed_question.value = user_answers.get(parsed_question.name) + + if parsed_question.ask is None: + parsed_question.ask = "Enter value for '%s':" % parsed_question.name + + return parsed_question + + def parse(self, question, user_answers): + question = self.parse_question(question, user_answers) + + if question.value is None: + text_for_user_input_in_cli = self._format_text_for_user_input_in_cli(question) + + try: + question.value = msignals.prompt(text_for_user_input_in_cli, self.hide_user_input_in_prompt) + except NotImplementedError: + question.value = None + + # we don't have an answer, check optional and default_value + if question.value is None: + if not question.optional and question.default is None: + raise YunohostError('app_argument_required', name=question.name) + else: + question.value = getattr(self, "default_value", None) if question.default is None else question.default + + # we have an answer, do some post checks + if question.value is not None: + if question.choices and question.value not in question.choices: + self._raise_invalide_answer(question) + + # this is done to enforce a certain formating like for boolean + # by default it doesn't do anything + question.value = self._post_parse_value(question) + + return (question.value, self.argument_type) + + def _raise_invalide_answer(self, question): + raise YunohostError('app_argument_choice_invalid', name=question.name, + choices=', '.join(question.choices)) + + def _format_text_for_user_input_in_cli(self, question): + text_for_user_input_in_cli = _value_for_locale(question.ask) + + if question.default is not None: + text_for_user_input_in_cli += ' (default: {0})'.format(question.default) + + if question.choices: + text_for_user_input_in_cli += ' [{0}]'.format(' | '.join(question.choices)) + + return text_for_user_input_in_cli + + def _post_parse_value(self, question): + return question.value + + +class StringArgumentParser(YunoHostArgumentFormatParser): + argument_type = "string" + default_value = "" + + +class PasswordArgumentParser(YunoHostArgumentFormatParser): + hide_user_input_in_prompt = True + argument_type = "password" + default_value = "" + forbidden_chars = "{}" + + def parse_question(self, question, user_answers): + question = super(PasswordArgumentParser, self).parse_question(question, user_answers) + + if question.default is not None: + raise YunohostError('app_argument_password_no_default', name=question.name) + + return question + + def _post_parse_value(self, question): + if any(char in question.value for char in self.forbidden_chars): + raise YunohostError('pattern_password_app', forbidden_chars=self.forbidden_chars) + + from yunohost.utils.password import assert_password_is_strong_enough + assert_password_is_strong_enough('user', question.value) + + return super(PasswordArgumentParser, self)._post_parse_value(question) + + +class PathArgumentParser(YunoHostArgumentFormatParser): + argument_type = "path" + default_value = "" + + +class BooleanArgumentParser(YunoHostArgumentFormatParser): + argument_type = "boolean" + default_value = False + + def parse_question(self, question, user_answers): + question = super(BooleanArgumentParser, self).parse_question(question, user_answers) + + if question.default is None: + question.default = False + + return question + + def _format_text_for_user_input_in_cli(self, question): + text_for_user_input_in_cli = _value_for_locale(question.ask) + + text_for_user_input_in_cli += " [yes | no]" + + if question.default is not None: + formatted_default = "yes" if question.default else "no" + text_for_user_input_in_cli += ' (default: {0})'.format(formatted_default) + + return text_for_user_input_in_cli + + def _post_parse_value(self, question): + if isinstance(question.value, bool): + return 1 if question.value else 0 + + if str(question.value).lower() in ["1", "yes", "y"]: + return 1 + + if str(question.value).lower() in ["0", "no", "n"]: + return 0 + + raise YunohostError('app_argument_choice_invalid', name=question.name, + choices='yes, no, y, n, 1, 0') + + +class DomainArgumentParser(YunoHostArgumentFormatParser): + argument_type = "domain" + + def parse_question(self, question, user_answers): + from yunohost.domain import domain_list, _get_maindomain + + question = super(DomainArgumentParser, self).parse_question(question, user_answers) + + if question.default is None: + question.default = _get_maindomain() + + question.choices = domain_list()["domains"] + + return question + + def _raise_invalide_answer(self, question): + raise YunohostError('app_argument_invalid', name=question.name, + error=m18n.n('domain_unknown')) + + +class UserArgumentParser(YunoHostArgumentFormatParser): + argument_type = "user" + + def parse_question(self, question, user_answers): + from yunohost.user import user_list, user_info + from yunohost.domain import _get_maindomain + + question = super(UserArgumentParser, self).parse_question(question, user_answers) + question.choices = user_list()["users"] + if question.default is None: + root_mail = "root@%s" % _get_maindomain() + for user in question.choices.keys(): + if root_mail in user_info(user).get("mail-aliases", []): + question.default = user + break + + return question + + def _raise_invalide_answer(self, question): + raise YunohostError('app_argument_invalid', name=question.name, + error=m18n.n('user_unknown', user=question.value)) + + +class AppArgumentParser(YunoHostArgumentFormatParser): + argument_type = "app" + + def parse_question(self, question, user_answers): + from yunohost.app import app_list + + question = super(AppArgumentParser, self).parse_question(question, user_answers) + question.choices = [x["id"] for x in app_list()["apps"]] + + return question + + def _raise_invalide_answer(self, question): + raise YunohostError('app_argument_invalid', name=question.name, + error=m18n.n('app_unknown')) + + +class DisplayTextArgumentParser(YunoHostArgumentFormatParser): + argument_type = "display_text" + + def parse(self, question, user_answers): + print(question["ask"]) + + +ARGUMENTS_TYPE_PARSERS = { + "string": StringArgumentParser, + "password": PasswordArgumentParser, + "path": PathArgumentParser, + "boolean": BooleanArgumentParser, + "domain": DomainArgumentParser, + "user": UserArgumentParser, + "app": AppArgumentParser, + "display_text": DisplayTextArgumentParser, +} + + def _parse_args_in_yunohost_format(user_answers, argument_questions): """Parse arguments store in either manifest.json or actions.json or from a config panel against the user answers when they are present. @@ -2519,128 +2738,14 @@ def _parse_args_in_yunohost_format(user_answers, argument_questions): format from actions.json/toml, manifest.json/toml or config_panel.json/toml """ - from yunohost.domain import domain_list, _get_maindomain - from yunohost.user import user_list, user_info - parsed_answers_dict = OrderedDict() for question in argument_questions: - question_name = question['name'] - question_type = question.get('type', 'string') - question_default = question.get('default', None) - question_choices = question.get('choices', []) - question_value = None + parser = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")]() - # Transpose default value for boolean type and set it to - # false if not defined. - if question_type == 'boolean': - question_default = 1 if question_default else 0 - - # do not print for webadmin - if question_type == 'display_text' and msettings.get('interface') != 'api': - print(_value_for_locale(question['ask'])) - continue - - # Attempt to retrieve argument value - if question_name in user_answers: - question_value = user_answers[question_name] - else: - if 'ask' in question: - - if question_type == 'domain': - question_default = _get_maindomain() - msignals.display(m18n.n('domains_available')) - for domain in domain_list()['domains']: - msignals.display("- {}".format(domain)) - - elif question_type == 'user': - msignals.display(m18n.n('users_available')) - users = user_list()['users'] - for user in users.keys(): - msignals.display("- {}".format(user)) - - root_mail = "root@%s" % _get_maindomain() - for user in users.keys(): - if root_mail in user_info(user).get("mail-aliases", []): - question_default = user - break - - elif question_type == 'password': - msignals.display(m18n.n('good_practices_about_user_password')) - - # Retrieve proper ask string - text_for_user_input_in_cli = _value_for_locale(question['ask']) - - # Append extra strings - if question_type == 'boolean': - text_for_user_input_in_cli += ' [yes | no]' - elif question_choices: - text_for_user_input_in_cli += ' [{0}]'.format(' | '.join(question_choices)) - - - if question_default is not None: - if question_type == 'boolean': - text_for_user_input_in_cli += ' (default: {0})'.format("yes" if question_default == 1 else "no") - else: - text_for_user_input_in_cli += ' (default: {0})'.format(question_default) - - is_password = True if question_type == 'password' else False - - try: - input_string = msignals.prompt(text_for_user_input_in_cli, is_password) - except NotImplementedError: - input_string = None - if (input_string == '' or input_string is None) \ - and question_default is not None: - question_value = question_default - else: - question_value = input_string - elif question_default is not None: - question_value = question_default - - # If the value is empty (none or '') - # then check if question is optional or not - if question_value is None or question_value == '': - if question.get("optional", False): - # Argument is optional, keep an empty value - # and that's all for this question! - parsed_answers_dict[question_name] = ('', question_type) - continue - else: - # The argument is required ! - raise YunohostError('app_argument_required', name=question_name) - - # Validate argument choice - if question_choices and question_value not in question_choices: - raise YunohostError('app_argument_choice_invalid', name=question_name, choices=', '.join(question_choices)) - - # Validate argument type - if question_type == 'domain': - if question_value not in domain_list()['domains']: - raise YunohostError('app_argument_invalid', name=question_name, error=m18n.n('domain_unknown')) - elif question_type == 'user': - if question_value not in user_list()["users"].keys(): - raise YunohostError('app_argument_invalid', name=question_name, error=m18n.n('user_unknown', user=question_value)) - elif question_type == 'app': - if not _is_installed(question_value): - raise YunohostError('app_argument_invalid', name=question_name, error=m18n.n('app_unknown')) - elif question_type == 'boolean': - if isinstance(question_value, bool): - question_value = 1 if question_value else 0 - else: - if str(question_value).lower() in ["1", "yes", "y"]: - question_value = 1 - elif str(question_value).lower() in ["0", "no", "n"]: - question_value = 0 - else: - raise YunohostError('app_argument_choice_invalid', name=question_name, choices='yes, no, y, n, 1, 0') - elif question_type == 'password': - forbidden_chars = "{}" - if any(char in question_value for char in forbidden_chars): - raise YunohostError('pattern_password_app', forbidden_chars=forbidden_chars) - from yunohost.utils.password import assert_password_is_strong_enough - assert_password_is_strong_enough('user', question_value) - parsed_answers_dict[question_name] = (question_value, question_type) + answer = parser.parse(question=question, user_answers=user_answers) + if answer is not None: + parsed_answers_dict[question["name"]] = answer return parsed_answers_dict diff --git a/src/yunohost/tests/test_apps_arguments_parsing.py b/src/yunohost/tests/test_apps_arguments_parsing.py index 03ed9e93a..dede7a0f9 100644 --- a/src/yunohost/tests/test_apps_arguments_parsing.py +++ b/src/yunohost/tests/test_apps_arguments_parsing.py @@ -8,7 +8,7 @@ from collections import OrderedDict from moulinette import msignals from yunohost import domain, user, app -from yunohost.app import _parse_args_in_yunohost_format +from yunohost.app import _parse_args_in_yunohost_format, PasswordArgumentParser from yunohost.utils.error import YunohostError @@ -70,7 +70,6 @@ def test_parse_args_in_yunohost_format_string_input(): assert _parse_args_in_yunohost_format(answers, questions) == expected_result -@pytest.mark.skip # that shit should work x( def test_parse_args_in_yunohost_format_string_input_no_ask(): questions = [{"name": "some_string", }] answers = {} @@ -96,7 +95,6 @@ def test_parse_args_in_yunohost_format_string_optional_with_input(): assert _parse_args_in_yunohost_format(answers, questions) == expected_result -@pytest.mark.skip # this should work without ask def test_parse_args_in_yunohost_format_string_optional_with_input_without_ask(): questions = [{"name": "some_string", "optional": True, }] answers = {} @@ -237,7 +235,6 @@ def test_parse_args_in_yunohost_format_password_input(): assert _parse_args_in_yunohost_format(answers, questions) == expected_result -@pytest.mark.skip # that shit should work x( def test_parse_args_in_yunohost_format_password_input_no_ask(): questions = [{"name": "some_password", "type": "password", }] answers = {} @@ -250,8 +247,9 @@ def test_parse_args_in_yunohost_format_password_input_no_ask(): def test_parse_args_in_yunohost_format_password_no_input_optional(): questions = [{"name": "some_password", "type": "password", "optional": True, }] answers = {} - expected_result = OrderedDict({"some_password": ("", "password")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + + with pytest.raises(YunohostError): + _parse_args_in_yunohost_format(answers, questions) def test_parse_args_in_yunohost_format_password_optional_with_input(): @@ -270,7 +268,6 @@ def test_parse_args_in_yunohost_format_password_optional_with_input(): assert _parse_args_in_yunohost_format(answers, questions) == expected_result -@pytest.mark.skip # this should work without ask def test_parse_args_in_yunohost_format_password_optional_with_input_without_ask(): questions = [{"name": "some_password", "type": "password", "optional": True, }] answers = {} @@ -280,7 +277,6 @@ def test_parse_args_in_yunohost_format_password_optional_with_input_without_ask( assert _parse_args_in_yunohost_format(answers, questions) == expected_result -@pytest.mark.skip # this should raises def test_parse_args_in_yunohost_format_password_no_input_default(): questions = [ { @@ -364,6 +360,39 @@ def test_parse_args_in_yunohost_format_password_input_test_ask_with_help(): assert help_text in prompt.call_args[0][0] +def test_parse_args_in_yunohost_format_password_bad_chars(): + questions = [ + { + "name": "some_password", + "type": "password", + "ask": "some question", + "example": "some_value", + } + ] + + for i in PasswordArgumentParser.forbidden_chars: + with pytest.raises(YunohostError): + _parse_args_in_yunohost_format({"some_password": i * 8}, questions) + + +def test_parse_args_in_yunohost_format_password_strong_enough(): + questions = [ + { + "name": "some_password", + "type": "password", + "ask": "some question", + "example": "some_value", + } + ] + + with pytest.raises(YunohostError): + # too short + _parse_args_in_yunohost_format({"some_password": "a"}, questions) + + with pytest.raises(YunohostError): + _parse_args_in_yunohost_format({"some_password": "password"}, questions) + + def test_parse_args_in_yunohost_format_path(): questions = [{"name": "some_path", "type": "path", }] answers = {"some_path": "some_value"} @@ -388,7 +417,6 @@ def test_parse_args_in_yunohost_format_path_input(): assert _parse_args_in_yunohost_format(answers, questions) == expected_result -@pytest.mark.skip # that shit should work x( def test_parse_args_in_yunohost_format_path_input_no_ask(): questions = [{"name": "some_path", "type": "path", }] answers = {} @@ -416,7 +444,6 @@ def test_parse_args_in_yunohost_format_path_optional_with_input(): assert _parse_args_in_yunohost_format(answers, questions) == expected_result -@pytest.mark.skip # this should work without ask def test_parse_args_in_yunohost_format_path_optional_with_input_without_ask(): questions = [{"name": "some_path", "type": "path", "optional": True, }] answers = {} @@ -604,11 +631,10 @@ def test_parse_args_in_yunohost_format_boolean_input(): assert _parse_args_in_yunohost_format(answers, questions) == expected_result -@pytest.mark.skip # we should work def test_parse_args_in_yunohost_format_boolean_input_no_ask(): questions = [{"name": "some_boolean", "type": "boolean", }] answers = {} - expected_result = OrderedDict({"some_boolean": ("some_value", "boolean")}) + expected_result = OrderedDict({"some_boolean": (1, "boolean")}) with patch.object(msignals, "prompt", return_value="y"): assert _parse_args_in_yunohost_format(answers, questions) == expected_result @@ -660,7 +686,6 @@ def test_parse_args_in_yunohost_format_boolean_no_input_default(): assert _parse_args_in_yunohost_format(answers, questions) == expected_result -@pytest.mark.skip # we should raise def test_parse_args_in_yunohost_format_boolean_bad_default(): questions = [ { @@ -704,16 +729,17 @@ def test_parse_args_in_yunohost_format_boolean_input_test_ask_with_default(): def test_parse_args_in_yunohost_format_domain_empty(): - questions = [{"name": "some_domain", "type": "domain", }] + questions = [{"name": "some_domain", "type": "domain",}] + main_domain = "my_main_domain.com" + expected_result = OrderedDict({"some_domain": (main_domain, "domain")}) answers = {} with patch.object( domain, "_get_maindomain", return_value="my_main_domain.com" ), patch.object( - domain, "domain_list", return_value={"domains": ["my_main_domain.com"]} + domain, "domain_list", return_value={"domains": [main_domain]} ): - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + assert _parse_args_in_yunohost_format(answers, questions) == expected_result def test_parse_args_in_yunohost_format_domain(): @@ -768,7 +794,6 @@ def test_parse_args_in_yunohost_format_domain_two_domains_wrong_answer(): _parse_args_in_yunohost_format(answers, questions) -@pytest.mark.skip # XXX should work def test_parse_args_in_yunohost_format_domain_two_domains_default_no_ask(): main_domain = "my_main_domain.com" other_domain = "some_other_domain.tld" @@ -858,7 +883,8 @@ def test_parse_args_in_yunohost_format_user(): expected_result = OrderedDict({"some_user": (username, "user")}) with patch.object(user, "user_list", return_value={"users": users}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(user, "user_info", return_value={}): + assert _parse_args_in_yunohost_format(answers, questions) == expected_result def test_parse_args_in_yunohost_format_user_two_users(): @@ -888,13 +914,15 @@ def test_parse_args_in_yunohost_format_user_two_users(): expected_result = OrderedDict({"some_user": (other_user, "user")}) with patch.object(user, "user_list", return_value={"users": users}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(user, "user_info", return_value={}): + assert _parse_args_in_yunohost_format(answers, questions) == expected_result answers = {"some_user": username} expected_result = OrderedDict({"some_user": (username, "user")}) with patch.object(user, "user_list", return_value={"users": users}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(user, "user_info", return_value={}): + assert _parse_args_in_yunohost_format(answers, questions) == expected_result def test_parse_args_in_yunohost_format_user_two_users_wrong_answer(): @@ -983,13 +1011,14 @@ def test_parse_args_in_yunohost_format_user_two_users_default_input(): answers = {} with patch.object(user, "user_list", return_value={"users": users}): - expected_result = OrderedDict({"some_user": (username, "user")}) - with patch.object(msignals, "prompt", return_value=username): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(user, "user_info", return_value={}): + expected_result = OrderedDict({"some_user": (username, "user")}) + with patch.object(msignals, "prompt", return_value=username): + assert _parse_args_in_yunohost_format(answers, questions) == expected_result - expected_result = OrderedDict({"some_user": (other_user, "user")}) - with patch.object(msignals, "prompt", return_value=other_user): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + expected_result = OrderedDict({"some_user": (other_user, "user")}) + with patch.object(msignals, "prompt", return_value=other_user): + assert _parse_args_in_yunohost_format(answers, questions) == expected_result def test_parse_args_in_yunohost_format_app_empty(): @@ -1020,14 +1049,14 @@ def test_parse_args_in_yunohost_format_app_no_apps(): _parse_args_in_yunohost_format(answers, questions) -@pytest.mark.skip # XXX should work def test_parse_args_in_yunohost_format_app_no_apps_optional(): apps = [] questions = [{"name": "some_app", "type": "app", "optional": True}] answers = {} + expected_result = OrderedDict({"some_app": (None, "app")}) with patch.object(app, "app_list", return_value={"apps": apps}): - assert _parse_args_in_yunohost_format(answers, questions) == [] + assert _parse_args_in_yunohost_format(answers, questions) == expected_result def test_parse_args_in_yunohost_format_app():