Commit ff2e6dc1 by Robert Raposa

Merge pull request #12082 from edx/robrap/linter-mako

TNL-4324: Enhance linter for HTML() and Text()
parents 498ab887 96fe8afb
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
A linting tool to check if templates are safe A linting tool to check if templates are safe
""" """
from __future__ import print_function from __future__ import print_function
import argparse
from enum import Enum from enum import Enum
import os import os
import re import re
...@@ -205,8 +206,8 @@ class Rules(Enum): ...@@ -205,8 +206,8 @@ class Rules(Enum):
'mako-multiple-page-tags', 'mako-multiple-page-tags',
'A Mako template can only have one <%page> tag.' 'A Mako template can only have one <%page> tag.'
) )
mako_unparsable_expression = ( mako_unparseable_expression = (
'mako-unparsable-expression', 'mako-unparseable-expression',
'The expression could not be properly parsed.' 'The expression could not be properly parsed.'
) )
mako_unwanted_html_filter = ( mako_unwanted_html_filter = (
...@@ -217,15 +218,30 @@ class Rules(Enum): ...@@ -217,15 +218,30 @@ class Rules(Enum):
'mako-invalid-html-filter', 'mako-invalid-html-filter',
'The expression is using an invalid filter in an HTML context.' 'The expression is using an invalid filter in an HTML context.'
) )
mako_invalid_js_filter = (
'mako-invalid-js-filter',
'The expression is using an invalid filter in a JavaScript context.'
)
mako_deprecated_display_name = ( mako_deprecated_display_name = (
'mako-deprecated-display-name', 'mako-deprecated-display-name',
'Replace deprecated display_name_with_default_escaped with display_name_with_default.' 'Replace deprecated display_name_with_default_escaped with display_name_with_default.'
) )
mako_invalid_js_filter = ( mako_html_requires_text = (
'mako-invalid-js-filter', 'mako-html-requires-text',
'The expression is using an invalid filter in a JavaScript context.' 'You must begin with Text() if you use HTML() during interpolation.'
)
mako_close_before_format = (
'mako-close-before-format',
'You must close any call to Text() or HTML() before calling format().'
)
mako_text_redundant = (
'mako-text-redundant',
'Using Text() function without HTML() is unnecessary.'
)
mako_html_alone = (
'mako-html-alone',
"Only use HTML() alone with properly escaped HTML(), and make sure it is really alone."
) )
underscore_not_escaped = ( underscore_not_escaped = (
'underscore-not-escaped', 'underscore-not-escaped',
'Expressions should be escaped using <%- expression %>.' 'Expressions should be escaped using <%- expression %>.'
...@@ -421,7 +437,7 @@ class ExpressionRuleViolation(RuleViolation): ...@@ -421,7 +437,7 @@ class ExpressionRuleViolation(RuleViolation):
line_number, line_number,
column, column,
rule_id, rule_id,
self.lines[line_number - self.start_line - 1].encode(encoding='utf-8') self.lines[line_number - self.start_line].encode(encoding='utf-8')
), file=out) ), file=out)
...@@ -630,13 +646,14 @@ class MakoTemplateLinter(object): ...@@ -630,13 +646,14 @@ class MakoTemplateLinter(object):
for expression in expressions: for expression in expressions:
if expression['expression'] is None: if expression['expression'] is None:
results.violations.append(ExpressionRuleViolation( results.violations.append(ExpressionRuleViolation(
Rules.mako_unparsable_expression, expression Rules.mako_unparseable_expression, expression
)) ))
continue continue
context = self._get_context(contexts, expression['start_index']) context = self._get_context(contexts, expression['start_index'])
self._check_filters(mako_template, expression, context, has_page_default, results) self._check_filters(mako_template, expression, context, has_page_default, results)
self._check_deprecated_display_name(expression, results) self._check_deprecated_display_name(expression, results)
self._check_html_and_text(expression, results)
def _check_deprecated_display_name(self, expression, results): def _check_deprecated_display_name(self, expression, results):
""" """
...@@ -654,6 +671,52 @@ class MakoTemplateLinter(object): ...@@ -654,6 +671,52 @@ class MakoTemplateLinter(object):
Rules.mako_deprecated_display_name, expression Rules.mako_deprecated_display_name, expression
)) ))
def _check_html_and_text(self, expression, results):
"""
Checks rules related to proper use of HTML() and Text().
Rule 1: If HTML() is called, the expression must begin with Text(), or
Rule 2: If HTML() is called alone, it must be the only call.
Rule 3: Both HTML() and Text() must be closed before any call to
format().
Rule 4: Using Text() without HTML() is unnecessary.
Arguments:
expression: A dict containing the start_index, end_index, and
expression (text) of the expression.
results: A list of results into which violations will be added.
"""
# strip '${' and '}' and whitespace from ends
expression_inner = expression['expression'][2:-1].strip()
if 'HTML(' in expression_inner:
if expression_inner.startswith('HTML('):
close_paren_index = self._find_closing_char_index(None, "(", ")", expression_inner, len('HTML('), 0)
# check that the close paren is at the end of the expression.
if close_paren_index != len(expression_inner) - 1:
results.violations.append(ExpressionRuleViolation(
Rules.mako_html_alone, expression
))
elif expression_inner.startswith('Text(') is False:
results.violations.append(ExpressionRuleViolation(
Rules.mako_html_requires_text, expression
))
else:
if 'Text(' in expression_inner:
results.violations.append(ExpressionRuleViolation(
Rules.mako_text_redundant, expression
))
for match in re.finditer("(HTML\(|Text\()", expression_inner):
close_paren_index = self._find_closing_char_index(None, "(", ")", expression_inner, match.end(), 0)
if 0 <= close_paren_index:
# the argument sent to HTML() or Text()
argument = expression_inner[match.end():close_paren_index]
if ".format(" in argument:
results.violations.append(ExpressionRuleViolation(
Rules.mako_close_before_format, expression
))
def _check_filters(self, mako_template, expression, context, has_page_default, results): def _check_filters(self, mako_template, expression, context, has_page_default, results):
""" """
Checks that the filters used in the given Mako expression are valid Checks that the filters used in the given Mako expression are valid
...@@ -796,9 +859,9 @@ class MakoTemplateLinter(object): ...@@ -796,9 +859,9 @@ class MakoTemplateLinter(object):
while True: while True:
start_index = mako_template.find(start_delim, start_index) start_index = mako_template.find(start_delim, start_index)
if (start_index < 0): if start_index < 0:
break break
end_index = self._find_balanced_end_curly(mako_template, start_index + len(start_delim), 0) end_index = self._find_closing_char_index(start_delim, '{', '}', mako_template, start_index + len(start_delim), 0)
if end_index < 0: if end_index < 0:
expression = None expression = None
...@@ -817,39 +880,117 @@ class MakoTemplateLinter(object): ...@@ -817,39 +880,117 @@ class MakoTemplateLinter(object):
return expressions return expressions
def _find_balanced_end_curly(self, mako_template, start_index, num_open_curlies): def _find_closing_char_index(self, start_delim, open_char, close_char, template, start_index, num_open_chars):
""" """
Finds the end index of the Mako expression's ending curly brace. Skips Finds the index of the closing char that matches the opening char.
any additional open/closed braces that are balanced inside. Does not
take into consideration strings. For example, this could be used to find the end of a Mako expression,
where the open and close characters would be '{' and '}'.
Arguments: Arguments:
mako_template: The template text. start_delim: If provided (e.g. '${' for Mako expressions), the
start_index: The start index of the Mako expression. closing character must be found before the next start_delim.
num_open_curlies: The current number of open expressions. open_char: The opening character to be matched (e.g '{')
close_char: The closing character to be matched (e.g '}')
template: The template to be searched.
start_index: The start index of the last open char.
num_open_chars: The current number of open chars.
Returns: Returns:
The end index of the expression, or -1 if unparseable. The index of the closing character, or -1 if unparseable.
""" """
end_curly_index = mako_template.find('}', start_index) close_char_index = template.find(close_char, start_index)
if end_curly_index < 0: if close_char_index < 0:
# if we can't find an end_curly, let's just quit # if we can't find an end_char, let's just quit
return end_curly_index return -1
open_curly_index = mako_template.find('{', start_index, end_curly_index) open_char_index = template.find(open_char, start_index, close_char_index)
start_quote_index = self._find_string_start(template, start_index, close_char_index)
if (open_curly_index >= 0) and (open_curly_index < end_curly_index): if 0 <= start_quote_index:
if mako_template[open_curly_index - 1] == '$': string_end_index = self._parse_string(template, start_quote_index)['end_index']
# assume if we find "${" it is the start of the next expression if string_end_index < 0:
# and we have a parse error
return -1 return -1
else: else:
return self._find_balanced_end_curly(mako_template, open_curly_index + 1, num_open_curlies + 1) return self._find_closing_char_index(start_delim, open_char, close_char, template, string_end_index, num_open_chars)
if num_open_curlies == 0: if (open_char_index >= 0) and (open_char_index < close_char_index):
return end_curly_index if start_delim is not None:
# if we find another starting delim, consider this unparseable
start_delim_index = template.find(start_delim, start_index, close_char_index)
if start_delim_index < open_char_index:
return -1
return self._find_closing_char_index(start_delim, open_char, close_char, template, open_char_index + 1, num_open_chars + 1)
if num_open_chars == 0:
return close_char_index
else: else:
return self._find_balanced_end_curly(mako_template, end_curly_index + 1, num_open_curlies - 1) return self._find_closing_char_index(start_delim, open_char, close_char, template, close_char_index + 1, num_open_chars - 1)
def _find_string_start(self, template, start_index, end_index):
"""
Finds the index of the end of start of a string. In other words, the
first single or double quote.
Arguments:
template: The template to be searched.
start_index: The start index to search.
end_index: The end index to search before.
num_open_chars: The current number of open expressions.
Returns:
The start index of the first single or double quote, or -1 if
no quote was found.
"""
double_quote_index = template.find('"', start_index, end_index)
single_quote_index = template.find("'", start_index, end_index)
if 0 <= single_quote_index or 0 <= double_quote_index:
if 0 <= single_quote_index and 0 <= double_quote_index:
return min(single_quote_index, double_quote_index)
else:
return max(single_quote_index, double_quote_index)
return -1
def _parse_string(self, template, start_index):
"""
Finds the indices of a string inside a template.
Arguments:
template: The template to be searched.
start_index: The start index of the open quote.
Returns:
A dict containing the following:
start_index: The index of the first quote.
end_index: The index following the closing quote, or -1 if
unparseable
quote_length: The length of the quote. Could be 3 for a Python
triple quote.
"""
quote = template[start_index]
if quote not in ["'", '"']:
raise ValueError("start_index must refer to a single or double quote.")
triple_quote = quote * 3
if template.startswith(triple_quote, start_index):
quote = triple_quote
result = {
'start_index': start_index,
'end_index': -1,
'quote_length': len(quote),
}
start_index += len(quote)
while True:
quote_end_index = template.find(quote, start_index)
backslash_index = template.find("\\", start_index)
if quote_end_index < 0:
return result
if 0 <= backslash_index < quote_end_index:
start_index = backslash_index + 2
else:
result['end_index'] = quote_end_index + len(quote)
return result
class UnderscoreTemplateLinter(object): class UnderscoreTemplateLinter(object):
...@@ -1001,6 +1142,25 @@ class UnderscoreTemplateLinter(object): ...@@ -1001,6 +1142,25 @@ class UnderscoreTemplateLinter(object):
return expressions return expressions
def _process_file(full_path, template_linters, options, out):
"""
For each linter, lints the provided file. This means finding and printing
violations.
Arguments:
full_path: The full path of the file to lint.
template_linters: A list of linting objects.
options: A list of the options.
out: output file
"""
directory = os.path.dirname(full_path)
file = os.path.basename(full_path)
for template_linter in template_linters:
results = template_linter.process_file(directory, file)
results.print_results(options, out)
def _process_current_walk(current_walk, template_linters, options, out): def _process_current_walk(current_walk, template_linters, options, out):
""" """
For each linter, lints all the files in the current os walk. This means For each linter, lints all the files in the current os walk. This means
...@@ -1016,10 +1176,8 @@ def _process_current_walk(current_walk, template_linters, options, out): ...@@ -1016,10 +1176,8 @@ def _process_current_walk(current_walk, template_linters, options, out):
walk_directory = os.path.normpath(current_walk[0]) walk_directory = os.path.normpath(current_walk[0])
walk_files = current_walk[2] walk_files = current_walk[2]
for walk_file in walk_files: for walk_file in walk_files:
walk_file = os.path.normpath(walk_file) full_path = os.path.join(walk_directory, walk_file)
for template_linter in template_linters: _process_file(full_path, template_linters, options, out)
results = template_linter.process_file(walk_directory, walk_file)
results.print_results(options, out)
def _process_os_walk(starting_dir, template_linters, options, out): def _process_os_walk(starting_dir, template_linters, options, out):
...@@ -1037,33 +1195,60 @@ def _process_os_walk(starting_dir, template_linters, options, out): ...@@ -1037,33 +1195,60 @@ def _process_os_walk(starting_dir, template_linters, options, out):
_process_current_walk(current_walk, template_linters, options, out) _process_current_walk(current_walk, template_linters, options, out)
def _parse_arg(arg, option):
"""
Parses an argument searching for --[option]=[OPTION_VALUE]
Arguments:
arg: The system argument
option: The specific option to be searched for (e.g. "file")
Returns:
The option value for a match, or None if arg is not for this option
"""
if arg.startswith('--{}='.format(option)):
option_value = arg.split('=')[1]
if option_value.startswith("'") or option_value.startswith('"'):
option_value = option_value[1:-1]
return option_value
else:
return None
def main(): def main():
""" """
Used to execute the linter. Use --help option for help. Used to execute the linter. Use --help option for help.
Prints all of the violations. Prints all violations.
""" """
epilog = 'rules:\n'
for rule in Rules.__members__.values():
epilog += " {0[0]}: {0[1]}\n".format(rule.value)
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description='Checks that templates are safe.',
epilog=epilog
)
parser.add_argument('--quiet', dest='quiet', action='store_true', help='only display the filenames that contain violations')
parser.add_argument('--file', dest='file', nargs=1, default=None, help='a single file to lint')
parser.add_argument('--dir', dest='directory', nargs=1, default=['.'], help='the directory to lint (including sub-directories)')
#TODO: Use click args = parser.parse_args()
if '--help' in sys.argv:
print("Check that templates are safe.")
print("Options:")
print(" --quiet Just display the filenames that have violations.")
print("")
print("Rules:")
for rule in Rules.__members__.values():
print(" {0[0]}: {0[1]}".format(rule.value))
return
is_quiet = '--quiet' in sys.argv
# TODO --file=...
options = { options = {
'is_quiet': is_quiet, 'is_quiet': args.quiet,
} }
template_linters = [MakoTemplateLinter(), UnderscoreTemplateLinter()] template_linters = [MakoTemplateLinter(), UnderscoreTemplateLinter()]
_process_os_walk('.', template_linters, options, out=sys.stdout) if args.file is not None:
if os.path.isfile(args.file[0]) is False:
raise ValueError("File [{}] is not a valid file.".format(args.file[0]))
_process_file(args.file[0], template_linters, options, out=sys.stdout)
else:
if os.path.exists(args.directory[0]) is False or os.path.isfile(args.directory[0]) is True:
raise ValueError("Directory [{}] is not a valid directory.".format(args.directory[0]))
_process_os_walk(args.directory[0], template_linters, options, out=sys.stdout)
if __name__ == "__main__": if __name__ == "__main__":
......
...@@ -109,7 +109,14 @@ class TestMakoTemplateLinter(TestCase): ...@@ -109,7 +109,14 @@ class TestMakoTemplateLinter(TestCase):
if data['violations'] > 0: if data['violations'] > 0:
self.assertEqual(results.violations[0].rule, data['rule']) self.assertEqual(results.violations[0].rule, data['rule'])
def test_check_mako_expressions_in_html(self): @data(
{'expression': '${x}', 'rule': None},
{'expression': '${{unbalanced}', 'rule': Rules.mako_unparseable_expression},
{'expression': '${x | n}', 'rule': Rules.mako_invalid_html_filter},
{'expression': '${x | h}', 'rule': Rules.mako_unwanted_html_filter},
{'expression': '${x | n, dump_js_escaped_json}', 'rule': Rules.mako_invalid_html_filter},
)
def test_check_mako_expressions_in_html(self, data):
""" """
Test _check_mako_file_is_safe in html context provides appropriate violations Test _check_mako_file_is_safe in html context provides appropriate violations
""" """
...@@ -118,25 +125,12 @@ class TestMakoTemplateLinter(TestCase): ...@@ -118,25 +125,12 @@ class TestMakoTemplateLinter(TestCase):
mako_template = textwrap.dedent(""" mako_template = textwrap.dedent("""
<%page expression_filter="h"/> <%page expression_filter="h"/>
${x} {expression}
${'{{unbalanced-nested'} """.format(expression=data['expression']))
${x | n}
${x | h}
${x | n, dump_js_escaped_json}
""")
linter._check_mako_file_is_safe(mako_template, results) linter._check_mako_file_is_safe(mako_template, results)
self.assertEqual(len(results.violations), 4) self._validate_data_rule(data, results)
self.assertEqual(results.violations[0].rule, Rules.mako_unparsable_expression)
start_index = results.violations[0].expression['start_index']
self.assertEqual(mako_template[start_index:start_index + 24], "${'{{unbalanced-nested'}")
self.assertEqual(results.violations[1].rule, Rules.mako_invalid_html_filter)
self.assertEqual(results.violations[1].expression['expression'], "${x | n}")
self.assertEqual(results.violations[2].rule, Rules.mako_unwanted_html_filter)
self.assertEqual(results.violations[2].expression['expression'], "${x | h}")
self.assertEqual(results.violations[3].rule, Rules.mako_invalid_html_filter)
self.assertEqual(results.violations[3].expression['expression'], "${x | n, dump_js_escaped_json}")
def test_check_mako_expression_display_name(self): def test_check_mako_expression_display_name(self):
""" """
...@@ -156,6 +150,87 @@ class TestMakoTemplateLinter(TestCase): ...@@ -156,6 +150,87 @@ class TestMakoTemplateLinter(TestCase):
self.assertEqual(len(results.violations), 1) self.assertEqual(len(results.violations), 1)
self.assertEqual(results.violations[0].rule, Rules.mako_deprecated_display_name) self.assertEqual(results.violations[0].rule, Rules.mako_deprecated_display_name)
@data(
{
'expression':
textwrap.dedent("""
${"Mixed {span_start}text{span_end}".format(
span_start=HTML("<span>"),
span_end=HTML("</span>"),
)}
"""),
'rule': Rules.mako_html_requires_text
},
{
'expression':
textwrap.dedent("""
${Text("Mixed {span_start}text{span_end}").format(
span_start=HTML("<span>"),
span_end=HTML("</span>"),
)}
"""),
'rule': None
},
{
'expression':
textwrap.dedent("""
${"Mixed {span_start}{text}{span_end}".format(
span_start=HTML("<span>"),
text=Text("This should still break."),
span_end=HTML("</span>"),
)}
"""),
'rule': Rules.mako_html_requires_text
},
{
'expression':
textwrap.dedent("""
${Text("Mixed {link_start}text{link_end}".format(
link_start=HTML("<a href='{}'>").format(url),
link_end=HTML("</a>"),
))}
"""),
'rule': Rules.mako_close_before_format
},
{
'expression':
textwrap.dedent("""
${Text("Mixed {link_start}text{link_end}").format(
link_start=HTML("<a href='{}'>".format(url)),
link_end=HTML("</a>"),
)}
"""),
'rule': Rules.mako_close_before_format
},
{
'expression': """${ Text("text") }""",
'rule': Rules.mako_text_redundant
},
{
'expression': """${ HTML("<span></span>") }""",
'rule': None
},
{
'expression': """${ HTML("<span></span>") + "some other text" }""",
'rule': Rules.mako_html_alone
},
)
def test_check_mako_with_text_and_html(self, data):
"""
Test _check_mako_file_is_safe tests for proper use of Text() and Html().
"""
linter = MakoTemplateLinter()
results = FileResults('')
mako_template = textwrap.dedent("""
<%page expression_filter="h"/>
{expression}
""".format(expression=data['expression']))
linter._check_mako_file_is_safe(mako_template, results)
self._validate_data_rule(data, results)
def test_check_mako_expression_default_disabled(self): def test_check_mako_expression_default_disabled(self):
""" """
Test _check_mako_file_is_safe with disable pragma for safe-by-default Test _check_mako_file_is_safe with disable pragma for safe-by-default
...@@ -228,7 +303,14 @@ class TestMakoTemplateLinter(TestCase): ...@@ -228,7 +303,14 @@ class TestMakoTemplateLinter(TestCase):
self.assertEqual(len(results.violations), 1) self.assertEqual(len(results.violations), 1)
self.assertEqual(results.violations[0].rule, Rules.mako_missing_default) self.assertEqual(results.violations[0].rule, Rules.mako_missing_default)
def test_check_mako_expressions_in_javascript(self): @data(
{'expression': '${x}', 'rule': Rules.mako_invalid_js_filter},
{'expression': '${{unbalanced}', 'rule': Rules.mako_unparseable_expression},
{'expression': '${x | n}', 'rule': Rules.mako_invalid_js_filter},
{'expression': '${x | h}', 'rule': Rules.mako_invalid_js_filter},
{'expression': '${x | n, dump_js_escaped_json}', 'rule': None},
)
def test_check_mako_expressions_in_javascript(self, data):
""" """
Test _check_mako_file_is_safe in JavaScript script context provides Test _check_mako_file_is_safe in JavaScript script context provides
appropriate violations appropriate violations
...@@ -239,29 +321,19 @@ class TestMakoTemplateLinter(TestCase): ...@@ -239,29 +321,19 @@ class TestMakoTemplateLinter(TestCase):
mako_template = textwrap.dedent(""" mako_template = textwrap.dedent("""
<%page expression_filter="h"/> <%page expression_filter="h"/>
<script> <script>
${x} {expression}
${'{{unbalanced-nested'}
${x | n}
${x | h}
${x | n, dump_js_escaped_json}
"${x-with-quotes | n, js_escaped_string}"
</script> </script>
""") """.format(expression=data['expression']))
linter._check_mako_file_is_safe(mako_template, results) linter._check_mako_file_is_safe(mako_template, results)
self.assertEqual(len(results.violations), 4) self._validate_data_rule(data, results)
self.assertEqual(results.violations[0].rule, Rules.mako_invalid_js_filter)
self.assertEqual(results.violations[0].expression['expression'], "${x}")
self.assertEqual(results.violations[1].rule, Rules.mako_unparsable_expression)
start_index = results.violations[1].expression['start_index']
self.assertEqual(mako_template[start_index:start_index + 24], "${'{{unbalanced-nested'}")
self.assertEqual(results.violations[2].rule, Rules.mako_invalid_js_filter)
self.assertEqual(results.violations[2].expression['expression'], "${x | n}")
self.assertEqual(results.violations[3].rule, Rules.mako_invalid_js_filter)
self.assertEqual(results.violations[3].expression['expression'], "${x | h}")
def test_check_mako_expressions_in_require_js(self): @data(
{'expression': '${x}', 'rule': Rules.mako_invalid_js_filter},
{'expression': '${x | n, js_escaped_string}', 'rule': None},
)
def test_check_mako_expressions_in_require_js(self, data):
""" """
Test _check_mako_file_is_safe in JavaScript require context provides Test _check_mako_file_is_safe in JavaScript require context provides
appropriate violations appropriate violations
...@@ -271,17 +343,14 @@ class TestMakoTemplateLinter(TestCase): ...@@ -271,17 +343,14 @@ class TestMakoTemplateLinter(TestCase):
mako_template = textwrap.dedent(""" mako_template = textwrap.dedent("""
<%page expression_filter="h"/> <%page expression_filter="h"/>
<%static:require_module module_name="${x}" class_name="TestFactory"> <%static:require_module module_name="${{x}}" class_name="TestFactory">
${x} {expression}
${x | n, js_escaped_string}
</%static:require_module> </%static:require_module>
""") """.format(expression=data['expression']))
linter._check_mako_file_is_safe(mako_template, results) linter._check_mako_file_is_safe(mako_template, results)
self.assertEqual(len(results.violations), 1) self._validate_data_rule(data, results)
self.assertEqual(results.violations[0].rule, Rules.mako_invalid_js_filter)
self.assertEqual(results.violations[0].expression['expression'], "${x}")
@data( @data(
{'media_type': 'text/javascript', 'expected_violations': 0}, {'media_type': 'text/javascript', 'expected_violations': 0},
...@@ -339,7 +408,20 @@ class TestMakoTemplateLinter(TestCase): ...@@ -339,7 +408,20 @@ class TestMakoTemplateLinter(TestCase):
self.assertEqual(results.violations[3].rule, Rules.mako_invalid_js_filter) self.assertEqual(results.violations[3].rule, Rules.mako_invalid_js_filter)
self.assertEqual(results.violations[4].rule, Rules.mako_unwanted_html_filter) self.assertEqual(results.violations[4].rule, Rules.mako_unwanted_html_filter)
def test_expression_detailed_results(self): @data(
{'template': "\n${x | n}", 'parseable': True},
{
'template': textwrap.dedent(
"""
<div>${(
'tabbed-multi-line-expression'
) | n}</div>
"""),
'parseable': True
},
{'template': "${{unparseable}", 'parseable': False},
)
def test_expression_detailed_results(self, data):
""" """
Test _check_mako_file_is_safe provides detailed results, including line Test _check_mako_file_is_safe provides detailed results, including line
numbers, columns, and line numbers, columns, and line
...@@ -347,86 +429,115 @@ class TestMakoTemplateLinter(TestCase): ...@@ -347,86 +429,115 @@ class TestMakoTemplateLinter(TestCase):
linter = MakoTemplateLinter() linter = MakoTemplateLinter()
results = FileResults('') results = FileResults('')
mako_template = textwrap.dedent(""" linter._check_mako_file_is_safe(data['template'], results)
${x | n}
<div>${(
'tabbed-multi-line-expression'
) | n}</div>
${'{{unbalanced-nested' | n}
""")
linter._check_mako_file_is_safe(mako_template, results)
self.assertEqual(len(results.violations), 4) self.assertEqual(len(results.violations), 2)
self.assertEqual(results.violations[0].rule, Rules.mako_missing_default) self.assertEqual(results.violations[0].rule, Rules.mako_missing_default)
self.assertEqual(results.violations[1].start_line, 2) violation = results.violations[1]
self.assertEqual(results.violations[1].start_column, 1) lines = list(data['template'].splitlines())
self.assertEqual(results.violations[1].end_line, 2) self.assertTrue("${" in lines[violation.start_line - 1])
self.assertEqual(results.violations[1].end_column, 8) self.assertTrue(lines[violation.start_line - 1].startswith("${", violation.start_column - 1))
self.assertEqual(len(results.violations[1].lines), 1) if data['parseable']:
self.assertEqual(results.violations[1].lines[0], "${x | n}") self.assertTrue("}" in lines[violation.end_line - 1])
self.assertTrue(lines[violation.end_line - 1].startswith("}", violation.end_column - 1))
self.assertEqual(results.violations[2].start_line, 3) else:
self.assertEqual(results.violations[2].start_column, 10) self.assertEqual(violation.start_line, violation.end_line)
self.assertEqual(results.violations[2].end_line, 5) self.assertEqual(violation.end_column, "?")
self.assertEqual(results.violations[2].end_column, 10) self.assertEqual(len(violation.lines), violation.end_line - violation.start_line + 1)
self.assertEqual(len(results.violations[2].lines), 3) for line_index in range(0, len(violation.lines)):
self.assertEqual(results.violations[2].lines[0], " <div>${(") self.assertEqual(violation.lines[line_index], lines[line_index + violation.start_line - 1])
self.assertEqual(
results.violations[2].lines[1], @data(
" 'tabbed-multi-line-expression'" {'template': "${x}"},
) {'template': "\n ${x}"},
self.assertEqual(results.violations[2].lines[2], " ) | n}</div>") {'template': "${x} "},
{'template': "${{test-balanced-delims}} "},
self.assertEqual(results.violations[3].start_line, 6) {'template': "${'{unbalanced in string'}"},
self.assertEqual(results.violations[3].start_column, 1) {'template': "${'unbalanced in string}'}"},
self.assertEqual(results.violations[3].end_line, 6) {'template': "${(\n 'tabbed-multi-line-expression'\n )}"},
self.assertEqual(results.violations[3].end_column, "?") )
self.assertEqual(len(results.violations[3].lines), 1) def test_find_mako_expressions(self, data):
self.assertEqual( """
results.violations[3].lines[0], Test _find_mako_expressions for parseable expressions
"${'{{unbalanced-nested' | n}"
)
def test_find_mako_expressions(self):
"""
Test _find_mako_expressions finds appropriate expressions
""" """
linter = MakoTemplateLinter() linter = MakoTemplateLinter()
mako_template = textwrap.dedent(""" expressions = linter._find_mako_expressions(data['template'])
${x}
${tabbed-x}
${(
'tabbed-multi-line-expression'
)}
${'{{unbalanced-nested'}
${'{{nested}}'}
<div>no expression</div>
""")
expressions = linter._find_mako_expressions(mako_template) self.assertEqual(len(expressions), 1)
start_index = expressions[0]['start_index']
end_index = expressions[0]['end_index']
self.assertEqual(data['template'][start_index:end_index + 1], data['template'].strip())
self.assertEqual(expressions[0]['expression'], data['template'].strip())
self.assertEqual(len(expressions), 5) @data(
self._validate_expression(mako_template, expressions[0], '${x}') {'template': " ${{unparseable} ${}", 'start_index': 1},
self._validate_expression(mako_template, expressions[1], '${tabbed-x}') {'template': " ${'unparseable} ${}", 'start_index': 1},
self._validate_expression(mako_template, expressions[2], "${(\n 'tabbed-multi-line-expression'\n )}") )
def test_find_mako_expressions(self, data):
"""
Test _find_mako_expressions for unparseable expressions
"""
linter = MakoTemplateLinter()
# won't parse unbalanced nested {}'s expressions = linter._find_mako_expressions(data['template'])
unbalanced_expression = "${'{{unbalanced-nested'}" self.assertTrue(2 <= len(expressions))
self.assertEqual(expressions[3]['end_index'], -1) self.assertEqual(expressions[0]['start_index'], data['start_index'])
start_index = expressions[3]['start_index'] self.assertIsNone(expressions[0]['expression'])
self.assertEqual(mako_template[start_index:start_index + len(unbalanced_expression)], unbalanced_expression)
self.assertEqual(expressions[3]['expression'], None)
self._validate_expression(mako_template, expressions[4], "${'{{nested}}'}") @data(
{'template': """${""}""", 'start_index': 0, 'end_index': 5, 'expected_index': 2},
{'template': """${''}""", 'start_index': 0, 'end_index': 5, 'expected_index': 2},
{'template': """${"''"}""", 'start_index': 0, 'end_index': 7, 'expected_index': 2},
{'template': """${'""'}""", 'start_index': 0, 'end_index': 7, 'expected_index': 2},
{'template': """${'""'}""", 'start_index': 3, 'end_index': 7, 'expected_index': 3},
{'template': """${'""'}""", 'start_index': 0, 'end_index': 1, 'expected_index': -1},
)
def test_find_string_start(self, data):
"""
Test _find_string_start helper
"""
linter = MakoTemplateLinter()
string_start_index = linter._find_string_start(data['template'], data['start_index'], data['end_index'])
self.assertEqual(string_start_index, data['expected_index'])
@data(
{
'template': '${""}',
'result': {'start_index': 2, 'end_index': 4, 'quote_length': 1}
},
{
'template': "${'Hello'}",
'result': {'start_index': 2, 'end_index': 9, 'quote_length': 1}
},
{
'template': '${""" triple """}',
'result': {'start_index': 2, 'end_index': 16, 'quote_length': 3}
},
{
'template': r""" ${" \" \\"} """,
'result': {'start_index': 3, 'end_index': 11, 'quote_length': 1}
},
)
def test_parse_string(self, data):
"""
Test _parse_string helper
"""
linter = MakoTemplateLinter()
result = linter._parse_string(data['template'], data['result']['start_index'])
def _validate_expression(self, template_string, expression, expected_expression): self.assertDictEqual(result, data['result'])
start_index = expression['start_index']
end_index = expression['end_index'] def _validate_data_rule(self, data, results):
self.assertEqual(template_string[start_index:end_index + 1], expected_expression) if data['rule'] is None:
self.assertEqual(expression['expression'], expected_expression) self.assertEqual(len(results.violations), 0)
else:
self.assertEqual(len(results.violations), 1)
self.assertEqual(results.violations[0].rule, data['rule'])
@ddt @ddt
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment