Merge pull request #12082 from edx/robrap/linter-mako
TNL-4324: Enhance linter for HTML() and Text()
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
A linting tool to check if templates are safe
|
||||
"""
|
||||
from __future__ import print_function
|
||||
import argparse
|
||||
from enum import Enum
|
||||
import os
|
||||
import re
|
||||
@@ -205,8 +206,8 @@ class Rules(Enum):
|
||||
'mako-multiple-page-tags',
|
||||
'A Mako template can only have one <%page> tag.'
|
||||
)
|
||||
mako_unparsable_expression = (
|
||||
'mako-unparsable-expression',
|
||||
mako_unparseable_expression = (
|
||||
'mako-unparseable-expression',
|
||||
'The expression could not be properly parsed.'
|
||||
)
|
||||
mako_unwanted_html_filter = (
|
||||
@@ -217,15 +218,30 @@ class Rules(Enum):
|
||||
'mako-invalid-html-filter',
|
||||
'The expression is using an invalid filter in an HTML context.'
|
||||
)
|
||||
mako_deprecated_display_name = (
|
||||
'mako-deprecated-display-name',
|
||||
'Replace deprecated display_name_with_default_escaped with display_name_with_default.'
|
||||
)
|
||||
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',
|
||||
'Replace deprecated display_name_with_default_escaped with display_name_with_default.'
|
||||
)
|
||||
mako_html_requires_text = (
|
||||
'mako-html-requires-text',
|
||||
'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',
|
||||
'Expressions should be escaped using <%- expression %>.'
|
||||
@@ -421,7 +437,7 @@ class ExpressionRuleViolation(RuleViolation):
|
||||
line_number,
|
||||
column,
|
||||
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)
|
||||
|
||||
|
||||
@@ -630,13 +646,14 @@ class MakoTemplateLinter(object):
|
||||
for expression in expressions:
|
||||
if expression['expression'] is None:
|
||||
results.violations.append(ExpressionRuleViolation(
|
||||
Rules.mako_unparsable_expression, expression
|
||||
Rules.mako_unparseable_expression, expression
|
||||
))
|
||||
continue
|
||||
|
||||
context = self._get_context(contexts, expression['start_index'])
|
||||
self._check_filters(mako_template, expression, context, has_page_default, results)
|
||||
self._check_deprecated_display_name(expression, results)
|
||||
self._check_html_and_text(expression, results)
|
||||
|
||||
def _check_deprecated_display_name(self, expression, results):
|
||||
"""
|
||||
@@ -654,6 +671,52 @@ class MakoTemplateLinter(object):
|
||||
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):
|
||||
"""
|
||||
Checks that the filters used in the given Mako expression are valid
|
||||
@@ -796,9 +859,9 @@ class MakoTemplateLinter(object):
|
||||
|
||||
while True:
|
||||
start_index = mako_template.find(start_delim, start_index)
|
||||
if (start_index < 0):
|
||||
if start_index < 0:
|
||||
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:
|
||||
expression = None
|
||||
@@ -817,39 +880,117 @@ class MakoTemplateLinter(object):
|
||||
|
||||
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
|
||||
any additional open/closed braces that are balanced inside. Does not
|
||||
take into consideration strings.
|
||||
Finds the index of the closing char that matches the opening char.
|
||||
|
||||
For example, this could be used to find the end of a Mako expression,
|
||||
where the open and close characters would be '{' and '}'.
|
||||
|
||||
Arguments:
|
||||
mako_template: The template text.
|
||||
start_index: The start index of the Mako expression.
|
||||
num_open_curlies: The current number of open expressions.
|
||||
start_delim: If provided (e.g. '${' for Mako expressions), the
|
||||
closing character must be found before the next start_delim.
|
||||
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:
|
||||
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)
|
||||
if end_curly_index < 0:
|
||||
# if we can't find an end_curly, let's just quit
|
||||
return end_curly_index
|
||||
close_char_index = template.find(close_char, start_index)
|
||||
if close_char_index < 0:
|
||||
# if we can't find an end_char, let's just quit
|
||||
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 mako_template[open_curly_index - 1] == '$':
|
||||
# assume if we find "${" it is the start of the next expression
|
||||
# and we have a parse error
|
||||
if 0 <= start_quote_index:
|
||||
string_end_index = self._parse_string(template, start_quote_index)['end_index']
|
||||
if string_end_index < 0:
|
||||
return -1
|
||||
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:
|
||||
return end_curly_index
|
||||
if (open_char_index >= 0) and (open_char_index < close_char_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:
|
||||
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):
|
||||
@@ -1001,6 +1142,25 @@ class UnderscoreTemplateLinter(object):
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
walk_directory = os.path.normpath(current_walk[0])
|
||||
walk_files = current_walk[2]
|
||||
for walk_file in walk_files:
|
||||
walk_file = os.path.normpath(walk_file)
|
||||
for template_linter in template_linters:
|
||||
results = template_linter.process_file(walk_directory, walk_file)
|
||||
results.print_results(options, out)
|
||||
full_path = os.path.join(walk_directory, walk_file)
|
||||
_process_file(full_path, 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):
|
||||
_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():
|
||||
"""
|
||||
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)
|
||||
|
||||
#TODO: Use click
|
||||
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
|
||||
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)')
|
||||
|
||||
is_quiet = '--quiet' in sys.argv
|
||||
# TODO --file=...
|
||||
args = parser.parse_args()
|
||||
|
||||
options = {
|
||||
'is_quiet': is_quiet,
|
||||
'is_quiet': args.quiet,
|
||||
}
|
||||
|
||||
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__":
|
||||
|
||||
@@ -109,7 +109,14 @@ class TestMakoTemplateLinter(TestCase):
|
||||
if data['violations'] > 0:
|
||||
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
|
||||
"""
|
||||
@@ -118,25 +125,12 @@ class TestMakoTemplateLinter(TestCase):
|
||||
|
||||
mako_template = textwrap.dedent("""
|
||||
<%page expression_filter="h"/>
|
||||
${x}
|
||||
${'{{unbalanced-nested'}
|
||||
${x | n}
|
||||
${x | h}
|
||||
${x | n, dump_js_escaped_json}
|
||||
""")
|
||||
{expression}
|
||||
""".format(expression=data['expression']))
|
||||
|
||||
linter._check_mako_file_is_safe(mako_template, results)
|
||||
|
||||
self.assertEqual(len(results.violations), 4)
|
||||
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}")
|
||||
self._validate_data_rule(data, results)
|
||||
|
||||
def test_check_mako_expression_display_name(self):
|
||||
"""
|
||||
@@ -156,6 +150,87 @@ class TestMakoTemplateLinter(TestCase):
|
||||
self.assertEqual(len(results.violations), 1)
|
||||
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):
|
||||
"""
|
||||
Test _check_mako_file_is_safe with disable pragma for safe-by-default
|
||||
@@ -228,7 +303,14 @@ class TestMakoTemplateLinter(TestCase):
|
||||
self.assertEqual(len(results.violations), 1)
|
||||
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
|
||||
appropriate violations
|
||||
@@ -239,29 +321,19 @@ class TestMakoTemplateLinter(TestCase):
|
||||
mako_template = textwrap.dedent("""
|
||||
<%page expression_filter="h"/>
|
||||
<script>
|
||||
${x}
|
||||
${'{{unbalanced-nested'}
|
||||
${x | n}
|
||||
${x | h}
|
||||
${x | n, dump_js_escaped_json}
|
||||
"${x-with-quotes | n, js_escaped_string}"
|
||||
{expression}
|
||||
</script>
|
||||
""")
|
||||
""".format(expression=data['expression']))
|
||||
|
||||
linter._check_mako_file_is_safe(mako_template, results)
|
||||
|
||||
self.assertEqual(len(results.violations), 4)
|
||||
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}")
|
||||
self._validate_data_rule(data, results)
|
||||
|
||||
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
|
||||
appropriate violations
|
||||
@@ -271,17 +343,14 @@ class TestMakoTemplateLinter(TestCase):
|
||||
|
||||
mako_template = textwrap.dedent("""
|
||||
<%page expression_filter="h"/>
|
||||
<%static:require_module module_name="${x}" class_name="TestFactory">
|
||||
${x}
|
||||
${x | n, js_escaped_string}
|
||||
<%static:require_module module_name="${{x}}" class_name="TestFactory">
|
||||
{expression}
|
||||
</%static:require_module>
|
||||
""")
|
||||
""".format(expression=data['expression']))
|
||||
|
||||
linter._check_mako_file_is_safe(mako_template, results)
|
||||
|
||||
self.assertEqual(len(results.violations), 1)
|
||||
self.assertEqual(results.violations[0].rule, Rules.mako_invalid_js_filter)
|
||||
self.assertEqual(results.violations[0].expression['expression'], "${x}")
|
||||
self._validate_data_rule(data, results)
|
||||
|
||||
@data(
|
||||
{'media_type': 'text/javascript', 'expected_violations': 0},
|
||||
@@ -339,7 +408,20 @@ class TestMakoTemplateLinter(TestCase):
|
||||
self.assertEqual(results.violations[3].rule, Rules.mako_invalid_js_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
|
||||
numbers, columns, and line
|
||||
@@ -347,86 +429,115 @@ class TestMakoTemplateLinter(TestCase):
|
||||
linter = MakoTemplateLinter()
|
||||
results = FileResults('')
|
||||
|
||||
mako_template = textwrap.dedent("""
|
||||
${x | n}
|
||||
<div>${(
|
||||
'tabbed-multi-line-expression'
|
||||
) | n}</div>
|
||||
${'{{unbalanced-nested' | n}
|
||||
""")
|
||||
linter._check_mako_file_is_safe(data['template'], results)
|
||||
|
||||
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[1].start_line, 2)
|
||||
self.assertEqual(results.violations[1].start_column, 1)
|
||||
self.assertEqual(results.violations[1].end_line, 2)
|
||||
self.assertEqual(results.violations[1].end_column, 8)
|
||||
self.assertEqual(len(results.violations[1].lines), 1)
|
||||
self.assertEqual(results.violations[1].lines[0], "${x | n}")
|
||||
violation = results.violations[1]
|
||||
lines = list(data['template'].splitlines())
|
||||
self.assertTrue("${" in lines[violation.start_line - 1])
|
||||
self.assertTrue(lines[violation.start_line - 1].startswith("${", violation.start_column - 1))
|
||||
if data['parseable']:
|
||||
self.assertTrue("}" in lines[violation.end_line - 1])
|
||||
self.assertTrue(lines[violation.end_line - 1].startswith("}", violation.end_column - 1))
|
||||
else:
|
||||
self.assertEqual(violation.start_line, violation.end_line)
|
||||
self.assertEqual(violation.end_column, "?")
|
||||
self.assertEqual(len(violation.lines), violation.end_line - violation.start_line + 1)
|
||||
for line_index in range(0, len(violation.lines)):
|
||||
self.assertEqual(violation.lines[line_index], lines[line_index + violation.start_line - 1])
|
||||
|
||||
self.assertEqual(results.violations[2].start_line, 3)
|
||||
self.assertEqual(results.violations[2].start_column, 10)
|
||||
self.assertEqual(results.violations[2].end_line, 5)
|
||||
self.assertEqual(results.violations[2].end_column, 10)
|
||||
self.assertEqual(len(results.violations[2].lines), 3)
|
||||
self.assertEqual(results.violations[2].lines[0], " <div>${(")
|
||||
self.assertEqual(
|
||||
results.violations[2].lines[1],
|
||||
" 'tabbed-multi-line-expression'"
|
||||
)
|
||||
self.assertEqual(results.violations[2].lines[2], " ) | n}</div>")
|
||||
|
||||
self.assertEqual(results.violations[3].start_line, 6)
|
||||
self.assertEqual(results.violations[3].start_column, 1)
|
||||
self.assertEqual(results.violations[3].end_line, 6)
|
||||
self.assertEqual(results.violations[3].end_column, "?")
|
||||
self.assertEqual(len(results.violations[3].lines), 1)
|
||||
self.assertEqual(
|
||||
results.violations[3].lines[0],
|
||||
"${'{{unbalanced-nested' | n}"
|
||||
)
|
||||
|
||||
def test_find_mako_expressions(self):
|
||||
@data(
|
||||
{'template': "${x}"},
|
||||
{'template': "\n ${x}"},
|
||||
{'template': "${x} "},
|
||||
{'template': "${{test-balanced-delims}} "},
|
||||
{'template': "${'{unbalanced in string'}"},
|
||||
{'template': "${'unbalanced in string}'}"},
|
||||
{'template': "${(\n 'tabbed-multi-line-expression'\n )}"},
|
||||
)
|
||||
def test_find_mako_expressions(self, data):
|
||||
"""
|
||||
Test _find_mako_expressions finds appropriate expressions
|
||||
Test _find_mako_expressions for parseable expressions
|
||||
"""
|
||||
linter = MakoTemplateLinter()
|
||||
|
||||
mako_template = textwrap.dedent("""
|
||||
${x}
|
||||
${tabbed-x}
|
||||
${(
|
||||
'tabbed-multi-line-expression'
|
||||
)}
|
||||
${'{{unbalanced-nested'}
|
||||
${'{{nested}}'}
|
||||
<div>no expression</div>
|
||||
""")
|
||||
expressions = linter._find_mako_expressions(data['template'])
|
||||
|
||||
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)
|
||||
self._validate_expression(mako_template, expressions[0], '${x}')
|
||||
self._validate_expression(mako_template, expressions[1], '${tabbed-x}')
|
||||
self._validate_expression(mako_template, expressions[2], "${(\n 'tabbed-multi-line-expression'\n )}")
|
||||
@data(
|
||||
{'template': " ${{unparseable} ${}", 'start_index': 1},
|
||||
{'template': " ${'unparseable} ${}", 'start_index': 1},
|
||||
)
|
||||
def test_find_mako_expressions(self, data):
|
||||
"""
|
||||
Test _find_mako_expressions for unparseable expressions
|
||||
"""
|
||||
linter = MakoTemplateLinter()
|
||||
|
||||
# won't parse unbalanced nested {}'s
|
||||
unbalanced_expression = "${'{{unbalanced-nested'}"
|
||||
self.assertEqual(expressions[3]['end_index'], -1)
|
||||
start_index = expressions[3]['start_index']
|
||||
self.assertEqual(mako_template[start_index:start_index + len(unbalanced_expression)], unbalanced_expression)
|
||||
self.assertEqual(expressions[3]['expression'], None)
|
||||
expressions = linter._find_mako_expressions(data['template'])
|
||||
self.assertTrue(2 <= len(expressions))
|
||||
self.assertEqual(expressions[0]['start_index'], data['start_index'])
|
||||
self.assertIsNone(expressions[0]['expression'])
|
||||
|
||||
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()
|
||||
|
||||
def _validate_expression(self, template_string, expression, expected_expression):
|
||||
start_index = expression['start_index']
|
||||
end_index = expression['end_index']
|
||||
self.assertEqual(template_string[start_index:end_index + 1], expected_expression)
|
||||
self.assertEqual(expression['expression'], expected_expression)
|
||||
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'])
|
||||
|
||||
self.assertDictEqual(result, data['result'])
|
||||
|
||||
def _validate_data_rule(self, data, results):
|
||||
if data['rule'] is None:
|
||||
self.assertEqual(len(results.violations), 0)
|
||||
else:
|
||||
self.assertEqual(len(results.violations), 1)
|
||||
self.assertEqual(results.violations[0].rule, data['rule'])
|
||||
|
||||
|
||||
@ddt
|
||||
|
||||
Reference in New Issue
Block a user