mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""Pure, finite text interpretation for Builder's existing native Template node.
|
|
|
|
Jinja supplies only its lexer and parser. No compilation, rendering, globals,
|
|
extensions or effectful collaborator is used. Admission inspects every branch;
|
|
the native adapter repeats inspection and bounds all selected values and output.
|
|
"""
|
|
|
|
import math
|
|
import re
|
|
from collections.abc import Iterator, Mapping
|
|
from typing import Any, NoReturn, override
|
|
|
|
from jinja2 import Environment, nodes
|
|
|
|
from graphon.template_rendering import Jinja2TemplateRenderer, TemplateRenderError
|
|
from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError
|
|
|
|
MAX_SOURCE_BYTES = 16384
|
|
MAX_TOKENS = 512
|
|
MAX_AST_NODES = 256
|
|
MAX_AST_DEPTH = 16
|
|
MAX_VARIABLES = 32
|
|
MAX_TEXT_BYTES = 65536
|
|
MAX_SELECTED_BYTES = 131072
|
|
MAX_OUTPUT_BYTES = 65536
|
|
MAX_VISITS = 512
|
|
MAX_INTEGER = 2**53 - 1
|
|
|
|
Scalar = str | bool | int | float | None
|
|
_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,63}\Z", re.ASCII)
|
|
_FIELDS = {
|
|
nodes.Template: ("body",),
|
|
nodes.Output: ("nodes",),
|
|
nodes.TemplateData: ("data",),
|
|
nodes.If: ("test", "body", "elif_", "else_"),
|
|
nodes.Name: ("name", "ctx"),
|
|
nodes.Const: ("value",),
|
|
nodes.Filter: ("node", "name", "args", "kwargs", "dyn_args", "dyn_kwargs"),
|
|
nodes.And: ("left", "right"),
|
|
nodes.Or: ("left", "right"),
|
|
nodes.Not: ("node",),
|
|
}
|
|
|
|
|
|
def _refuse(reason: str = "unsupported_template_syntax") -> NoReturn:
|
|
raise BuilderExecutionPolicyError(reason)
|
|
|
|
|
|
class _ParserEnvironment(Environment):
|
|
"""Keep real lex/parse, bypass Jinja's synthetic Python traceback frames."""
|
|
|
|
@override
|
|
def handle_exception(self, source: str | None = None) -> NoReturn:
|
|
# Public lex/parse route syntax errors here. Never rewrite their
|
|
# traceback (Jinja debug does Python compile/exec), or expose source.
|
|
_ = source
|
|
raise BuilderExecutionPolicyError("unsupported_template_syntax") from None
|
|
|
|
|
|
def _text_bytes(value: object, limit: int) -> int:
|
|
"""Bound character count before allocating any UTF-8 representation."""
|
|
if type(value) is not str:
|
|
_refuse("unsupported_template_configuration")
|
|
if len(value) > limit:
|
|
_refuse("template_execution_limit")
|
|
try:
|
|
size = len(value.encode("utf-8"))
|
|
except UnicodeError:
|
|
_refuse("unsupported_template_configuration")
|
|
if size > limit:
|
|
_refuse("template_execution_limit")
|
|
return size
|
|
|
|
|
|
def validate_template_source(template: object) -> None:
|
|
"""Cheap preflight, including before an existing caller's graph digest."""
|
|
_text_bytes(template, MAX_SOURCE_BYTES)
|
|
|
|
|
|
def _names(values: object) -> frozenset[str]:
|
|
if not isinstance(values, (list, tuple)) or len(values) > MAX_VARIABLES:
|
|
_refuse("unsupported_template_configuration")
|
|
names: set[str] = set()
|
|
for name in values:
|
|
if type(name) is not str or not _NAME.fullmatch(name) or name in names:
|
|
_refuse("unsupported_template_configuration")
|
|
names.add(name)
|
|
return frozenset(names)
|
|
|
|
|
|
def _scalar(value: object) -> Scalar:
|
|
"""Exact built-ins only: no custom conversion/truthiness can execute."""
|
|
if value is None or type(value) is bool:
|
|
return value
|
|
if type(value) is str:
|
|
_text_bytes(value, MAX_TEXT_BYTES)
|
|
return value
|
|
if type(value) is int:
|
|
if not -MAX_INTEGER <= value <= MAX_INTEGER:
|
|
_refuse("template_execution_limit")
|
|
return value
|
|
if type(value) is float and math.isfinite(value):
|
|
return value
|
|
_refuse("unsupported_template_configuration")
|
|
|
|
|
|
def _scalar_text(value: Scalar) -> str:
|
|
# Values are checked before this conversion, including scalar constants.
|
|
return value if type(value) is str else str(value)
|
|
|
|
|
|
def _children(node: nodes.Node, declared: frozenset[str]) -> Iterator[nodes.Node]:
|
|
"""Validate every field, yielding only the reviewed structural children."""
|
|
kind = type(node)
|
|
if kind not in _FIELDS or node.fields != _FIELDS[kind]:
|
|
_refuse()
|
|
if type(node) is nodes.Name:
|
|
if node.ctx != "load" or node.name not in declared:
|
|
_refuse()
|
|
return
|
|
if type(node) is nodes.Const:
|
|
try:
|
|
_scalar(node.value)
|
|
except BuilderExecutionPolicyError:
|
|
_refuse()
|
|
return
|
|
if type(node) is nodes.TemplateData:
|
|
_text_bytes(node.data, MAX_SOURCE_BYTES)
|
|
return
|
|
if type(node) is nodes.Filter:
|
|
if node.name != "trim" or node.args or node.kwargs or node.dyn_args is not None or node.dyn_kwargs is not None:
|
|
_refuse()
|
|
if node.node is None:
|
|
_refuse()
|
|
yield node.node
|
|
return
|
|
fields = dict(nodes.Node.iter_fields(node))
|
|
for field in _FIELDS[kind]:
|
|
if field not in fields:
|
|
_refuse()
|
|
value = fields[field]
|
|
if isinstance(value, nodes.Node):
|
|
yield value
|
|
elif type(value) is list:
|
|
for child in value:
|
|
if not isinstance(child, nodes.Node):
|
|
_refuse()
|
|
yield child
|
|
else:
|
|
_refuse()
|
|
|
|
|
|
def inspect_template(template: str, declared: frozenset[str]) -> nodes.Template:
|
|
"""Refuse before parsing on source/token bounds, then inspect iteratively."""
|
|
validate_template_source(template)
|
|
try:
|
|
environment = _ParserEnvironment()
|
|
for count, _token in enumerate(environment.lex(template), 1):
|
|
if count > MAX_TOKENS:
|
|
_refuse("template_execution_limit")
|
|
parsed = environment.parse(template)
|
|
pending: list[tuple[nodes.Node, int]] = [(parsed, 0)]
|
|
visited = 0
|
|
while pending:
|
|
node, depth = pending.pop()
|
|
visited += 1
|
|
if visited > MAX_AST_NODES or depth > MAX_AST_DEPTH:
|
|
_refuse("template_execution_limit")
|
|
for child in _children(node, declared):
|
|
# Bound the work stack as well as total visited nodes.
|
|
if visited + len(pending) >= MAX_AST_NODES:
|
|
_refuse("template_execution_limit")
|
|
pending.append((child, depth + 1))
|
|
return parsed
|
|
except BuilderExecutionPolicyError:
|
|
raise
|
|
except Exception:
|
|
# Parser/lexer/AST failures, including recursion/overflow, never echo source.
|
|
_refuse()
|
|
|
|
|
|
def validate_template_configuration(data: Mapping[str, Any]) -> None:
|
|
"""Raw names/selectors must be safe before permissive native DTO parsing."""
|
|
template, variables = data.get("template"), data.get("variables")
|
|
validate_template_source(template)
|
|
# A render failure must remain a native failure, not a default-value branch.
|
|
if data.get("error_strategy") not in (None, "") or data.get("default_value") not in (None, []):
|
|
_refuse("unsupported_template_configuration")
|
|
if data.get("default_values") not in (None, []):
|
|
_refuse("unsupported_template_configuration")
|
|
retry = data.get("retry_config")
|
|
if retry is not None and (
|
|
type(retry) is not dict
|
|
or set(retry) - {"retry_enabled", "max_retries", "retry_interval"}
|
|
or retry.get("retry_enabled", False) is not False
|
|
or any(
|
|
type(retry.get(key, 0)) is not int or not 0 <= retry.get(key, 0) <= MAX_INTEGER
|
|
for key in ("max_retries", "retry_interval")
|
|
)
|
|
):
|
|
_refuse("unsupported_template_configuration")
|
|
if type(variables) is not list or len(variables) > MAX_VARIABLES:
|
|
_refuse("unsupported_template_configuration")
|
|
names = []
|
|
for variable in variables:
|
|
if type(variable) is not dict or set(variable) != {"variable", "value_selector"}:
|
|
_refuse("unsupported_template_configuration")
|
|
selector = variable["value_selector"]
|
|
if (
|
|
not isinstance(selector, (list, tuple))
|
|
or len(selector) != 2
|
|
or any(type(part) is not str or not part for part in selector)
|
|
):
|
|
_refuse("unsupported_template_selector")
|
|
names.append(variable["variable"])
|
|
assert isinstance(template, str)
|
|
inspect_template(template, _names(names))
|
|
|
|
|
|
class _Evaluation:
|
|
"""One bounded invocation, with cumulative append and visit accounting."""
|
|
|
|
def __init__(self, values: dict[str, Scalar]) -> None:
|
|
self.values = values
|
|
self.visits = 0
|
|
self.output_bytes = 0
|
|
self.fragments: list[str] = []
|
|
|
|
def _visit(self) -> None:
|
|
if self.visits >= MAX_VISITS:
|
|
_refuse("template_execution_limit")
|
|
self.visits += 1
|
|
|
|
def expression(self, node: nodes.Node) -> Scalar:
|
|
self._visit()
|
|
if type(node) is nodes.Name:
|
|
return self.values[node.name]
|
|
if type(node) is nodes.Const:
|
|
return node.value
|
|
if type(node) is nodes.TemplateData:
|
|
return node.data
|
|
if type(node) is nodes.Filter:
|
|
assert node.node is not None
|
|
return _scalar_text(self.expression(node.node)).strip()
|
|
if type(node) is nodes.Not:
|
|
return not self.expression(node.node)
|
|
if type(node) is nodes.And:
|
|
left = self.expression(node.left)
|
|
return self.expression(node.right) if left else left
|
|
if type(node) is nodes.Or:
|
|
left = self.expression(node.left)
|
|
return left or self.expression(node.right)
|
|
_refuse()
|
|
|
|
def statement(self, node: nodes.Node) -> None:
|
|
self._visit()
|
|
if type(node) is nodes.Template:
|
|
for child in node.body:
|
|
self.statement(child)
|
|
elif type(node) is nodes.Output:
|
|
for expression in node.nodes:
|
|
text = _scalar_text(self.expression(expression))
|
|
size = _text_bytes(text, MAX_OUTPUT_BYTES - self.output_bytes)
|
|
self.output_bytes += size
|
|
self.fragments.append(text)
|
|
elif type(node) is nodes.If:
|
|
branch = node.else_
|
|
if self.expression(node.test):
|
|
branch = node.body
|
|
else:
|
|
for alternative in node.elif_:
|
|
self._visit()
|
|
if self.expression(alternative.test):
|
|
branch = alternative.body
|
|
break
|
|
for child in branch:
|
|
self.statement(child)
|
|
else:
|
|
_refuse()
|
|
|
|
|
|
class BoundedJinja2TemplateRenderer(Jinja2TemplateRenderer):
|
|
"""Native nominal adapter; never delegates to ordinary Jinja rendering."""
|
|
|
|
@override
|
|
def render_template(self, template: str, variables: Mapping[str, Any]) -> str:
|
|
try:
|
|
if type(variables) is not dict or len(variables) > MAX_VARIABLES:
|
|
_refuse("unsupported_template_configuration")
|
|
declared = _names(tuple(variables))
|
|
values: dict[str, Scalar] = {}
|
|
selected_bytes = 0
|
|
for name, raw in variables.items():
|
|
value = _scalar(raw)
|
|
text = _scalar_text(value)
|
|
selected_bytes += _text_bytes(text, MAX_SELECTED_BYTES - selected_bytes)
|
|
values[name] = value
|
|
parsed = inspect_template(template, declared)
|
|
evaluation = _Evaluation(values)
|
|
evaluation.statement(parsed)
|
|
return "".join(evaluation.fragments)
|
|
except BuilderExecutionPolicyError as error:
|
|
if error.reason_code == "template_execution_limit":
|
|
raise TemplateRenderError("Template rendering exceeded safe testing size limits.") from None
|
|
raise TemplateRenderError("Template rendering refused unsupported syntax or scalar inputs.") from None
|
|
except Exception:
|
|
raise TemplateRenderError("Template rendering failed safely; review the template and inputs.") from None
|