From 74256845525799c674df5984afe2ec18974dbcd0 Mon Sep 17 00:00:00 2001 From: ljf Date: Wed, 6 Oct 2021 02:37:27 +0200 Subject: [PATCH] [enh] Add visible attribute support in cli --- src/yunohost/tests/test_questions.py | 96 ++++++++++++++ src/yunohost/utils/config.py | 182 ++++++++++++++++++++++++--- 2 files changed, 259 insertions(+), 19 deletions(-) diff --git a/src/yunohost/tests/test_questions.py b/src/yunohost/tests/test_questions.py index cf4e67733..b39990b73 100644 --- a/src/yunohost/tests/test_questions.py +++ b/src/yunohost/tests/test_questions.py @@ -15,6 +15,7 @@ from yunohost.utils.config import ( PathQuestion, BooleanQuestion, FileQuestion, + evaluate_simple_js_expression ) from yunohost.utils.error import YunohostError, YunohostValidationError @@ -2093,3 +2094,98 @@ def test_normalize_path(): assert PathQuestion.normalize("/macnuggets/") == "/macnuggets" assert PathQuestion.normalize("macnuggets/") == "/macnuggets" assert PathQuestion.normalize("////macnuggets///") == "/macnuggets" + +def test_simple_evaluate(): + context = { + 'a1': 1, + 'b2': 2, + 'c10': 10, + 'foo': 'bar', + 'comp': '1>2', + 'empty': '', + 'lorem': 'Lorem ipsum dolor et si qua met!', + 'warning': 'Warning! This sentence will fail!', + 'quote': "Je s'apelle Groot", + 'and_': '&&', + 'object': { 'a': 'Security risk' } + } + supported = { + '42': 42, + '9.5': 9.5, + "'bopbidibopbopbop'": 'bopbidibopbopbop', + 'true': True, + 'false': False, + 'null': None, + + # Math + '1 * (2 + 3 * (4 - 3))': 5, + '1 * (2 + 3 * (4 - 3)) > 10 - 2 || 3 * 2 > 9 - 2 * 3': True, + '(9 - 2) * 3 - 10': 11, + '12 - 2 * -2 + (3 - 4) * 3.1': 12.9, + '9 / 12 + 12 * 3 - 5': 31.75, + '9 / 12 + 12 * (3 - 5)': -23.25, + '12 > 13.1': False, + '12 < 14': True, + '12 <= 14': True, + '12 >= 14': False, + '12 == 14': False, + '12 % 5 > 3': False, + '12 != 14': True, + '9 - 1 > 10 && 3 * 5 > 10': False, + '9 - 1 > 10 || 3 * 5 > 10': True, + 'a1 > 0 || a1 < -12': True, + 'a1 > 0 && a1 < -12': False, + 'a1 + 1 > 0 && -a1 > -12': True, + '-(a1 + 1) < 0 || -(a1 + 2) > -12': True, + '-a1 * 2': -2, + '(9 - 2) * 3 - c10': 11, + '(9 - b2) * 3 - c10': 11, + 'c10 > b2': True, + + # String + "foo == 'bar'":True, + "foo != 'bar'":False, + 'foo == "bar" && 1 > 0':True, + '!!foo': True, + '!foo': False, + 'foo': 'bar', + '!(foo > "baa") || 1 > 2': False, + '!(foo > "baa") || 1 < 2': True, + 'empty == ""': True, + '1 == "1"': True, + '1.0 == "1"': True, + '1 == "aaa"': False, + "'I am ' + b2 + ' years'": 'I am 2 years', + "quote == 'Je s\\'apelle Groot'": True, + "lorem == 'Lorem ipsum dolor et si qua met!'": True, + "and_ == '&&'": True, + "warning == 'Warning! This sentence will fail!'": True, + + # Match + "match(lorem, '^Lorem [ia]psumE?')": bool, + "match(foo, '^Lorem [ia]psumE?')": None, + "match(lorem, '^Lorem [ia]psumE?') && 1 == 1": bool, + + # No code + "": False, + " ": False, + } + trigger_errors = { + "object.a": YunohostError, # Keep unsupported, for security reasons + 'a1 ** b2': YunohostError, # Keep unsupported, for security reasons + '().__class__.__bases__[0].__subclasses__()': YunohostError, # Very dangerous code + 'a1 > 11 ? 1 : 0': SyntaxError, + 'c10 > b2 == false': YunohostError, # JS and Python doesn't do the same thing for this situation + 'c10 > b2 == true': YunohostError, + } + + for expression, result in supported.items(): + if result == bool: + assert bool(evaluate_simple_js_expression(expression, context)), expression + else: + assert evaluate_simple_js_expression(expression, context) == result, expression + + for expression, error in trigger_errors.items(): + with pytest.raises(error): + evaluate_simple_js_expression(expression, context) + diff --git a/src/yunohost/utils/config.py b/src/yunohost/utils/config.py index 2363545cf..0f18fad0d 100644 --- a/src/yunohost/utils/config.py +++ b/src/yunohost/utils/config.py @@ -24,6 +24,8 @@ import re import urllib.parse import tempfile import shutil +import ast +import operator as op from collections import OrderedDict from typing import Optional, Dict, List, Union, Any, Mapping @@ -46,6 +48,138 @@ from yunohost.log import OperationLogger logger = getActionLogger("yunohost.config") CONFIG_PANEL_VERSION_SUPPORTED = 1.0 +# Those js-like evaluate functions are used to eval safely visible attributes +# The goal is to evaluate in the same way than js simple-evaluate +# https://github.com/shepherdwind/simple-evaluate +def evaluate_simple_ast(node, context={}): + operators = { + ast.Not: op.not_, + ast.Mult: op.mul, + ast.Div: op.truediv, # number + ast.Mod: op.mod, # number + ast.Add: op.add, #str + ast.Sub: op.sub, #number + ast.USub: op.neg, # Negative number + ast.Gt: op.gt, + ast.Lt: op.lt, + ast.GtE: op.ge, + ast.LtE: op.le, + ast.Eq: op.eq, + ast.NotEq: op.ne + } + context['true'] = True + context['false'] = False + context['null'] = None + + # Variable + if isinstance(node, ast.Name): # Variable + return context[node.id] + + # Python <=3.7 String + elif isinstance(node, ast.Str): + return node.s + + # Python <=3.7 Number + elif isinstance(node, ast.Num): + return node.n + + # Boolean, None and Python 3.8 for Number, Boolean, String and None + elif isinstance(node, (ast.Constant, ast.NameConstant)): + return node.value + + # + - * / % + elif isinstance(node, ast.BinOp) and type(node.op) in operators: # + left = evaluate_simple_ast(node.left, context) + right = evaluate_simple_ast(node.right, context) + if type(node.op) == ast.Add: + if isinstance(left, str) or isinstance(right, str): # support 'I am ' + 42 + left = str(left) + right = str(right) + elif type(left) != type(right): # support "111" - "1" -> 110 + left = float(left) + right = float(right) + + return operators[type(node.op)](left, right) + + # Comparison + # JS and Python don't give the same result for multi operators + # like True == 10 > 2. + elif isinstance(node, ast.Compare) and len(node.comparators) == 1: # + left = evaluate_simple_ast(node.left, context) + right = evaluate_simple_ast(node.comparators[0], context) + operator = node.ops[0] + if isinstance(left, (int, float)) or isinstance(right, (int, float)): + try: + left = float(left) + right = float(right) + except ValueError: + return type(operator) == ast.NotEq + try: + return operators[type(operator)](left, right) + except TypeError: # support "e" > 1 -> False like in JS + return False + + # and / or + elif isinstance(node, ast.BoolOp): # + values = node.values + for value in node.values: + value = evaluate_simple_ast(value, context) + if isinstance(node.op, ast.And) and not value: + return False + elif isinstance(node.op, ast.Or) and value: + return True + return isinstance(node.op, ast.And) + + # not / USub (it's negation number -\d) + elif isinstance(node, ast.UnaryOp): # e.g., -1 + return operators[type(node.op)](evaluate_simple_ast(node.operand, context)) + + # match function call + elif isinstance(node, ast.Call) and node.func.__dict__.get('id') == 'match': + return re.match( + evaluate_simple_ast(node.args[1], context), + context[node.args[0].id] + ) + + # Unauthorized opcode + else: + opcode = str(type(node)) + raise YunohostError(f"Unauthorize opcode '{opcode}' in visible attribute", raw_msg=True) + +def js_to_python(expr): + in_string = None + py_expr = "" + i = 0 + escaped = False + for char in expr: + if char in r"\"'": + # Start a string + if not in_string: + in_string = char + + # Finish a string + elif in_string == char and not escaped: + in_string = None + + # If we are not in a string, replace operators + elif not in_string: + if char == "!" and expr[i +1] != "=": + char = "not " + elif char in "|&" and py_expr[-1:] == char: + py_expr = py_expr[:-1] + char = " and " if char == "&" else " or " + + # Determine if next loop will be in escaped mode + escaped = char == "\\" and not escaped + py_expr += char + i+=1 + return py_expr + +def evaluate_simple_js_expression(expr, context={}): + if not expr.strip(): + return False + node = ast.parse(js_to_python(expr), mode="eval").body + return evaluate_simple_ast(node, context) class ConfigPanel: def __init__(self, config_path, save_path=None): @@ -466,11 +600,13 @@ class Question(object): hide_user_input_in_prompt = False pattern: Optional[Dict] = None - def __init__(self, question: Dict[str, Any]): + def __init__(self, question: Dict[str, Any], context: Dict[str, Any] = {}): self.name = question["name"] self.type = question.get("type", "string") self.default = question.get("default", None) self.optional = question.get("optional", False) + self.visible = question.get("visible", None) + self.context = context self.choices = question.get("choices", []) self.pattern = question.get("pattern", self.pattern) self.ask = question.get("ask", {"en": self.name}) @@ -512,6 +648,15 @@ class Question(object): ) def ask_if_needed(self): + + if self.visible and not evaluate_simple_js_expression(self.visible, context=self.context): + # FIXME There could be several use case if the question is not displayed: + # - we doesn't want to give a specific value + # - we want to keep the previous value + # - we want the default value + self.value = None + return self.value + for i in range(5): # Display question if no value filled or if it's a readonly message if Moulinette.interface.type == "cli" and os.isatty(1): @@ -577,7 +722,7 @@ class Question(object): # Prevent displaying a shitload of choices # (e.g. 100+ available users when choosing an app admin...) choices = ( - list(self.choices.values()) + list(self.choices.keys()) if isinstance(self.choices, dict) else self.choices ) @@ -710,8 +855,8 @@ class PasswordQuestion(Question): default_value = "" forbidden_chars = "{}" - def __init__(self, question): - super().__init__(question) + def __init__(self, question, context: Dict[str, Any] = {}): + super().__init__(question, context) self.redact = True if self.default is not None: raise YunohostValidationError( @@ -829,8 +974,8 @@ class BooleanQuestion(Question): choices="yes/no", ) - def __init__(self, question): - super().__init__(question) + def __init__(self, question, context: Dict[str, Any] = {}): + super().__init__(question, context) self.yes = question.get("yes", 1) self.no = question.get("no", 0) if self.default is None: @@ -850,10 +995,10 @@ class BooleanQuestion(Question): class DomainQuestion(Question): argument_type = "domain" - def __init__(self, question): + def __init__(self, question, context: Dict[str, Any] = {}): from yunohost.domain import domain_list, _get_maindomain - super().__init__(question) + super().__init__(question, context) if self.default is None: self.default = _get_maindomain() @@ -876,11 +1021,11 @@ class DomainQuestion(Question): class UserQuestion(Question): argument_type = "user" - def __init__(self, question): + def __init__(self, question, context: Dict[str, Any] = {}): from yunohost.user import user_list, user_info from yunohost.domain import _get_maindomain - super().__init__(question) + super().__init__(question, context) self.choices = list(user_list()["users"].keys()) if not self.choices: @@ -902,8 +1047,8 @@ class NumberQuestion(Question): argument_type = "number" default_value = None - def __init__(self, question): - super().__init__(question) + def __init__(self, question, context: Dict[str, Any] = {}): + super().__init__(question, context) self.min = question.get("min", None) self.max = question.get("max", None) self.step = question.get("step", None) @@ -954,8 +1099,8 @@ class DisplayTextQuestion(Question): argument_type = "display_text" readonly = True - def __init__(self, question): - super().__init__(question) + def __init__(self, question, context: Dict[str, Any] = {}): + super().__init__(question, context) self.optional = True self.style = question.get( @@ -989,8 +1134,8 @@ class FileQuestion(Question): if os.path.exists(upload_dir): shutil.rmtree(upload_dir) - def __init__(self, question): - super().__init__(question) + def __init__(self, question, context: Dict[str, Any] = {}): + super().__init__(question, context) self.accept = question.get("accept", "") def _prevalidate(self): @@ -1089,9 +1234,8 @@ def ask_questions_and_parse_answers( for question in questions: question_class = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")] question["value"] = prefilled_answers.get(question["name"]) - question = question_class(question) - - question.ask_if_needed() + question = question_class(question, prefilled_answers) + prefilled_answers[question.name] = question.ask_if_needed() out.append(question) return out