Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions jinja2cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand Down
3 changes: 3 additions & 0 deletions tests/files/deep/level1/level2/deep_template.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% from "../../../macros_lib/formatting.j2" import code, emphasize -%}
{{ emphasize("Deep nested template") }}
{{ code(language) }}
17 changes: 17 additions & 0 deletions tests/files/macros_lib/formatting.j2
Original file line number Diff line number Diff line change
@@ -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 %}
3 changes: 3 additions & 0 deletions tests/files/nested/child_template.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% from "../macros_lib/formatting.j2" import greeting, emphasize -%}
{{ greeting(name) }}
{{ emphasize(message) }}
11 changes: 11 additions & 0 deletions tests/files/template_with_macros.j2
Original file line number Diff line number Diff line change
@@ -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) }}
125 changes: 125 additions & 0 deletions tests/test_jinja2cli.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,150 @@
import json
import os
from optparse import Values

from jinja2cli import cli

# change dir to tests directory to make relative paths possible
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