diff --git a/jinja2cli/cli.py b/jinja2cli/cli.py index edf6210..8c2fed8 100644 --- a/jinja2cli/cli.py +++ b/jinja2cli/cli.py @@ -252,9 +252,10 @@ def _load_json5(): def render(template_path, data, extensions, strict=False): from jinja2 import ( __version__ as jinja_version, + BaseLoader, Environment, - FileSystemLoader, StrictUndefined, + TemplateNotFound, ) # Starting with jinja2 3.1, `with_` and `autoescape` are no longer @@ -268,8 +269,46 @@ def render(template_path, data, extensions, strict=False): if ext not in extensions: extensions.append(ext) + # Custom loader that allows safe ../ traversal + class SafeRelativeLoader(BaseLoader): + """Allows ../ in imports while restricting access to project root.""" + + def __init__(self, template_dir, cwd): + # Resolve symlinks for consistent path comparison + self.template_dir = os.path.realpath(os.path.abspath(template_dir)) + self.cwd = os.path.realpath(os.path.abspath(cwd)) + self.root = os.path.commonpath([self.template_dir, self.cwd]) + + def _is_safe(self, path): + """Check if path is within security boundary.""" + real_path = os.path.realpath(os.path.abspath(path)) + try: + os.path.relpath(real_path, self.root) + return real_path.startswith(self.root + os.sep) or real_path == self.root + except ValueError: + return False # Different drives on Windows + + def _try_load(self, path): + """Try to load a template file if it exists and is safe.""" + if self._is_safe(path) and os.path.isfile(path): + mtime = os.path.getmtime(path) + with open(path, encoding='utf-8') as f: + source = f.read() + return source, path, lambda: mtime == os.path.getmtime(path) + return None + + def get_source(self, environment, template): + # Try resolving relative to template directory, then cwd, then root + for base in (self.template_dir, self.cwd, self.root): + result = self._try_load(os.path.normpath(os.path.join(base, template))) + if result: + return result + raise TemplateNotFound(template) + + # Setup the environment + template_dir = os.path.dirname(template_path) or "." env = Environment( - loader=FileSystemLoader(os.path.dirname(template_path)), + loader=SafeRelativeLoader(template_dir, os.getcwd()), extensions=extensions, keep_trailing_newline=True, ) diff --git a/tests/files/deep/level1/level2/deep_template.j2 b/tests/files/deep/level1/level2/deep_template.j2 new file mode 100644 index 0000000..c07f0f3 --- /dev/null +++ b/tests/files/deep/level1/level2/deep_template.j2 @@ -0,0 +1,3 @@ +{% from "../../../macros_lib/formatting.j2" import code, emphasize -%} +{{ emphasize("Deep nested template") }} +{{ code(language) }} diff --git a/tests/files/macros_lib/formatting.j2 b/tests/files/macros_lib/formatting.j2 new file mode 100644 index 0000000..07e3a39 --- /dev/null +++ b/tests/files/macros_lib/formatting.j2 @@ -0,0 +1,17 @@ +{% macro greeting(name) -%} +Hello, {{ name }}! +{%- endmacro %} + +{% macro bullet_list(items) -%} +{%- for item in items %} +- {{ item }} +{%- endfor %} +{%- endmacro %} + +{% macro emphasize(text) -%} +*{{ text }}* +{%- endmacro %} + +{% macro code(value) -%} +`{{ value }}` +{%- endmacro %} diff --git a/tests/files/nested/child_template.j2 b/tests/files/nested/child_template.j2 new file mode 100644 index 0000000..e4ff0e4 --- /dev/null +++ b/tests/files/nested/child_template.j2 @@ -0,0 +1,3 @@ +{% from "../macros_lib/formatting.j2" import greeting, emphasize -%} +{{ greeting(name) }} +{{ emphasize(message) }} diff --git a/tests/files/template_with_macros.j2 b/tests/files/template_with_macros.j2 new file mode 100644 index 0000000..69a8704 --- /dev/null +++ b/tests/files/template_with_macros.j2 @@ -0,0 +1,11 @@ +{% macro bold(text) -%} +**{{ text }}** +{%- endmacro -%} +{% from "macros_lib/formatting.j2" import greeting, bullet_list, emphasize, code -%} +{{ bold(title) }} + +{{ greeting(name) }} +{{ bullet_list(items) }} + +{{ emphasize(subtitle) }} +{{ code(language) }} diff --git a/tests/test_jinja2cli.py b/tests/test_jinja2cli.py index 181a8bd..d499d24 100644 --- a/tests/test_jinja2cli.py +++ b/tests/test_jinja2cli.py @@ -1,4 +1,6 @@ +import json import os +from optparse import Values from jinja2cli import cli @@ -6,20 +8,143 @@ os.chdir(os.path.dirname(os.path.realpath(__file__))) +def expected_output(title, name, items, subtitle, language): + """Helper to generate expected output for macro tests""" + lines = [ + f"**{title}**", + "", + f"Hello, {name}!", + "", + ] + lines.extend(f"- {item}" for item in items) + lines.extend(["", f"*{subtitle}*", f"`{language}`"]) + return "\n".join(lines) + + +# ============================================================================ +# Basic Path Resolution Tests +# ============================================================================ + + def test_relative_path(): + """Verify templates can be loaded using relative paths""" path = "./files/template.j2" title = b"\xc3\xb8".decode("utf8") output = cli.render(path, {"title": title}, []) + assert output == title assert type(output) == cli.text_type def test_absolute_path(): + """Verify templates can be loaded using absolute paths""" absolute_base_path = os.path.dirname(os.path.realpath(__file__)) path = os.path.join(absolute_base_path, "files", "template.j2") title = b"\xc3\xb8".decode("utf8") output = cli.render(path, {"title": title}, []) + assert output == title assert type(output) == cli.text_type + + +# ============================================================================ +# Macro Import Tests (Same Directory) +# ============================================================================ + + +def test_inline_and_imported_macros(): + """Verify templates can use inline macros and import from subdirectories""" + path = "./files/template_with_macros.j2" + + data = { + "title": "Test Title", + "name": "World", + "items": ["First", "Second", "Third"], + "subtitle": "A guide", + "language": "python", + } + + output = cli.render(path, data, []) + expected = expected_output( + data["title"], data["name"], data["items"], data["subtitle"], data["language"] + ) + + assert output.strip() == expected + assert type(output) == cli.text_type + + +# ============================================================================ +# Relative Import Tests (Parent Directories) +# ============================================================================ + + +def test_parent_directory_import(): + """Verify templates can import from parent directory using ../""" + path = "./files/nested/child_template.j2" + + data = {"name": "World", "message": "Testing parent imports"} + output = cli.render(path, data, []) + + # Should import macros from ../macros_lib/formatting.j2 + expected_lines = ["Hello, World!", "*Testing parent imports*"] + assert output.strip() == "\n".join(expected_lines) + + +def test_deep_nested_import(): + """Verify templates can navigate multiple levels using ../../../""" + path = "./files/deep/level1/level2/deep_template.j2" + + data = {"language": "python"} + output = cli.render(path, data, []) + + # Should import macros from ../../../macros_lib/formatting.j2 + expected_lines = ["*Deep nested template*", "`python`"] + assert output.strip() == "\n".join(expected_lines) + + +# ============================================================================ +# File Output Tests +# ============================================================================ + + +def test_render_to_file(tmp_path): + """Verify rendering to a file with JSON data input works correctly""" + template_path = "./files/template_with_macros.j2" + + data = { + "title": "File Output Test", + "name": "Jinja2", + "items": ["One", "Two", "Three"], + "subtitle": "Template", + "language": "cli", + } + + data_file = tmp_path / "data.json" + data_file.write_text(json.dumps(data)) + + outfile = tmp_path / "output.txt" + + opts = Values( + { + "format": "json", + "extensions": set(["do", "loopcontrols"]), + "D": None, + "section": None, + "strict": False, + "outfile": str(outfile), + } + ) + + args = [os.path.abspath(template_path), str(data_file)] + result = cli.cli(opts, args) + + assert result == 0 + + output = outfile.read_text().strip() + expected = expected_output( + data["title"], data["name"], data["items"], data["subtitle"], data["language"] + ) + assert output == expected +