From 2e7e9bdabe2d26c3acbcf94bdd74f296e77048a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 10:50:14 +0000 Subject: [PATCH 01/11] Public RenderOptions (globals, poll) and Template::FindReferencedTemplates (0357 S2b) RenderTo(out|sink, params, RenderOptions) renders with the caller's own globals, shared so that a value a callable keeps outlives the render, and a poll that may throw to stop it. A poll alone makes the render count its steps; a render a polled one starts is polled too. FindReferencedTemplates is public; in a wide template it gave nullopt for every name and now converts them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWNL7n7c5XUg2Xoo4ZRjLn --- CMakeLists.txt | 6 +- docs/api-2.0.md | 4 +- docs/tasks/0357-python-module-public-api.md | 9 ++ include/jinja2cpp/template.h | 55 +++++++ python/src/poll.h | 18 ++- src/recursion_guard.cpp | 7 +- src/recursion_guard.h | 42 ++++- src/template.cpp | 19 +++ src/template_impl.h | 93 +++++++++--- test/referenced_templates_test.cpp | 19 ++- test/template_env_test.cpp | 160 ++++++++++++++++++++ 11 files changed, 386 insertions(+), 46 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index eb4916737..585e13f60 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -548,6 +548,7 @@ if (JINJA2CPP_BUILD_TESTS) test/arena_switch_test.cpp test/ordered_map_test.cpp test/recursion_limits_test.cpp + test/referenced_templates_test.cpp test/slot_frame_test.cpp test/statements_tets.cpp test/support_types_test.cpp @@ -565,11 +566,6 @@ if (JINJA2CPP_BUILD_TESTS) test/value_ptr_test.cpp test/parity/parity_test.cpp ) - # The referenced-templates walk is a header-only internal of the Python module, which links - # the library statically: it calls the parser, which a shared library does not export - if (NOT JINJA2CPP_BUILD_SHARED) - list(APPEND JINJA2CPP_TEST_SOURCES test/referenced_templates_test.cpp) - endif () if (JINJA2CPP_WITH_CHAT_TEMPLATES) list(APPEND JINJA2CPP_TEST_SOURCES test/chat_templates/chat_template_test.cpp test/chat_template_preset_test.cpp) endif () diff --git a/docs/api-2.0.md b/docs/api-2.0.md index ad3bd4d4f..dae8059ac 100644 --- a/docs/api-2.0.md +++ b/docs/api-2.0.md @@ -837,7 +837,9 @@ Recommend A now, C only if someone asks. `UserCallable::keepUndefined`. `None` stays the default. - **Rendering.** `RenderOptions`, with per-render globals and a `std::function` poll; call-site information for `*context` callables; hex digits for `BigInt`; - `Template::FindReferencedTemplates`. + `Template::FindReferencedTemplates`. The globals are a `shared_ptr`, not a + call-scoped borrow: a value of them that a callable keeps stays valid after the render, as + an environment global's does (S2b). - **Sandbox.** An optional public `ISandboxPolicy`, at no cost when it is unset. Both the native attribute path and the direct method-call path honour it. - **Growth after the freeze.** Through `IHostObject2 : IHostObject`, found once per object. diff --git a/docs/tasks/0357-python-module-public-api.md b/docs/tasks/0357-python-module-public-api.md index 340862cf2..962942a74 100644 --- a/docs/tasks/0357-python-module-public-api.md +++ b/docs/tasks/0357-python-module-public-api.md @@ -62,3 +62,12 @@ freeze. 710,482; mitsuhiko_table 8,211,299 / 9,131,276 / 8,890,719. D7 is relaxed for host-data cases (api-2.0.md section 7 item 10); `detail::MakeHostValueInPlace` and `HostResult::SetHostObjectInPlace` are frozen ABI. +- S2b (B3, B6), stacked on #800: `RenderOptions` (`globals` as `shared_ptr`, so + a value a callable keeps outlives the render as an environment global does through + `LendGlobal`; `poll` as `std::function`) with `Template::RenderTo(out|sink, params, + options)` over the `RenderCore` instantiation; a poll alone makes the render count its + steps, and a render a polled one starts (a callable rendering another template) is polled too. + `Template::FindReferencedTemplates()` is public, and `test/referenced_templates_test.cpp` now + builds in shared mode. Found on the way: in a wide template every referenced name came back as + `nullopt` (the walk read narrow strings only); fixed, with a row. The module's poll moved to + the `std::function` form of `detail::t_hostPoll`; its render entry is unchanged until S3. diff --git a/include/jinja2cpp/template.h b/include/jinja2cpp/template.h index 6cd9da905..1b9d0b958 100644 --- a/include/jinja2cpp/template.h +++ b/include/jinja2cpp/template.h @@ -17,8 +17,10 @@ #error "Jinja2C++ needs expected-lite's nonstd::expected: define nsel_CONFIG_SELECT_EXPECTED=nsel_EXPECTED_NONSTD" #endif +#include #include #include +#include #include #include #include @@ -45,6 +47,35 @@ using ResultW = Result; template using BadResultAccess = nonstd::bad_expected_access>; +/*! + * \brief What a render takes besides its params (\ref BasicTemplate::RenderTo) + * + * ```cpp + * jinja2::RenderOptions options; + * options.globals = std::make_shared(jinja2::ValuesMap{{"site", "example.org"}}); + * options.poll = [&] { if (cancelled) throw Cancelled(); }; + * auto result = tpl.RenderTo(out, params, options); + * ``` + */ +struct RenderOptions +{ + /*! + * The render's globals instead of the environment's (\ref TemplateEnv::AddGlobal): the + * template and every template it includes, imports or extends see these, under its params. + * Null, the default, keeps the environment's. Shared, as a value of the map that a callable + * keeps stays valid after the render. + */ + std::shared_ptr globals; + /*! + * Called on the rendering thread about every 1024 render steps, and more often while a render + * time limit (Settings::Limits::maxRenderTime) runs: a way to cancel a render, or to check a + * host's signals. It may throw to stop the render; the render then fails with the exception in + * \ref BasicErrorInfo::GetException. A render that the poll's render starts with no poll of + * its own (a callable rendering another template) is polled with it too. + */ + std::function poll; +}; + /*! * \brief Raw contents of the {% meta %} tag * @@ -365,6 +396,19 @@ class BasicTemplate { return RenderToGeneric(sink, params); } + /*! + * \brief Render previously loaded template, appending the output to a string, with \ref RenderOptions + * + * As `RenderTo(out, params)`, with the render's own globals and poll. + */ + [[nodiscard]] Result RenderTo(StringType& out, const ValuesMap& params, const RenderOptions& options) const; + Result RenderTo(StringType&& out, const ValuesMap& params, const RenderOptions& options) const = delete; + /*! + * \brief Render previously loaded template, streaming the output to a sink, with \ref RenderOptions + * + * As `RenderTo(sink, params)`, with the render's own globals and poll. + */ + [[nodiscard]] Result RenderTo(BasicOutputSink& sink, const ValuesMap& params, const RenderOptions& options) const; /*! * \brief Get metadata, provided in the {% meta %} tag * @@ -388,6 +432,17 @@ class BasicTemplate * @return The names, or the error of the parse (TemplateNotParsed before a successful Load) */ [[nodiscard]] Result, CharT> FindUndeclaredVariables() const; + /*! + * \brief The names of the templates the template refers to, as `jinja2.meta.find_referenced_templates` + * + * The names the `extends`, `include`, `import` and `from` tags give, in source order. A tag + * whose name is a string literal gives it, a list or tuple literal of strings gives each of them, + * and any other expression gives `std::nullopt`, as Jinja2 gives `None`. The templates are not + * loaded or looked into. Parses the template's source again. + * + * @return The names, or the error of the parse (TemplateNotParsed before a successful Load) + */ + [[nodiscard]] Result>, CharT> FindReferencedTemplates() const; /*! * \brief Renders the template once and returns what it exports, as Jinja2's `make_module(vars)` * diff --git a/python/src/poll.h b/python/src/poll.h index 6e53e2378..5b19260a7 100644 --- a/python/src/poll.h +++ b/python/src/poll.h @@ -5,7 +5,7 @@ #include "recursion_guard.h" -#include +#include namespace jinja2cpp_py { @@ -19,21 +19,23 @@ inline void PollSignals() } } +// Installs PollSignals as the thread's poll for the scope class HostPollScope { public: HostPollScope() noexcept - : m_saved(std::exchange(jinja2::detail::t_hostPoll, &PollSignals)) + : m_scope(&Poll()) { } - ~HostPollScope() { jinja2::detail::t_hostPoll = m_saved; } - HostPollScope(const HostPollScope&) = delete; - HostPollScope& operator=(const HostPollScope&) = delete; - HostPollScope(HostPollScope&&) = delete; - HostPollScope& operator=(HostPollScope&&) = delete; private: - jinja2::detail::HostPoll m_saved; + static const std::function& Poll() + { + static const std::function poll(&PollSignals); + return poll; + } + + jinja2::HostPollScope m_scope; }; } // namespace jinja2cpp_py diff --git a/src/recursion_guard.cpp b/src/recursion_guard.cpp index 18259e0c1..c159707eb 100644 --- a/src/recursion_guard.cpp +++ b/src/recursion_guard.cpp @@ -199,7 +199,7 @@ void StepsSlow() // Last, with the count refilled: the poll may throw, and the render stops if (const auto poll = t_hostPoll) { - poll(); + (*poll)(); } } } // namespace @@ -236,6 +236,11 @@ void RenderLimitsScope::Install(const Settings::Limits* limitsOrNone, const BigI m_saved = detail::t_renderLimits; detail::RenderLimits state; state.bigInts = bigInts; + // A render a polled one starts (a host callable rendering another template) is polled too + if (!limitsOrNone && detail::t_hostPoll) + { + limitsOrNone = &PollOnlyLimits(); + } if (!limitsOrNone) { detail::InstallRenderLimits(state); diff --git a/src/recursion_guard.h b/src/recursion_guard.h index 1e30a710d..769441644 100644 --- a/src/recursion_guard.h +++ b/src/recursion_guard.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -140,15 +141,46 @@ inline thread_local RenderLimits t_renderLimits JINJA2CPP_HOT_TLS; void PollDeadline(); void InstallRenderLimits(const RenderLimits& limits); -// A host's poll of the renders on this thread (docs/tasks/0353): the Python module checks for -// signals there, so Ctrl-C stops a long render. A render with limits calls it every -// HostPollSteps steps; it may throw to stop the render. Null, the default, costs nothing: the -// slow path of the step count reads it, not the step -using HostPoll = void (*)(); +// A host's poll of the renders on this thread (RenderOptions::poll, docs/tasks/0357 B3; the +// Python module checks for signals there, so Ctrl-C stops a long render, 0353). A render with +// limits calls it every HostPollSteps steps; it may throw to stop the render. Null, the +// default, costs nothing: the slow path of the step count reads it, not the step +using HostPoll = const std::function*; inline thread_local HostPoll t_hostPoll JINJA2CPP_HOT_TLS = nullptr; inline constexpr std::uint64_t HostPollSteps = 1024; } // namespace detail +// Installs a host's poll for as long as it lives and puts back the one it replaces. A render +// it interrupts, or one it starts with no poll of its own, polls with the same +class HostPollScope +{ +public: + explicit HostPollScope(detail::HostPoll poll) noexcept + : m_saved(std::exchange(detail::t_hostPoll, poll)) + { + } + ~HostPollScope() { detail::t_hostPoll = m_saved; } + HostPollScope(const HostPollScope&) = delete; + HostPollScope& operator=(const HostPollScope&) = delete; + HostPollScope(HostPollScope&&) = delete; + HostPollScope& operator=(HostPollScope&&) = delete; + +private: + detail::HostPoll m_saved; +}; + +// The limits of a render that has a poll but no limits of its own: it counts its steps, to +// reach the poll, and limits nothing (UINT64_MAX steps are no limit) +inline const Settings::Limits& PollOnlyLimits() +{ + static const Settings::Limits limits = [] { + Settings::Limits result; + result.maxRenderSteps = UINT64_MAX; + return result; + }(); + return limits; +} + // Installs a render's limits for as long as it lives and puts back the ones of the render it // interrupts (a host callable may render another template). A render without limits (null) // on a thread that runs none costs a test of a thread-local flag diff --git a/src/template.cpp b/src/template.cpp index 628e35ada..c41a41947 100644 --- a/src/template.cpp +++ b/src/template.cpp @@ -267,6 +267,19 @@ Result BasicTemplate::RenderToGeneric(BasicOutputSink return ToResult(GetImpl(m_impl)->Render(sink, params)); } +template +Result BasicTemplate::RenderTo(StringType& out, const ValuesMap& params, const RenderOptions& options) const +{ + TruncateOnError guard(out); + return ToResult(guard.Done(GetImpl(m_impl)->RenderWithOptions(out, params, options))); +} + +template +Result BasicTemplate::RenderTo(BasicOutputSink& sink, const ValuesMap& params, const RenderOptions& options) const +{ + return ToResult(GetImpl(m_impl)->RenderWithOptions(sink, params, options)); +} + template Result BasicTemplate::GetMetadata() const { @@ -285,6 +298,12 @@ Result, CharT> BasicTemplate::FindUndeclaredVari return GetImpl(m_impl)->FindUndeclaredVariables(); } +template +Result>, CharT> BasicTemplate::FindReferencedTemplates() const +{ + return GetImpl(m_impl)->FindReferencedTemplates(); +} + template Result, CharT> BasicTemplate::MakeModule(const ValuesMap& params) const { diff --git a/src/template_impl.h b/src/template_impl.h index 64e50b8fc..9594de46c 100644 --- a/src/template_impl.h +++ b/src/template_impl.h @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -388,7 +389,7 @@ class TemplateImpl : public ITemplateImpl // (template_module.cpp) static nonstd::expected, BasicErrorInfo> MakeModule(const std::shared_ptr& self, const ValuesMap& params); - // FindReferencedTemplates of a loaded template, for the Python module (docs/tasks/0354) + // Template::FindReferencedTemplates; the Python module's meta.find_referenced_templates (docs/tasks/0354) static nonstd::expected>, BasicErrorInfo> ReferencedTemplatesOf(const BasicTemplate& tpl) { return static_cast(tpl.m_impl.get())->FindReferencedTemplates(); @@ -403,26 +404,58 @@ class TemplateImpl : public ITemplateImpl const InternalValueMap& params, const InternalValueMap* globals = nullptr) { - const auto* impl = static_cast(tpl.m_impl.get()); - // A separate instantiation, so that the ordinary Render keeps its shape and cost - if (globals) - { - return impl->template RenderCore(os, params, globals); - } - return impl->template RenderCore(os, params, nullptr); + return RenderInternalTo(tpl, os, params, globals); } // The same, writing to a host's sink (the module's Template.generate) static std::optional> RenderInternal(const BasicTemplate& tpl, BasicOutputSink& sink, const InternalValueMap& params, const InternalValueMap* globals = nullptr) + { + return RenderInternalTo(tpl, sink, params, globals); + } + template + static std::optional> RenderInternalTo(const BasicTemplate& tpl, Target& os, const InternalValueMap& params, const InternalValueMap* globals) { const auto* impl = static_cast(tpl.m_impl.get()); + // A separate instantiation, so that the ordinary Render keeps its shape and cost if (globals) { - return impl->template RenderCore(sink, params, globals); + const RenderHooks hooks{ globals, nullptr }; + return impl->template RenderCore(os, params, &hooks); + } + return impl->template RenderCore(os, params, nullptr); + } + + // What a render takes besides its params (Template::RenderTo with RenderOptions, docs/tasks/0357 B3) + struct RenderHooks + { + // Replace the environment's globals when set + const InternalValueMap* globals = nullptr; + // The host's poll (detail::t_hostPoll) for the render, or null to keep the thread's + const std::function* poll = nullptr; + }; + + // The same with the public options: their globals, lent for the render and kept by any value a + // host keeps (LendGlobal), and their poll + template + std::optional> RenderWithOptions(Target& os, const ValuesMap& params, const RenderOptions& options) const + { + RenderHooks hooks; + InternalValueMap globals; + if (options.globals) + { + for (const auto& [name, value] : *options.globals) + { + globals[name] = LendGlobal(value, options.globals); + } + hooks.globals = &globals; } - return impl->template RenderCore(sink, params, nullptr); + if (options.poll) + { + hooks.poll = &options.poll; + } + return RenderCore(os, params, &hooks); } // Renders with the params of a ValuesMap or a GenericMap, appending to a string or writing to a @@ -433,9 +466,10 @@ class TemplateImpl : public ITemplateImpl return RenderCore(os, params, nullptr); } - // `hostGlobals` (with hasHostGlobals set) replaces the environment's globals (RenderInternal) - template - std::optional> RenderCore(Target& os, const ParamsMap& params, const InternalValueMap* hostGlobals) const + // `hooks` (with hasHooks set) brings the render's own globals and poll (RenderHooks). A + // separate instantiation, so that the ordinary Render pays nothing for them + template + std::optional> RenderCore(Target& os, const ParamsMap& params, const RenderHooks* hooks) const { static_assert(std::is_same_v> || std::is_same_v>); std::optional> normalResult; @@ -459,8 +493,25 @@ class TemplateImpl : public ITemplateImpl // First, so that it ends last: when nothing of the render holds values any more // It also keeps the render's copies of the host's data (docs/tasks/0049) const CycleCollector cycles; + const InternalValueMap* hostGlobals = nullptr; + const Settings::Limits* limits = m_settings->hasRenderLimits ? &m_settings->settings.limits : nullptr; + // Before the limits, which start counting the steps to the first poll + std::optional pollScope; + if constexpr (hasHooks) + { + hostGlobals = hooks->globals; + if (hooks->poll) + { + pollScope.emplace(hooks->poll); + // A poll alone makes the render count its steps + if (!limits) + { + limits = &PollOnlyLimits(); + } + } + } // Before anything else the render does, the conversion of the params included - const RenderLimitsScope limitsScope(m_settings->hasRenderLimits ? &m_settings->settings.limits : nullptr, m_settings->bigIntBackend); + const RenderLimitsScope limitsScope(limits, m_settings->bigIntBackend); // The tables of the params and the top scope are lent by the thread's workspace and // go back to it, emptied, when the render ends (docs/tasks/0160) const WorkspaceMaps maps; @@ -471,7 +522,7 @@ class TemplateImpl : public ITemplateImpl }; static const InternalValueMap noGlobals; - const auto globals = m_env && !hasHostGlobals ? GetGlobalsSnapshot(*detail::TemplateEnvAccess::GetImpl(*m_env)) : nullptr; + const auto globals = m_env && !hostGlobals ? GetGlobalsSnapshot(*detail::TemplateEnvAccess::GetImpl(*m_env)) : nullptr; // A GenericMap returns values by copy; the context refers to them, so they live here std::list genericValues; @@ -498,8 +549,8 @@ class TemplateImpl : public ITemplateImpl } RendererCallback callback(this); callback.SetGlobals(globals.get()); - const InternalValueMap& renderGlobals = hasHostGlobals ? *hostGlobals : globals ? globals->values - : noGlobals; + const InternalValueMap& renderGlobals = hostGlobals ? *hostGlobals : globals ? globals->values + : noGlobals; RenderContext context(intParams, renderGlobals, &callback, @@ -737,8 +788,8 @@ class TemplateImpl : public ITemplateImpl // anything that is no constant if (const InternalValue* constant = nodes[ref].GetConstant(nodes)) { - const auto text = AsStringView(*constant); - names.push_back(text ? std::optional(std::string(*text)) : std::nullopt); + // A wide template's names are converted, as its include converts them (LoadTemplate) + names.push_back(GetAsSameString(std::string(), *constant)); continue; } // Jinja2 sees through a tag's expression to a list or tuple literal, but not through a condition @@ -762,9 +813,9 @@ class TemplateImpl : public ITemplateImpl { names.emplace_back(); } - else if (const auto text = AsStringView(*constant)) + else if (auto text = GetAsSameString(std::string(), *constant)) { - names.emplace_back(std::string(*text)); + names.push_back(std::move(text)); } } } diff --git a/test/referenced_templates_test.cpp b/test/referenced_templates_test.cpp index 9c549f565..31c2beac1 100644 --- a/test/referenced_templates_test.cpp +++ b/test/referenced_templates_test.cpp @@ -1,4 +1,3 @@ -#include "../src/template_impl.h" #include "test_tools.h" #include @@ -15,8 +14,8 @@ using namespace jinja2; -// The internal walk behind the Python module's jinja2.meta.find_referenced_templates -// (docs/tasks/0354) against Jinja2 3.1.6's: the names of `extends`, `include`, `import` and +// Template::FindReferencedTemplates (the Python module's jinja2.meta.find_referenced_templates, +// docs/tasks/0354, public since 0357) against Jinja2 3.1.6's: the names of `extends`, `include`, `import` and // `from` in source order, "?" for a name that is no constant namespace { @@ -66,7 +65,7 @@ TEST_P(ReferencedTemplatesTest, MatchesJinja2) TemplateEnv env; Template tpl(&env); ASSERT_TRUE(tpl.Load(GetParam().source).has_value()); - auto found = TemplateImpl::ReferencedTemplatesOf(tpl); + auto found = tpl.FindReferencedTemplates(); ASSERT_TRUE(found.has_value()) << ErrorToString(found.error()); EXPECT_EQ(SplitNames(GetParam().names), *found) << GetParam().source; } @@ -77,7 +76,17 @@ TEST(ReferencedTemplatesNotLoadedTest, ReportsTheError) { TemplateEnv env; Template tpl(&env); - auto found = TemplateImpl::ReferencedTemplatesOf(tpl); + auto found = tpl.FindReferencedTemplates(); ASSERT_FALSE(found.has_value()); EXPECT_EQ(ErrorCode::TemplateNotParsed, found.error().GetCode()); } + +TEST(ReferencedTemplatesWideTest, GivesNarrowNames) +{ + TemplateEnv env; + TemplateW tpl(&env); + ASSERT_TRUE(tpl.Load(L"{% extends 'base' %}{% include ['a', x] %}{% import n as m %}").has_value()); + auto found = tpl.FindReferencedTemplates(); + ASSERT_TRUE(found.has_value()) << ErrorToString(found.error()); + EXPECT_EQ(SplitNames("base a ? ?"), *found); +} diff --git a/test/template_env_test.cpp b/test/template_env_test.cpp index 88cf039f5..b10692263 100644 --- a/test/template_env_test.cpp +++ b/test/template_env_test.cpp @@ -137,3 +137,163 @@ TEST(TemplateEnvTest, SettingsEquality) a.finalize = b.finalize; EXPECT_TRUE(a == b); } + +// Template::RenderTo with RenderOptions (docs/tasks/0357 B3): a render's own globals and poll +namespace +{ +std::shared_ptr GlobalsOf(ValuesMap globals) +{ + return std::make_shared(std::move(globals)); +} + +struct PollStop +{ +}; +} // namespace + +TEST(RenderOptionsTest, GlobalsReplaceTheEnvironmentsForTheRenderAndItsImports) +{ + TemplateEnv env; + auto fs = std::make_shared(); + fs->AddFile("lib.j2tpl", "{% macro who() %}{{ greeting }}{% endmacro %}"); + fs->AddFile("part.j2tpl", "<{{ greeting }}>"); + env.AddFilesystemHandler(std::string(), fs); + env.AddGlobal("greeting", "env"); + env.AddGlobal("only_env", "kept?"); + auto tpl = env.FromString("{% import 'lib.j2tpl' as lib %}{{ greeting }} {{ lib.who() }} {% include 'part.j2tpl' %} {{ only_env is defined }} {{ shadowed }}"); + ASSERT_TRUE(tpl) << tpl.error().ToString(); + + RenderOptions options; + options.globals = GlobalsOf({ { "greeting", "mine" }, { "shadowed", "global" } }); + std::string out = "prefix:"; + auto result = tpl->RenderTo(out, { { "shadowed", "param" } }, options); + ASSERT_TRUE(result) << result.error().ToString(); + // The params stay over the globals, as in an ordinary render + EXPECT_EQ("prefix:mine mine False param", out); + + // The environment's globals are untouched: the next ordinary render sees them + EXPECT_EQ("env env True ", tpl->RenderAsString({}).value()); + // No globals in the options: the environment's + out.clear(); + ASSERT_TRUE(tpl->RenderTo(out, {}, RenderOptions{})); + EXPECT_EQ("env env True ", out); +} + +TEST(RenderOptionsTest, GlobalsWorkForWideTemplatesAndSinks) +{ + TemplateEnv env; + auto tpl = env.FromString(L"{{ name }}{{ n + 1 }}"); + ASSERT_TRUE(tpl) << tpl.error().ToString(); + RenderOptions options; + options.globals = GlobalsOf({ { "name", "w" }, { "n", 41 } }); + std::wstring out; + ASSERT_TRUE(tpl->RenderTo(out, {}, options)); + EXPECT_EQ(L"w42", out); + + std::wstring collected; + ContainerSink sink(collected); + ASSERT_TRUE(tpl->RenderTo(sink, {}, options)); + EXPECT_EQ(L"w42", collected); +} + +TEST(RenderOptionsTest, AGlobalACallableKeepsOutlivesTheOptions) +{ + // A callable may keep a value of the globals after the render: the options share their map + TemplateEnv env; + Value kept; + const UserCallable keep{ [&kept](const UserCallableParams& params) { + kept = params["v"]; + return Value(); + }, + { ArgInfo{ "v" } } }; + auto tpl = env.FromString("{{ keep(items) }}"); + ASSERT_TRUE(tpl) << tpl.error().ToString(); + { + RenderOptions options; + options.globals = GlobalsOf({ { "items", ValuesList{ 1, 2, 3 } } }); + std::string out; + auto result = tpl->RenderTo(out, { { "keep", keep } }, options); + ASSERT_TRUE(result) << result.error().ToString(); + } + ASSERT_TRUE(kept.IsList()); + auto join = env.FromString("{{ v|join(',') }}"); + ASSERT_TRUE(join) << join.error().ToString(); + EXPECT_EQ("1,2,3", join->RenderAsString({ { "v", kept } }).value()); +} + +TEST(RenderOptionsTest, PollRunsDuringALongRenderAndMayStopIt) +{ + TemplateEnv env; + auto tpl = env.FromString("{% for i in range(20000) %}{{ i }}{% endfor %}"); + ASSERT_TRUE(tpl) << tpl.error().ToString(); + + int polls = 0; + RenderOptions options; + options.poll = [&polls] { ++polls; }; + std::string out; + ASSERT_TRUE(tpl->RenderTo(out, {}, options)); + // About one poll every 1024 steps, without any limit set + EXPECT_GT(polls, 5); + + // No poll: none, and an ordinary render is not polled by an earlier one's + polls = 0; + out.clear(); + ASSERT_TRUE(tpl->RenderTo(out, {}, RenderOptions{})); + EXPECT_EQ(0, polls); + + // A throwing poll stops the render with its exception, and the string keeps what it had + options.poll = [&polls] { + if (++polls == 3) + { + throw PollStop{}; + } + }; + polls = 0; + out = "kept"; + auto result = tpl->RenderTo(out, {}, options); + ASSERT_FALSE(result); + EXPECT_EQ(3, polls); + EXPECT_EQ("kept", out); + ASSERT_TRUE(result.error().GetException()); + EXPECT_THROW(std::rethrow_exception(result.error().GetException()), PollStop); +} + +TEST(RenderOptionsTest, PollKeepsTheTemplatesLimits) +{ + TemplateEnv env; + env.GetSettings().limits.maxRenderSteps = 5000; + auto tpl = env.FromString("{% for i in range(20000) %}{{ i }}{% endfor %}"); + ASSERT_TRUE(tpl) << tpl.error().ToString(); + int polls = 0; + RenderOptions options; + options.poll = [&polls] { ++polls; }; + std::string out; + auto result = tpl->RenderTo(out, {}, options); + ASSERT_FALSE(result); + EXPECT_EQ(ErrorCode::ResourceLimitExceeded, result.error().GetCode()) << result.error().ToString(); + EXPECT_GT(polls, 0); +} + +TEST(RenderOptionsTest, ARenderACallableStartsIsPolledToo) +{ + TemplateEnv env; + auto inner = env.FromString("{% for i in range(5000) %}.{% endfor %}"); + ASSERT_TRUE(inner) << inner.error().ToString(); + int innerPolls = -1; + int polls = 0; + env.AddGlobal("nested", UserCallable{ [&](const UserCallableParams&) { + const int before = polls; + auto out = inner->RenderAsString({}); + innerPolls = polls - before; + return Value(out ? out->size() : 0); + }, + {} }); + auto tpl = env.FromString("{{ nested() }}"); + ASSERT_TRUE(tpl) << tpl.error().ToString(); + RenderOptions options; + options.poll = [&polls] { ++polls; }; + std::string out; + ASSERT_TRUE(tpl->RenderTo(out, {}, options)); + EXPECT_EQ("5000", out); + EXPECT_GT(innerPolls, 0); +} From daea4c50f699a06c2d0fe8f7a338b3f6cc71f839 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 11:08:42 +0000 Subject: [PATCH 02/11] RenderCore: poll and limits choice out of line (cognitive complexity); test includes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWNL7n7c5XUg2Xoo4ZRjLn --- src/template_impl.h | 45 ++++++++++++++++++++++++-------------- test/template_env_test.cpp | 2 ++ 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/template_impl.h b/src/template_impl.h index 9594de46c..f02d444ae 100644 --- a/src/template_impl.h +++ b/src/template_impl.h @@ -466,6 +466,32 @@ class TemplateImpl : public ITemplateImpl return RenderCore(os, params, nullptr); } + // The poll of a render with hooks, installed in `scope`, or null + template + static detail::HostPoll InstallPoll(std::optional& scope, const RenderHooks* hooks) + { + if constexpr (hasHooks) + { + if (hooks->poll) + { + scope.emplace(hooks->poll); + return hooks->poll; + } + } + static_cast(scope); + static_cast(hooks); + return nullptr; + } + // The limits a render installs: the template's, or with a poll of its own and none, those that count its steps + [[nodiscard]] const Settings::Limits* RenderLimitsOf(detail::HostPoll poll) const + { + if (m_settings->hasRenderLimits) + { + return &m_settings->settings.limits; + } + return poll ? &PollOnlyLimits() : nullptr; + } + // `hooks` (with hasHooks set) brings the render's own globals and poll (RenderHooks). A // separate instantiation, so that the ordinary Render pays nothing for them template @@ -493,25 +519,12 @@ class TemplateImpl : public ITemplateImpl // First, so that it ends last: when nothing of the render holds values any more // It also keeps the render's copies of the host's data (docs/tasks/0049) const CycleCollector cycles; - const InternalValueMap* hostGlobals = nullptr; - const Settings::Limits* limits = m_settings->hasRenderLimits ? &m_settings->settings.limits : nullptr; + const InternalValueMap* hostGlobals = hasHooks ? hooks->globals : nullptr; // Before the limits, which start counting the steps to the first poll std::optional pollScope; - if constexpr (hasHooks) - { - hostGlobals = hooks->globals; - if (hooks->poll) - { - pollScope.emplace(hooks->poll); - // A poll alone makes the render count its steps - if (!limits) - { - limits = &PollOnlyLimits(); - } - } - } + const auto* poll = InstallPoll(pollScope, hooks); // Before anything else the render does, the conversion of the params included - const RenderLimitsScope limitsScope(limits, m_settings->bigIntBackend); + const RenderLimitsScope limitsScope(RenderLimitsOf(poll), m_settings->bigIntBackend); // The tables of the params and the top scope are lent by the thread's workspace and // go back to it, emptied, when the render ends (docs/tasks/0160) const WorkspaceMaps maps; diff --git a/test/template_env_test.cpp b/test/template_env_test.cpp index b10692263..4f39c21bb 100644 --- a/test/template_env_test.cpp +++ b/test/template_env_test.cpp @@ -7,8 +7,10 @@ #include #include +#include #include #include +#include #include using namespace jinja2; From 737db7eb9bc7b4295f98a688d63b467104767a2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 11:32:35 +0000 Subject: [PATCH 03/11] Public ISandboxPolicy in Settings, immutableContainers and ErrorCode::SecurityError (0357 S2c) Settings::sandboxPolicy holds a public ISandboxPolicy, which one bridge turns into the engine's policy (renamed IEngineSandboxPolicy). Its Format may decline: the engine's own str.format then reads each field's attribute through the policy, as Jinja2's SandboxedFormatter does. Settings::immutableContainers is the public switch for the immutable sandbox. A refused attribute fails with the new ErrorCode::SecurityError instead of an UndefinedError with a marker, and UndefinedDescription::security makes and reads one. A template keeps the policy it was loaded with (0427, done). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWNL7n7c5XUg2Xoo4ZRjLn --- CMakeLists.txt | 2 + docs/api-2.0.md | 4 +- docs/design/0357-python-module-public-api.md | 6 + docs/python.md | 6 +- docs/tasks/0357-python-module-public-api.md | 11 + .../0427-sandbox-policy-and-template-cache.md | 9 +- include/jinja2cpp/error_info.h | 1 + include/jinja2cpp/host_object.h | 3 + include/jinja2cpp/sandbox_policy.h | 79 +++++++ include/jinja2cpp/template_env.h | 9 + python/src/adapters.cpp | 17 +- python/src/adapters.h | 2 +- python/src/errors.cpp | 9 +- src/engine_errors.h | 7 +- src/error_boundary.h | 7 +- src/error_info.cpp | 5 + src/expression_evaluator.cpp | 2 +- src/load_settings.h | 20 +- src/public_host_object.cpp | 4 +- src/public_sandbox_policy.cpp | 86 +++++++ src/render_context.h | 8 +- src/sandbox_policy.h | 33 +-- src/template_env.cpp | 12 +- src/template_env_impl.h | 8 +- src/undefined.h | 7 +- src/value_methods.cpp | 18 +- src/value_methods.h | 2 +- test/errors_test.cpp | 1 + test/public_sandbox_policy_test.cpp | 221 ++++++++++++++++++ test/sandbox_policy_test.cpp | 19 +- 30 files changed, 537 insertions(+), 81 deletions(-) create mode 100644 include/jinja2cpp/sandbox_policy.h create mode 100644 src/public_sandbox_policy.cpp create mode 100644 test/public_sandbox_policy_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 585e13f60..51529d22f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -263,6 +263,7 @@ set(JINJA2CPP_SOURCES src/prompt_escape.cpp src/prompt_filters.cpp src/public_host_object.cpp + src/public_sandbox_policy.cpp src/token_budget.cpp src/python_format.cpp src/recursion_guard.cpp @@ -530,6 +531,7 @@ if (JINJA2CPP_BUILD_TESTS) test/helpers_tests.cpp test/host_object_test.cpp test/public_host_object_test.cpp + test/public_sandbox_policy_test.cpp test/sandbox_policy_test.cpp test/i18n_test.cpp test/limits_test.cpp diff --git a/docs/api-2.0.md b/docs/api-2.0.md index dae8059ac..8e7c76914 100644 --- a/docs/api-2.0.md +++ b/docs/api-2.0.md @@ -176,7 +176,9 @@ locations), 0255 (engine runtime errors). `Load` replaces the template for its own object only, and a module keeps its tree, reversing the second half of 0284 D1a. 0301 raises it for a stored macro of a module that ended with an earlier call; 0300 for a template callable lent to a host's call and called after it), 20 `DataReadError` (`ToValue` could not - read a JSON document: its `JsonTraits` reported an error; 0311). `UnexpectedException` (1) now means "an exception the + read a JSON document: its `JsonTraits` reported an error; 0311), 21 `SecurityError` (a sandbox refused an attribute or a + list or dict method: `ISandboxPolicy`, `Settings::immutableContainers`; 0357 B7, until then an `UndefinedError` with a + marker in `extraParams[1]`). `UnexpectedException` (1) now means "an exception the engine did not raise and could not attribute", with location and `GetException()`. **Against Python.** Same: syntax, undefined and not-found errors keep their codes, with diff --git a/docs/design/0357-python-module-public-api.md b/docs/design/0357-python-module-public-api.md index 295feb865..a4264a690 100644 --- a/docs/design/0357-python-module-public-api.md +++ b/docs/design/0357-python-module-public-api.md @@ -181,3 +181,9 @@ Codex agrees with option 2 and with composition (D1). It also agrees with D3: an - **D6: Codex disagrees with the switch-only sandbox.** Under B7 alone, a subclassed `SandboxedEnvironment` that rejects a native method never sees that operation. Native methods are resolved and called directly (`src/expression_evaluator.cpp:1685-1696`), and native attribute safety is decided in `src/value_methods.cpp:2747-2764`. So the loss is in what the policy enforces, not only in message text. Codex prefers an optional public policy callback, with no cost when it is unset, honoured on both the native attribute path and the direct method-call path. A fixed built-in sandbox is acceptable only as an explicit, owner-approved reduction of the drop-in contract. **Effect on the plan.** B1 gains three export capabilities: lossless entries, the tuple flag and the identity token. All three belong to the public `GenericList`/`GenericMap` export views and also help C++ users. The D6 recommendation changes to **D6b, an optional `ISandboxPolicy`**, because parity in what the policy enforces is a goal and ordinary renders pay nothing. + +## 10. S2b and S2c as built (2026-11-09) + +- **B3.** `RenderOptions{ shared_ptr globals; std::function poll; }` and `Template::RenderTo(out|sink, params, options)`. The globals are shared, not borrowed: a value of them that a callable keeps stays valid after the render (`LendGlobal`, as for the environment's globals). A poll alone makes a render count its steps (`PollOnlyLimits`), and a render the polled one starts with no poll of its own is polled too. Same `RenderCore` instantiation as the module's `RenderInternal`; the ordinary render is unchanged (instruction gate). +- **B6.** `Template::FindReferencedTemplates()`; wide templates now give their names (they gave `nullopt` before). +- **B7 / D6b.** `jinja2::ISandboxPolicy` in `jinja2cpp/sandbox_policy.h`, held by `Settings::sandboxPolicy` (identity equality; a template keeps the policy it was loaded with, 0427). `NativeAttribute(obj, name) -> optional` is asked where the engine's policy was; `Format(...) -> optional` returns nullopt by default, and the engine's own `str.format` then reads a field's attribute through `NativeAttribute`. `Settings::immutableContainers` replaces `Dialect::ImmutableSandbox` for users; `ErrorCode::SecurityError` (21) replaces the `extraParams[1]` marker; `UndefinedDescription::security` marks a refused attribute. The engine's interface is `IEngineSandboxPolicy` with one bridge (`src/public_sandbox_policy.cpp`), as for host objects. diff --git a/docs/python.md b/docs/python.md index 749c367bb..8ba881070 100644 --- a/docs/python.md +++ b/docs/python.md @@ -345,15 +345,15 @@ inside the adapters that let the engine read Python objects, not in generated co Jinja2's `OverflowError("Range too big. The sandbox blocks ranges larger than MAX_RANGE (100000).")`. - **The engine's own values** (the template's strings, numbers, lists, dicts, `loop`, `cycler`, - macros) have no Python attributes. The environment holds an `ISandboxPolicy` (src/sandbox_policy.h, - internal until 0357 makes it public) that the engine asks where a native value's attribute is + macros) have no Python attributes. The environment holds an `IEngineSandboxPolicy` (src/sandbox_policy.h, + the engine's side of the public `ISandboxPolicy` the module moves to in 0357 S3) that the engine asks where a native value's attribute is missing, after it finds one of its methods, and at `str.format`. A private name (`__class__`, `__init__`, `_x`) on one is asked of `is_safe_attribute` with a stand-in object of the Python type and is refused with the same undefined; so is a public name when the subclass's `is_safe_attribute` is not Jinja2's. `format_map` of a string, which the engine lacks, and `str.format` are Python's, run through `SandboxedFormatter`. `ImmutableSandboxedEnvironment` also refuses the methods of the engine's lists and dicts that change them (`append`, `update`, - ...) with Jinja2's message (`Dialect::ImmutableSandbox`, the read-only containers of the + ...) with Jinja2's message and `ErrorCode::SecurityError` (`Dialect::ImmutableSandbox`, the read-only containers of the transformers preset). - **Cost.** A plain `Environment` pays a null check where the policy would be asked: the checks are behind a pointer the render already holds, which only a sandboxed environment sets. A sandboxed render calls `is_safe_attribute` once diff --git a/docs/tasks/0357-python-module-public-api.md b/docs/tasks/0357-python-module-public-api.md index 962942a74..0a00fdb08 100644 --- a/docs/tasks/0357-python-module-public-api.md +++ b/docs/tasks/0357-python-module-public-api.md @@ -71,3 +71,14 @@ freeze. builds in shared mode. Found on the way: in a wide template every referenced name came back as `nullopt` (the walk read narrow strings only); fixed, with a row. The module's poll moved to the `std::function` form of `detail::t_hostPoll`; its render entry is unchanged until S3. +- S2c (B7, D6b), same PR as S2b: public `jinja2::ISandboxPolicy` (`jinja2cpp/sandbox_policy.h`): + `NativeAttribute(const Value&, string_view) -> optional` and `Format(self, formatMap, params) -> + optional`, nullopt by default for the engine's own `str.format`, which now reads a field's + attribute through `NativeAttribute` (Jinja2's `SandboxedFormatter`). Held by `Settings::sandboxPolicy` + (identity equality), bridged to the engine's policy, renamed `IEngineSandboxPolicy` + (`src/public_sandbox_policy.cpp`). `Settings::immutableContainers` is the public switch for + `Dialect::ImmutableSandbox`. `ErrorCode::SecurityError` (21) replaces the `extraParams[1]` marker on an + `UndefinedError`; `UndefinedDescription::security` makes and reads a refused attribute's undefined value. + 0427 decided: a template keeps the policy it was loaded with. Template callables (a macro, `caller`) + reach `NativeAttribute` as an empty value, so S3 must keep the module's stand-in for them. + diff --git a/docs/tasks/0427-sandbox-policy-and-template-cache.md b/docs/tasks/0427-sandbox-policy-and-template-cache.md index f343b37a6..0d657d948 100644 --- a/docs/tasks/0427-sandbox-policy-and-template-cache.md +++ b/docs/tasks/0427-sandbox-policy-and-template-cache.md @@ -1,5 +1,5 @@ --- -status: open +status: done priority: low area: api depends: [0354, 0357] @@ -20,3 +20,10 @@ it was loaded with (as it keeps the delimiters). Whichever is chosen, `TemplateE Python module's per-load refresh of the public-names flag follow it. **Done when.** The decision is in `docs/design/0357` and a C++ test pins it. + +**Decision (0357 S2c).** A template keeps the policy it was loaded with, as it keeps its delimiters and other +settings: the public policy is `Settings::sandboxPolicy`, compared by identity in `Settings ==`, so a Load after a +change gets a fresh `LoadSettings` and the cache's own comparison of settings applies as for any other setting. +Pinned by `PublicSandboxPolicyTest.ATemplateKeepsThePolicyItWasLoadedWith`. The internal `SetSandboxPolicy` and +`SetDialect` go with the Python module's switch (0357 S3/S4). + diff --git a/include/jinja2cpp/error_info.h b/include/jinja2cpp/error_info.h index cce405273..c3b29e190 100644 --- a/include/jinja2cpp/error_info.h +++ b/include/jinja2cpp/error_info.h @@ -49,6 +49,7 @@ enum class ErrorCode ResourceLimitExceeded = 18, //!< A limit was passed: one of Settings::Limits, a built-in cap or a host's budget. ExtraParams[0] contains the message, ExtraParams[1] (for Settings::Limits) the name of the limit (`"maxRenderSteps"`) TemplateExpired = 19, //!< A macro of a \ref BasicTemplateModule called a macro an earlier call stored, whose module ended with that call (docs/tasks/0301). ExtraParams[0] contains the macro's name, ExtraParams[1] is "ended". A Load no longer ends a module (docs/tasks/0321). A macro or `caller` a host's call got as an argument and called after that call ended has ExtraParams[0] "lent" (docs/tasks/0300) DataReadError = 20, //!< \ref ToValue could not read a JSON document: its JsonTraits reported an error. ExtraParams[0] contains the message, ExtraParams[1] the JSON path of the value + SecurityError = 21, //!< A sandbox refused an operation (Jinja2's SecurityError): the use of an attribute \ref ISandboxPolicy refused (an undefined value with `security` set, \ref UndefinedDescription), a method that changes a list or dict under Settings::immutableContainers. ExtraParams[0] contains the message ExpectedStringLiteral = 1001, //!< String literal expected ExpectedIdentifier = 1002, //!< Identifier expected ExpectedSquareBracket = 1003, //!< ']' expected diff --git a/include/jinja2cpp/host_object.h b/include/jinja2cpp/host_object.h index 867393051..5474a2481 100644 --- a/include/jinja2cpp/host_object.h +++ b/include/jinja2cpp/host_object.h @@ -327,6 +327,9 @@ struct UndefinedDescription bool isAttr = true; //! Replaces the message built from the fields above when not empty std::string hint; + //! A sandbox refused the attribute (Jinja2's `unsafe_undefined`): a use that fails reports + //! ErrorCode::SecurityError instead of ErrorCode::UndefinedError (\ref ISandboxPolicy) + bool security = false; }; /*! diff --git a/include/jinja2cpp/sandbox_policy.h b/include/jinja2cpp/sandbox_policy.h new file mode 100644 index 000000000..015216c57 --- /dev/null +++ b/include/jinja2cpp/sandbox_policy.h @@ -0,0 +1,79 @@ +#ifndef JINJA2CPP_SANDBOX_POLICY_H +#define JINJA2CPP_SANDBOX_POLICY_H + +#include "config.h" +#include "user_callable.h" +#include "value.h" + +#include +#include + +JINJA2CPP_SUPPRESS_DLL_INTERFACE_WARNINGS_BEGIN + +namespace jinja2 +{ +inline namespace v2 +{ +/*! + * \brief A host's sandbox for the values of the engine (Jinja2's `SandboxedEnvironment`) + * + * Set as \ref Settings::sandboxPolicy, it decides what a template may do with the engine's own values: strings, + * numbers, lists, dicts, and template objects such as `loop`, a macro or a namespace. A host object (\ref IHostObject) + * is never asked about: it applies its own policy in \ref IHostObject::GetAttr and \ref IHostObject::Call. Unset, + * the default, a render pays one null check where a policy would be asked. + * + * The policy is asked: + * - for every attribute or method a template reads from a value of the engine by name (`s.upper`, `l.append`, + * `x|attr('n')`, `loop.index`), found or not, before the engine's own answer is used; + * - for `str.format` and `str.format_map`, which it may perform itself (\ref Format). + * + * Together with \ref Settings::immutableContainers, \ref ErrorCode::SecurityError and the `security` flag of + * \ref UndefinedDescription it is what the Python module's `SandboxedEnvironment` and + * `ImmutableSandboxedEnvironment` are built on (docs/python.md). A template keeps the policy it was loaded with, + * as it keeps its other settings. Called from any thread that renders, so it must be thread-safe. + */ +class JINJA2CPP_EXPORT ISandboxPolicy +{ +public: + ISandboxPolicy() = default; + ISandboxPolicy(const ISandboxPolicy&) = delete; + ISandboxPolicy& operator=(const ISandboxPolicy&) = delete; + ISandboxPolicy(ISandboxPolicy&&) = delete; + ISandboxPolicy& operator=(ISandboxPolicy&&) = delete; + virtual ~ISandboxPolicy(); + + /*! + * \brief The attribute `name` of `obj`, as the sandbox lets the template read it + * + * Jinja2's `is_safe_attribute`: return `std::nullopt` to leave the engine's answer (the attribute, the bound + * method, or an undefined value when there is none), or the value the template reads instead. A refused name + * is usually `MakeUndefined` with `security` set, whose use fails with \ref ErrorCode::SecurityError; a value + * the engine lacks (a method of Python's `str`) may be any value, a \ref UserCallable among them. + * + * @param obj The value, as a host callable gets it: `loop` and a namespace as a snapshot of their + * attributes, a macro or `caller` as an empty value + * @param name The attribute's name + */ + [[nodiscard]] virtual std::optional NativeAttribute(const Value& obj, std::string_view name) const = 0; + + /*! + * \brief `self.format(*args, **kwargs)`, or `self.format_map(mapping)` when `formatMap`, in the sandbox + * + * Return the result, or `std::nullopt`, the default, for the engine's own `str.format`, which then reads the + * attribute a format field names (`{0.name}`) through \ref NativeAttribute, as Jinja2's `SandboxedFormatter` + * does: a refused one formats as its undefined value. Strings of the engine have no `format_map` unless + * \ref NativeAttribute supplies one. + * + * @param self The string + * @param formatMap Whether the call is `format_map`, whose one argument is the mapping + * @param params The call's arguments, as a callable declared with ArgInfo::VarArgs and ArgInfo::VarKwArgs + * (and UserCallable::keepUndefined) gets them + */ + [[nodiscard]] virtual std::optional Format(const Value& self, bool formatMap, const UserCallableParams& params) const; +}; +} // namespace v2 +} // namespace jinja2 + +JINJA2CPP_SUPPRESS_DLL_INTERFACE_WARNINGS_END + +#endif // JINJA2CPP_SANDBOX_POLICY_H diff --git a/include/jinja2cpp/template_env.h b/include/jinja2cpp/template_env.h index ab3b03a80..34c76064d 100644 --- a/include/jinja2cpp/template_env.h +++ b/include/jinja2cpp/template_env.h @@ -93,6 +93,8 @@ JINJA2CPP_EXPORT AutoescapeSelector SelectAutoescape(std::vector en bool defaultValue = false); //! Global template environment settings +class ISandboxPolicy; + struct Settings { /// Extensions set which should be supported @@ -170,6 +172,13 @@ struct Settings //! (ErrorCode::UnknownFilter, ErrorCode::UnknownTest) unless it is inside an `if`, where it fails when it runs. //! With this flag such names are looked up when they run instead bool userCallablesAsFilters = false; + //! Makes the methods that change a list or dict in place (`append`, `pop`, `update`, `clear`, ...) unsafe + //! attributes, as Jinja2's `ImmutableSandboxedEnvironment` does: calling one fails with ErrorCode::SecurityError. + //! Off by default + bool immutableContainers = false; + //! A sandbox for the engine's values (\ref ISandboxPolicy, jinja2cpp/sandbox_policy.h). Unset by default. Compared + //! by identity; a template keeps the policy it was loaded with + std::shared_ptr sandboxPolicy; //! Bounds on what one Load or Render may do, for templates that are not trusted (docs/api-2.0.md). A Render that //! passes one fails with ErrorCode::ResourceLimitExceeded, `extraParams[0]` the message and `extraParams[1]` the diff --git a/python/src/adapters.cpp b/python/src/adapters.cpp index b3e5779f6..6d7410d05 100644 --- a/python/src/adapters.cpp +++ b/python/src/adapters.cpp @@ -520,9 +520,9 @@ nb::object Representative(const InternalValue& obj, bool& synthetic) } } -// The ISandboxPolicy of a SandboxedEnvironment: the environment's own is_safe_attribute and wrap_str_format, asked +// The IEngineSandboxPolicy of a SandboxedEnvironment: the environment's own is_safe_attribute and wrap_str_format, asked // for the engine's values. It borrows the environment, which holds the engine environment that holds this -class PySandboxPolicy final : public jinja2::ISandboxPolicy +class PySandboxPolicy final : public jinja2::IEngineSandboxPolicy { public: PySandboxPolicy(PyObject* environment, bool allNames) @@ -536,7 +536,7 @@ class PySandboxPolicy final : public jinja2::ISandboxPolicy if (name == "format_map" && jinja2::IsStringValue(obj)) { // The engine's strings have no format_map; the sandbox's is SandboxedFormatter's - return InternalValue(jinja2::Callable(jinja2::Callable::UserCallable, [obj, this](const CallParams& params, RenderContext& context) { return Format(obj, true, params, context); })); + return InternalValue(jinja2::Callable(jinja2::Callable::UserCallable, [obj, this](const CallParams& params, RenderContext& context) { return FormatWith(obj, true, params, context); })); } // Python's objects have attributes the engine's values have not: __class__, __init__, ... // A public name is the environment's to judge only when its is_safe_attribute is not Jinja2's, which @@ -576,7 +576,13 @@ class PySandboxPolicy final : public jinja2::ISandboxPolicy return std::nullopt; } - InternalValue Format(const InternalValue& self, bool formatMap, const CallParams& params, RenderContext& context) const override + std::optional Format(const InternalValue& self, bool formatMap, const CallParams& params, RenderContext& context) const override + { + return FormatWith(self, formatMap, params, context); + } + +private: + InternalValue FormatWith(const InternalValue& self, bool formatMap, const CallParams& params, RenderContext& context) const { const auto text = ToPython(self); const auto method = Steal(PyObject_GetAttrString(text.ptr(), formatMap ? "format_map" : "format")); @@ -588,7 +594,6 @@ class PySandboxPolicy final : public jinja2::ISandboxPolicy return CallPython(wrapped.ptr(), params, context); } -private: PyObject* m_environment; bool m_allNames; }; @@ -604,7 +609,7 @@ bool SandboxJudgesPublicNames(nb::handle environment) return PyObject_IsTrue(all.ptr()) != 0; } -std::shared_ptr MakePythonSandboxPolicy(nb::handle environment, bool allNames) +std::shared_ptr MakePythonSandboxPolicy(nb::handle environment, bool allNames) { return std::make_shared(environment.ptr(), allNames); } diff --git a/python/src/adapters.h b/python/src/adapters.h index a185ef2c9..146f07eac 100644 --- a/python/src/adapters.h +++ b/python/src/adapters.h @@ -81,7 +81,7 @@ class PyHost final : public jinja2::IEngineHostObject // Whether the sandboxed environment judges the public names of the engine's values too (its is_safe_attribute is not // Jinja2's): read again at each load, as the method may be replaced after the first bool SandboxJudgesPublicNames(nb::handle environment); -std::shared_ptr MakePythonSandboxPolicy(nb::handle environment, bool allNames); +std::shared_ptr MakePythonSandboxPolicy(nb::handle environment, bool allNames); // The PyHost a value of the engine carries, if any const PyHost* AsPyHost(const InternalValue& value); diff --git a/python/src/errors.cpp b/python/src/errors.cpp index 20f6177cc..fc0f1c08e 100644 --- a/python/src/errors.cpp +++ b/python/src/errors.cpp @@ -54,6 +54,7 @@ std::string ErrorMessage(const jinja2::ErrorInfo& error) case ErrorCode::UserCallableException: case ErrorCode::UnexpectedException: case ErrorCode::DataReadError: + case ErrorCode::SecurityError: case ErrorCode::TemplateNotFound: case ErrorCode::FileNotFound: if (auto text = ParamText(error, 0); !text.empty()) @@ -131,12 +132,10 @@ void RaiseError(const jinja2::ErrorInfo& error, const ErrorSource& source) case ErrorCode::FileNotFound: Raise(names.templateNotFound, names.templateNotFound(message)); case ErrorCode::UndefinedError: - // The use of an attribute the sandbox refused (jinja2::SecurityErrorMarker) - if (ParamText(error, 1) == jinja2::SecurityErrorMarker) - { - Raise(names.securityError, names.securityError(message)); - } Raise(names.undefinedError, names.undefinedError(message)); + case ErrorCode::SecurityError: + // The use of an attribute the sandbox refused, or of a list or dict method an immutable sandbox refuses + Raise(names.securityError, names.securityError(message)); case ErrorCode::ResourceLimitExceeded: // The sandbox's range(): safe_range's OverflowError if (ParamText(error, 1) == "maxRange") diff --git a/src/engine_errors.h b/src/engine_errors.h index b5b103f2a..cb81d79a4 100644 --- a/src/engine_errors.h +++ b/src/engine_errors.h @@ -52,11 +52,6 @@ class TemplateRuntimeError : public std::runtime_error using std::runtime_error::runtime_error; }; -// extraParams[1] of an ErrorCode::UndefinedError that Jinja2 reports as a SecurityError: the use of an attribute the -// sandbox refused (the jinja2cpp Python module's SandboxedEnvironment, and the list and dict methods of its -// immutable form) -inline constexpr const char* SecurityErrorMarker = "SecurityError"; - // Python's UndefinedError: ErrorCode::UndefinedError class UndefinedError : public std::exception { @@ -67,7 +62,7 @@ class UndefinedError : public std::exception { } [[nodiscard]] const char* what() const noexcept override { return m_message.c_str(); } - // Jinja2's SecurityError, which the sandbox's unsafe undefined raises on use (SecurityErrorMarker) + // Jinja2's SecurityError, which the sandbox's unsafe undefined raises on use (ErrorCode::SecurityError) [[nodiscard]] bool IsSecurity() const noexcept { return m_security; } private: diff --git a/src/error_boundary.h b/src/error_boundary.h index 65c37fbb6..b9bbfdc1e 100644 --- a/src/error_boundary.h +++ b/src/error_boundary.h @@ -91,13 +91,10 @@ typename BasicErrorInfo::Data ExceptionToData(std::exception_ptr exceptio } catch (const UndefinedError& ex) { - code = ErrorCode::UndefinedError; + // A refused attribute's undefined value fails as Jinja2's SecurityError + code = ex.IsSecurity() ? ErrorCode::SecurityError : ErrorCode::UndefinedError; message = ex.what(); exception = nullptr; - if (ex.IsSecurity()) - { - limitField = SecurityErrorMarker; - } } catch (const TemplateRuntimeError& ex) { diff --git a/src/error_info.cpp b/src/error_info.cpp index e0ecc8c94..be9f6eac5 100644 --- a/src/error_info.cpp +++ b/src/error_info.cpp @@ -200,6 +200,8 @@ const char* CodeDescription(ErrorCode code) noexcept return "template expired"; case ErrorCode::DataReadError: return "data read error"; + case ErrorCode::SecurityError: + return "security error"; case ErrorCode::ExpectedStringLiteral: return "string literal expected"; case ErrorCode::ExpectedIdentifier: @@ -352,6 +354,9 @@ void RenderErrorInfo(std::basic_string& result, const BasicErrorInfo(), errInfo.GetExtraParams()[0]); break; + case ErrorCode::SecurityError: + fmt::format_to(std::back_inserter(out), UNIVERSAL_STR("Security error: {}").GetValue(), errInfo.GetExtraParams()[0]); + break; case ErrorCode::YetUnsupported: fmt::format_to(std::back_inserter(out), UNIVERSAL_STR("This feature has not been supported yet").GetValue()); break; diff --git a/src/expression_evaluator.cpp b/src/expression_evaluator.cpp index 09fdb375e..e305d59f8 100644 --- a/src/expression_evaluator.cpp +++ b/src/expression_evaluator.cpp @@ -1712,7 +1712,7 @@ bool CallExpression::TryCallMethod(RenderContext& values, InternalValue& result, if (values.GetSandboxPolicy()) { - // The policy decides about a method of a value, as about an attribute (ISandboxPolicy::NativeAttribute) + // The policy decides about a method of a value, as about an attribute (IEngineSandboxPolicy::NativeAttribute) if (auto replacement = SandboxedNativeAttribute(&values, receiver, *name)) { callee = std::move(*replacement); diff --git a/src/load_settings.h b/src/load_settings.h index 969cd24fd..c09c99990 100644 --- a/src/load_settings.h +++ b/src/load_settings.h @@ -103,7 +103,7 @@ struct Delimiters // (TemplateEnvImpl::GetLoadSettings), instead of each copying the settings and rebuilding the delimiters struct LoadSettings { - explicit LoadSettings(const Settings& setts, const BigIntBackend* backend = nullptr, Dialect templateDialect = Dialect::Jinja2, std::shared_ptr policy = nullptr) + explicit LoadSettings(const Settings& setts, const BigIntBackend* backend = nullptr, Dialect templateDialect = Dialect::Jinja2, std::shared_ptr policy = nullptr) : settings(setts) , narrow(setts) , wide(setts) @@ -112,9 +112,11 @@ struct LoadSettings #else , hasRenderLimits(jinja2::HasRenderLimits(setts.limits) || backend != nullptr) #endif - , dialect(templateDialect) + , dialect(templateDialect == Dialect::Jinja2 && setts.immutableContainers ? Dialect::ImmutableSandbox : templateDialect) + , environmentDialect(templateDialect) , bigIntBackend(backend) - , sandboxPolicy(std::move(policy)) + , sandboxPolicy(policy || !setts.sandboxPolicy ? policy : MakeEngineSandboxPolicy(setts.sandboxPolicy)) + , environmentSandboxPolicy(std::move(policy)) , promptEscaper(AutoescapePromptEscaper(settings)) { } @@ -139,12 +141,18 @@ struct LoadSettings // settings.limits differs from the defaults, or (outside a -DJINJA2CPP_WITH_BIGINT=boost build, where every // template has one and RenderLimitsScope swaps just the pointer, docs/tasks/0272) the template has a backend bool hasRenderLimits; - // What a preset changed besides the settings (ConfigureForChatTemplates) + // What a preset changed besides the settings (ConfigureForChatTemplates), or Settings::immutableContainers Dialect dialect; + // The dialect the environment set (TemplateEnvImpl::SetDialect), without Settings::immutableContainers: what the + // next LoadSettings of the environment starts from + Dialect environmentDialect; // The big-integer arithmetic the template's renders use (TemplateEnv::SetBigIntBackend); null for none const BigIntBackend* bigIntBackend; - // The sandbox of templates loaded with these settings (src/sandbox_policy.h); null for none - std::shared_ptr sandboxPolicy; + // The sandbox of templates loaded with these settings (src/sandbox_policy.h): the environment's own (the Python + // module's, until 0357 S3) or a bridge over Settings::sandboxPolicy; null for none + std::shared_ptr sandboxPolicy; + // The environment's own (TemplateEnvImpl::SetSandboxPolicy), without the bridge: what its next LoadSettings starts from + std::shared_ptr environmentSandboxPolicy; // The escaper of AutoescapeMode::Prompt, null in HTML mode: chosen once here, so a render only copies it into its // callback (docs/tasks/0358). Points into settings.promptEscaper, or to the one without patterns const PromptEscaperImpl* promptEscaper; diff --git a/src/public_host_object.cpp b/src/public_host_object.cpp index 72c8889e4..97c3c519e 100644 --- a/src/public_host_object.cpp +++ b/src/public_host_object.cpp @@ -303,7 +303,7 @@ const UndefinedCarrier* AsCarrier(const IHostObject* object) // hint is a plain undefined, which fails no use (what the engine hands out for a name it has no information about) InternalValue UndefinedFromDescription(const UndefinedDescription& description) { - if (description.policy == UndefinedPolicy::Default && description.name.empty() && description.objType.empty() && description.hint.empty()) + if (description.policy == UndefinedPolicy::Default && description.name.empty() && description.objType.empty() && description.hint.empty() && !description.security) { return InternalValue(UndefinedValue{}); } @@ -313,6 +313,7 @@ InternalValue UndefinedFromDescription(const UndefinedDescription& description) info->objType = description.objType; info->isAttr = description.isAttr; info->hint = description.hint; + info->security = description.security; return InternalValue(UndefinedValue{ info.get() }); } @@ -766,6 +767,7 @@ Value UndefinedToHost(const UndefinedValue& undefined) description.objType = info->objType; description.isAttr = info->isAttr; description.hint = info->hint; + description.security = info->security; } return MakeUndefined(std::move(description)); } diff --git a/src/public_sandbox_policy.cpp b/src/public_sandbox_policy.cpp new file mode 100644 index 000000000..02a66a9b6 --- /dev/null +++ b/src/public_sandbox_policy.cpp @@ -0,0 +1,86 @@ +#include "internal_value.h" +#include "public_host_object.h" +#include "sandbox_policy.h" +#include "value_visitors.h" + +#include +#include +#include + +#include +#include +#include +#include + +// The engine's side of the public sandbox policy (jinja2cpp/sandbox_policy.h, docs/tasks/0357 B7): one bridge +// implements the engine's IEngineSandboxPolicy over a public ISandboxPolicy, converting the values at each question + +namespace jinja2 +{ +inline namespace v2 +{ +ISandboxPolicy::~ISandboxPolicy() = default; + +std::optional ISandboxPolicy::Format(const Value& /*self*/, bool /*formatMap*/, const UserCallableParams& /*params*/) const +{ + return std::nullopt; +} + +namespace +{ +class SandboxPolicyBridge final : public IEngineSandboxPolicy +{ +public: + explicit SandboxPolicyBridge(std::shared_ptr policy) + : m_policy(std::move(policy)) + { + } + + [[nodiscard]] std::optional NativeAttribute(const InternalValue& obj, const std::string& name) const override + { + auto replacement = m_policy->NativeAttribute(ToHostValue(obj), name); + if (!replacement) + { + return std::nullopt; + } + return Value2IntValue(std::move(*replacement)); + } + + std::optional Format(const InternalValue& self, bool formatMap, const CallParams& params, RenderContext& context) const override + { + // Through a callable of the engine, so that the arguments reach the policy as those of a host's callable do, + // lent for the call, and its result is converted while they are + bool declined = false; + UserCallable call( + [this, &declined, text = ToHostValue(self), formatMap](const UserCallableParams& callParams) -> Value { + auto formatted = m_policy->Format(text, formatMap, callParams); + if (!formatted) + { + declined = true; + return Value(); + } + return std::move(*formatted); + }, + { ArgInfo(ArgInfo::VarArgs), ArgInfo(ArgInfo::VarKwArgs) }); + // An undefined argument formats as one + call.keepUndefined = true; + const auto callable = visitors::InputValueConvertor::ConvertUserCallable(call); + auto result = GetIf(&callable)->GetExpressionCallable()(params, context); + if (declined) + { + return std::nullopt; + } + return result; + } + +private: + std::shared_ptr m_policy; +}; +} // namespace + +std::shared_ptr MakeEngineSandboxPolicy(std::shared_ptr policy) +{ + return std::make_shared(std::move(policy)); +} +} // namespace v2 +} // namespace jinja2 diff --git a/src/render_context.h b/src/render_context.h index 0add32e6a..0106ac348 100644 --- a/src/render_context.h +++ b/src/render_context.h @@ -128,7 +128,7 @@ struct IRendererCallback : IComparable [[nodiscard]] virtual detail::Dialect GetDialect() const { return detail::Dialect::Jinja2; } // The sandbox policy of the template (src/sandbox_policy.h), null for none. A field, not a virtual: the methods of // a value ask it each time one is found - [[nodiscard]] const ISandboxPolicy* GetSandboxPolicy() const noexcept { return m_sandboxPolicy; } + [[nodiscard]] const IEngineSandboxPolicy* GetSandboxPolicy() const noexcept { return m_sandboxPolicy; } // The environment the template was loaded in, if any [[nodiscard]] virtual TemplateEnv* GetEnv() const { return nullptr; } // The random generator of lipsum, one per render @@ -163,11 +163,11 @@ struct IRendererCallback : IComparable protected: // Called once, when the template's callback is made void SetPromptEscaper(const detail::PromptEscaperImpl* escaper) noexcept { m_promptEscaper = escaper; } - void SetSandboxPolicy(const ISandboxPolicy* policy) noexcept { m_sandboxPolicy = policy; } + void SetSandboxPolicy(const IEngineSandboxPolicy* policy) noexcept { m_sandboxPolicy = policy; } private: const detail::PromptEscaperImpl* m_promptEscaper = nullptr; - const ISandboxPolicy* m_sandboxPolicy = nullptr; + const IEngineSandboxPolicy* m_sandboxPolicy = nullptr; }; // The slots where names were last found (docs/tasks/0100 idea 7). An entry holds the slot @@ -1051,7 +1051,7 @@ class RenderContext return m_rendererCallback ? m_rendererCallback->GetEnv() : nullptr; } // The sandbox policy of the template (src/sandbox_policy.h), null for none - [[nodiscard]] const ISandboxPolicy* GetSandboxPolicy() const noexcept { return m_rendererCallback ? m_rendererCallback->GetSandboxPolicy() : nullptr; } + [[nodiscard]] const IEngineSandboxPolicy* GetSandboxPolicy() const noexcept { return m_rendererCallback ? m_rendererCallback->GetSandboxPolicy() : nullptr; } [[nodiscard]] detail::Dialect GetDialect() const { return m_rendererCallback ? m_rendererCallback->GetDialect() : detail::Dialect::Jinja2; } // Settings::userCallablesAsFilters of the template: whether a name that is no filter or test // of the template may be a callable among the values diff --git a/src/sandbox_policy.h b/src/sandbox_policy.h index 7e5113693..1b8c0c77c 100644 --- a/src/sandbox_policy.h +++ b/src/sandbox_policy.h @@ -3,6 +3,7 @@ #include "internal_value.h" +#include #include #include @@ -15,28 +16,34 @@ class RenderContext; // What a host decides about the operations of a template on the engine's own values, for a sandbox (docs/tasks/0354, // 0248 D8): Jinja2's SandboxedEnvironment.is_safe_attribute and wrap_str_format, for the values that are not host -// objects (a host object is asked through IEngineHostObject). Internal for now; docs/design/0357 makes it the public -// ISandboxPolicy, held by Settings. An environment holds it as a shared_ptr (LoadSettings), the -// render context reaches it through its renderer callback, and a render without one pays a null check where it would -// ask: after a method of a value is found, where an attribute is missing, and at str.format -class ISandboxPolicy +// objects (a host object is asked through IEngineHostObject). The engine's side of the public jinja2::ISandboxPolicy +// (jinja2cpp/sandbox_policy.h, docs/tasks/0357 B7), which Settings::sandboxPolicy holds and one bridge +// (MakeEngineSandboxPolicy) implements this over. An environment holds it as a shared_ptr +// (LoadSettings), the render context reaches it through its renderer callback, and a render without one pays a null +// check where it would ask: after a method of a value is found, where an attribute is missing, and at str.format +class IEngineSandboxPolicy { public: - ISandboxPolicy() = default; - ISandboxPolicy(const ISandboxPolicy&) = delete; - ISandboxPolicy& operator=(const ISandboxPolicy&) = delete; - ISandboxPolicy(ISandboxPolicy&&) = delete; - ISandboxPolicy& operator=(ISandboxPolicy&&) = delete; - virtual ~ISandboxPolicy() = default; + IEngineSandboxPolicy() = default; + IEngineSandboxPolicy(const IEngineSandboxPolicy&) = delete; + IEngineSandboxPolicy& operator=(const IEngineSandboxPolicy&) = delete; + IEngineSandboxPolicy(IEngineSandboxPolicy&&) = delete; + IEngineSandboxPolicy& operator=(IEngineSandboxPolicy&&) = delete; + virtual ~IEngineSandboxPolicy() = default; // The attribute `name` of `obj`, a value of the engine, whether or not the engine has one of that name (a // method, or none): the replacement the sandbox reads instead (the unsafe undefined of a refused attribute, a // method the engine lacks), or nullopt to leave the engine's answer. Never asked for a host object [[nodiscard]] virtual std::optional NativeAttribute(const InternalValue& obj, const std::string& name) const = 0; - // s.format(*args, **kwargs), or s.format_map(mapping) when `formatMap`, of a string of the engine - virtual InternalValue Format(const InternalValue& self, bool formatMap, const CallParams& params, RenderContext& context) const = 0; + // s.format(*args, **kwargs), or s.format_map(mapping) when `formatMap`, of a string of the engine; nullopt for the + // engine's own str.format, which asks NativeAttribute about the attribute a field reads (SandboxedFormatter) + virtual std::optional Format(const InternalValue& self, bool formatMap, const CallParams& params, RenderContext& context) const = 0; }; +class ISandboxPolicy; +// The engine's policy over a public one (public_sandbox_policy.cpp) +std::shared_ptr MakeEngineSandboxPolicy(std::shared_ptr policy); + } // namespace v2 } // namespace jinja2 diff --git a/src/template_env.cpp b/src/template_env.cpp index bbea7cfa4..fa4eb3124 100644 --- a/src/template_env.cpp +++ b/src/template_env.cpp @@ -83,15 +83,15 @@ bool operator==(const Settings& lhs, const Settings& rhs) // Structured bindings name every field: a field added to Settings or Settings::Extensions stops this compiling // until it is compared here too const auto& [lTrim, lLstrip, lCacheSize, lAutoReload, lExt, lMetaType, lKeepNl, lNlSeq, lVarStart, lVarEnd, lBlockStart, lBlockEnd, lCommentStart, - lCommentEnd, lLineStmt, lLineComment, lFinalize, lAutoescape, lAutoescapeSelector, lAutoescapeMode, lPromptEscaper, lTokenizer, lUndefined, lLookup, lCallablesAsFilters, lLimits] = lhs; + lCommentEnd, lLineStmt, lLineComment, lFinalize, lAutoescape, lAutoescapeSelector, lAutoescapeMode, lPromptEscaper, lTokenizer, lUndefined, lLookup, lCallablesAsFilters, lImmutable, lSandbox, lLimits] = lhs; const auto& [rTrim, rLstrip, rCacheSize, rAutoReload, rExt, rMetaType, rKeepNl, rNlSeq, rVarStart, rVarEnd, rBlockStart, rBlockEnd, rCommentStart, - rCommentEnd, rLineStmt, rLineComment, rFinalize, rAutoescape, rAutoescapeSelector, rAutoescapeMode, rPromptEscaper, rTokenizer, rUndefined, rLookup, rCallablesAsFilters, rLimits] = rhs; + rCommentEnd, rLineStmt, rLineComment, rFinalize, rAutoescape, rAutoescapeSelector, rAutoescapeMode, rPromptEscaper, rTokenizer, rUndefined, rLookup, rCallablesAsFilters, rImmutable, rSandbox, rLimits] = rhs; const auto& [lDo, lLoopControls, lI18n] = lExt; const auto& [rDo, rLoopControls, rI18n] = rExt; // The flags and numbers first, then the strings - if (std::tie(lTrim, lLstrip, lCacheSize, lAutoReload, lDo, lLoopControls, lI18n, lKeepNl, lAutoescape, lAutoescapeSelector, lAutoescapeMode, lPromptEscaper, lTokenizer, lUndefined, lLookup, lCallablesAsFilters) - != std::tie(rTrim, rLstrip, rCacheSize, rAutoReload, rDo, rLoopControls, rI18n, rKeepNl, rAutoescape, rAutoescapeSelector, rAutoescapeMode, rPromptEscaper, rTokenizer, rUndefined, rLookup, rCallablesAsFilters)) + if (std::tie(lTrim, lLstrip, lCacheSize, lAutoReload, lDo, lLoopControls, lI18n, lKeepNl, lAutoescape, lAutoescapeSelector, lAutoescapeMode, lPromptEscaper, lTokenizer, lUndefined, lLookup, lCallablesAsFilters, lImmutable, lSandbox) + != std::tie(rTrim, rLstrip, rCacheSize, rAutoReload, rDo, rLoopControls, rI18n, rKeepNl, rAutoescape, rAutoescapeSelector, rAutoescapeMode, rPromptEscaper, rTokenizer, rUndefined, rLookup, rCallablesAsFilters, rImmutable, rSandbox)) { return false; } @@ -310,7 +310,7 @@ void TemplateEnvImpl::SetDialect(Dialect dialect) m_loadSettings = std::make_shared(settings, BigIntBackendLocked(), dialect, SandboxPolicyLocked()); } -void TemplateEnvImpl::SetSandboxPolicy(std::shared_ptr policy) +void TemplateEnvImpl::SetSandboxPolicy(std::shared_ptr policy) { const WriterLock l(m_loadSettingsGuard); if (policy == SandboxPolicyLocked()) @@ -320,7 +320,7 @@ void TemplateEnvImpl::SetSandboxPolicy(std::shared_ptr pol m_loadSettings = std::make_shared(settings, BigIntBackendLocked(), DialectLocked(), std::move(policy)); } -std::shared_ptr TemplateEnvImpl::GetSandboxPolicy() const +std::shared_ptr TemplateEnvImpl::GetSandboxPolicy() const { const WriterLock l(m_loadSettingsGuard); return SandboxPolicyLocked(); diff --git a/src/template_env_impl.h b/src/template_env_impl.h index 0cbbceca8..28290bd61 100644 --- a/src/template_env_impl.h +++ b/src/template_env_impl.h @@ -108,15 +108,15 @@ class TemplateEnvImpl void SetDialect(Dialect dialect); Dialect GetDialect() const; // The sandbox policy of templates loaded from now on, kept in m_loadSettings too (src/sandbox_policy.h) - void SetSandboxPolicy(std::shared_ptr policy); - std::shared_ptr GetSandboxPolicy() const; + void SetSandboxPolicy(std::shared_ptr policy); + std::shared_ptr GetSandboxPolicy() const; private: const BigIntBackend* BigIntBackendLocked() const noexcept JINJA2CPP_REQUIRES(m_loadSettingsGuard) { return m_loadSettings ? m_loadSettings->bigIntBackend : GetBuiltinBigIntBackend(); } - Dialect DialectLocked() const noexcept JINJA2CPP_REQUIRES(m_loadSettingsGuard) { return m_loadSettings ? m_loadSettings->dialect : Dialect::Jinja2; } + Dialect DialectLocked() const noexcept JINJA2CPP_REQUIRES(m_loadSettingsGuard) { return m_loadSettings ? m_loadSettings->environmentDialect : Dialect::Jinja2; } - std::shared_ptr SandboxPolicyLocked() const JINJA2CPP_REQUIRES(m_loadSettingsGuard) { return m_loadSettings ? m_loadSettings->sandboxPolicy : nullptr; } + std::shared_ptr SandboxPolicyLocked() const JINJA2CPP_REQUIRES(m_loadSettingsGuard) { return m_loadSettings ? m_loadSettings->environmentSandboxPolicy : nullptr; } mutable Mutex m_loadSettingsGuard; // Made on the first Load, or by SetBigIntBackend or SetDialect when they change what it holds diff --git a/src/undefined.h b/src/undefined.h index b0bb6454f..407bbed24 100644 --- a/src/undefined.h +++ b/src/undefined.h @@ -31,7 +31,7 @@ struct UndefinedInfo final : ValueObject // A string key, reported as an attribute bool isAttr = true; // The sandbox refused the attribute (Jinja2's unsafe_undefined, exc=SecurityError): a use that fails - // reports a SecurityError, which ErrorInfo marks with extraParams[1] == SecurityErrorMarker + // reports ErrorCode::SecurityError bool security = false; // Replaces the message built from the fields above when set std::string hint; @@ -47,7 +47,7 @@ enum class UndefinedUse Method, // what Undefined has no method for: dictsort's items(), indent's +=, wordwrap; every policy refuses }; -// What the sandbox policy of the render (ISandboxPolicy) gives for the attribute `name` that the value `obj` of the engine has +// What the sandbox policy of the render (IEngineSandboxPolicy) gives for the attribute `name` that the value `obj` of the engine has // none of: the unsafe undefined of an attribute Python's object has and the sandbox refuses, or a method the engine // lacks; nothing without a sandbox, and for a host object, which the host reads itself std::optional SandboxedNativeAttribute(const RenderContext* context, const InternalValue& obj, const std::string& name); @@ -78,8 +78,7 @@ std::string UndefinedMessage(const UndefinedInfo& info); // {{ undefined value printed: hint }} std::string DebugUndefinedText(const UndefinedInfo& info); [[noreturn]] void ThrowUndefined(const UndefinedInfo& info); -// The undefined value of an attribute the sandbox refuses: its use fails with a SecurityError (UndefinedError -// code, extraParams[1] SecurityErrorMarker) +// The undefined value of an attribute the sandbox refuses: its use fails with ErrorCode::SecurityError InternalValue MakeUnsafeUndefined(const RenderContext& context, std::string hint); // The Python class of the undefined value, as a TypeError names it: Undefined, StrictUndefined, // ChainableUndefined or DebugUndefined diff --git a/src/value_methods.cpp b/src/value_methods.cpp index 68dd1eafe..01a854011 100644 --- a/src/value_methods.cpp +++ b/src/value_methods.cpp @@ -1488,6 +1488,12 @@ struct StrOps { result = host->GetAttr(attr); } + else if (auto replacement = SandboxedNativeAttribute(&context, value, attr)) + { + // The sandbox's formatter reads a field's attribute as the sandbox lets the template read it: a refused + // one is its undefined value, which formats as one (SandboxedFormatter.get_field) + return std::move(*replacement); + } else if (const auto* method = FindMethod(value, attr); method && (!isObject || map->GetAttrPolicy() == MapAttrPolicy::MethodsFirst || !map->HasValue(attr))) { result = MakeBoundMethod(value, *method); @@ -1827,7 +1833,10 @@ InternalValue StrFormat(const InternalValue& self, const CallParams& params, Ren { if (const auto* policy = context.GetSandboxPolicy()) { - return policy->Format(self, false, params, context); + if (auto formatted = policy->Format(self, false, params, context)) + { + return std::move(*formatted); + } } return ApplyStringConverter(self, [¶ms, &context](auto sv) -> InternalValue { using CharT = typename decltype(sv)::value_type; @@ -1972,7 +1981,10 @@ InternalValue MarkupFormat(const InternalValue& self, const CallParams& params, { if (const auto* policy = context.GetSandboxPolicy()) { - return policy->Format(self, false, params, context); + if (auto formatted = policy->Format(self, false, params, context)) + { + return std::move(*formatted); + } } return MarkupResult(ApplyStringConverter(self, [¶ms, &context](auto sv) -> InternalValue { using CharT = typename decltype(sv)::value_type; @@ -2793,7 +2805,7 @@ InternalValue GetAttr(const InternalValue& obj, const std::string& name, RenderC { return UnsafeMethod(obj, name, *context); } - // A sandbox policy decides about the method too (ISandboxPolicy::NativeAttribute) + // A sandbox policy decides about the method too (IEngineSandboxPolicy::NativeAttribute) if (context && context->GetSandboxPolicy()) { if (auto replacement = SandboxedNativeAttribute(context, obj, name)) diff --git a/src/value_methods.h b/src/value_methods.h index 353c52e55..d25bbc98b 100644 --- a/src/value_methods.h +++ b/src/value_methods.h @@ -50,7 +50,7 @@ InternalValue UnsafeMethod(const InternalValue& obj, std::string_view name, cons // x.name: Python's getattr first, then the item, as Jinja2's Environment.getattr InternalValue GetAttr(const InternalValue& obj, const std::string& name, RenderContext* context); // What the sandbox policy of the render says about the attribute `name` that `obj` has, when `obj` is loop, a cycler, -// a namespace or a macro, which have no Python class to ask about their public names (ISandboxPolicy::NativeAttribute); +// a namespace or a macro, which have no Python class to ask about their public names (IEngineSandboxPolicy::NativeAttribute); // nothing without a policy or for another value std::optional SandboxedSyntheticAttribute(RenderContext* context, const InternalValue& obj, const std::string& name); // x[key]: the item first, then a method of that name, as Jinja2's Environment.getitem diff --git a/test/errors_test.cpp b/test/errors_test.cpp index 3a7c5ac11..63eb9f9fb 100644 --- a/test/errors_test.cpp +++ b/test/errors_test.cpp @@ -644,6 +644,7 @@ static_assert(static_cast(ErrorCode::TemplateRuntimeError) == 17); static_assert(static_cast(ErrorCode::ResourceLimitExceeded) == 18); static_assert(static_cast(ErrorCode::TemplateExpired) == 19); static_assert(static_cast(ErrorCode::DataReadError) == 20); +static_assert(static_cast(ErrorCode::SecurityError) == 21); static_assert(sizeof(ErrorInfo) == sizeof(void*)); static_assert(sizeof(ErrorInfoW) == sizeof(void*)); static_assert(std::is_nothrow_default_constructible_v); diff --git a/test/public_sandbox_policy_test.cpp b/test/public_sandbox_policy_test.cpp new file mode 100644 index 000000000..d166c224d --- /dev/null +++ b/test/public_sandbox_policy_test.cpp @@ -0,0 +1,221 @@ +// The public sandbox (jinja2cpp/sandbox_policy.h, docs/tasks/0357 B7) through public headers only: a policy in +// Settings::sandboxPolicy, Settings::immutableContainers and ErrorCode::SecurityError. The expected outputs are +// Python Jinja2 3.1.6's SandboxedEnvironment and ImmutableSandboxedEnvironment where they have one + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace jinja2; + +namespace +{ +// Refuses private names as Jinja2's is_safe_attribute does, replaces `upper`, answers `extra` and records what it +// was asked +class ToyPolicy final : public ISandboxPolicy +{ +public: + [[nodiscard]] std::optional NativeAttribute(const Value& obj, std::string_view name) const override + { + { + const std::lock_guard lock(m_guard); + m_asked.push_back(std::string(name) + (obj.IsString() ? " of '" + obj.AsString() + "'" : std::string())); + } + if (!name.empty() && name[0] == '_') + { + UndefinedDescription refused; + refused.name = std::string(name); + refused.hint = "access to attribute '" + std::string(name) + "' is unsafe."; + refused.security = true; + return MakeUndefined(std::move(refused)); + } + if (name == "upper") + { + return Value(UserCallable{ [](const UserCallableParams&) { return Value("replaced"); }, {} }); + } + if (name == "extra") + { + return Value("an attribute the engine lacks"); + } + return std::nullopt; + } + + [[nodiscard]] std::vector Asked() const + { + const std::lock_guard lock(m_guard); + return m_asked; + } + +private: + mutable std::mutex m_guard; + mutable std::vector m_asked; +}; + +size_t CountOf(const Value& list) +{ + if (const auto* values = list.GetIf()) + { + return values->size(); + } + const auto* generic = list.GetIf(); + return generic ? generic->GetSize().value_or(0) : 0; +} + +// Formats every string as the count of its arguments +class FormattingPolicy final : public ISandboxPolicy +{ +public: + [[nodiscard]] std::optional NativeAttribute(const Value&, std::string_view) const override { return std::nullopt; } + [[nodiscard]] std::optional Format(const Value& self, bool formatMap, const UserCallableParams& params) const override + { + return Value((formatMap ? "map " : "format ") + self.AsString() + " " + std::to_string(CountOf(params.extraPosArgs))); + } +}; + +std::string RenderOrError(TemplateEnv& env, const std::string& source, ErrorInfo* errorOut = nullptr, ValuesMap params = {}) +{ + auto tpl = env.FromString(source); + if (!tpl) + { + return "load error: " + tpl.error().ToString(); + } + auto result = tpl->RenderAsString(params); + if (!result) + { + if (errorOut) + { + *errorOut = result.error(); + } + return "error: " + result.error().ToString(); + } + return *result; +} +} // namespace + +TEST(PublicSandboxPolicyTest, ARefusedAttributeIsASecurityErrorOnUse) +{ + auto policy = std::make_shared(); + TemplateEnv env; + env.GetSettings().sandboxPolicy = policy; + // Printed or tested, a refused attribute is an undefined value + EXPECT_EQ("[]", RenderOrError(env, "[{{ ''.__class__ }}]")); + EXPECT_EQ("False", RenderOrError(env, "{{ x.__class__ is defined }}", nullptr, { { "x", 1 } })); + // Called, it fails as Jinja2's SecurityError + ErrorInfo error; + const auto text = RenderOrError(env, "{{ ''.__class__() }}", &error); + EXPECT_EQ(ErrorCode::SecurityError, error.GetCode()) << text; + EXPECT_NE(text.find("access to attribute '__class__' is unsafe."), std::string::npos) << text; + // An undefined value the host made is described with the flag + EXPECT_TRUE(UndefinedOf(*policy->NativeAttribute(Value(), "_x"))->security); +} + +TEST(PublicSandboxPolicyTest, ThePolicyIsAskedAboutAttributesAndMethodsFoundOrNot) +{ + auto policy = std::make_shared(); + TemplateEnv env; + env.GetSettings().sandboxPolicy = policy; + EXPECT_EQ("replaced|True|an attribute the engine lacks|b", RenderOrError(env, "{{ 'abc'.upper() }}|{{ 'abc'|attr('upper') is defined }}|{{ 'abc'.extra }}|{{ 'a b'.split()[1] }}")); + const auto asked = policy->Asked(); + // The object reaches the policy as a public value: here the string + EXPECT_NE(std::find(asked.begin(), asked.end(), "upper of 'abc'"), asked.end()); + EXPECT_NE(std::find(asked.begin(), asked.end(), "extra of 'abc'"), asked.end()); + EXPECT_NE(std::find(asked.begin(), asked.end(), "split of 'a b'"), asked.end()); +} + +TEST(PublicSandboxPolicyTest, TheEnginesFormatAsksAboutFieldAttributes) +{ + auto policy = std::make_shared(); + TemplateEnv env; + env.GetSettings().sandboxPolicy = policy; + // Jinja2: '{0.__class__}|'.format(x) is '|' in a sandbox; a field reads what the sandbox lets it read + EXPECT_EQ("|", RenderOrError(env, "{{ '{0.__class__}|'.format('s') }}")); + EXPECT_EQ("an attribute the engine lacks", RenderOrError(env, "{{ '{0.extra}'.format('s') }}")); + EXPECT_EQ("a-1", RenderOrError(env, "{{ '{}-{n}'.format('a', n=1) }}")); +} + +TEST(PublicSandboxPolicyTest, APolicyMayFormatItself) +{ + TemplateEnv env; + env.GetSettings().sandboxPolicy = std::make_shared(); + EXPECT_EQ("format {}-{} 2", RenderOrError(env, "{{ '{}-{}'.format(1, 2) }}")); + // A Markup's format too; its result is the policy's, not made Markup + EXPECT_EQ("format <{}> 1", RenderOrError(env, "{{ ('<{}>'|e).format('x') }}")); +} + +TEST(PublicSandboxPolicyTest, ImmutableContainersRefuseTheMethodsThatChangeThem) +{ + TemplateEnv plain; + EXPECT_EQ("None[1, 2]", RenderOrError(plain, "{% set l = [1] %}{{ l.append(2) }}{{ l }}")); + TemplateEnv env; + env.GetSettings().immutableContainers = true; + for (const char* source : { "{{ [].append(1) }}", "{% set d = {} %}{{ d.update({'a': 1}) }}", "{{ [3, 1].sort() }}" }) + { + ErrorInfo error; + const auto text = RenderOrError(env, source, &error); + EXPECT_EQ(ErrorCode::SecurityError, error.GetCode()) << source << ": " << text; + EXPECT_NE(text.find("is unsafe."), std::string::npos) << source << ": " << text; + } + ErrorInfo error; + RenderOrError(env, "{{ [].append(1) }}", &error); + EXPECT_NE(error.ToString().find("access to attribute 'append' of 'list' object is unsafe."), std::string::npos) << error.ToString(); + EXPECT_EQ("11[3, 1]", RenderOrError(env, "{% set l = [3, 1] %}{{ l.index(1) }}{{ l.count(3) }}{{ l.copy() }}")); +} + +TEST(PublicSandboxPolicyTest, WideTemplatesAreAskedToo) +{ + auto policy = std::make_shared(); + TemplateEnv env; + env.GetSettings().sandboxPolicy = policy; + auto tpl = env.FromString(L"[{{ x.__class__ }}]{{ 'abc'.upper() }}"); + ASSERT_TRUE(tpl) << tpl.error().ToString(); + EXPECT_EQ(L"[]replaced", tpl->RenderAsString({ { "x", 1 } }).value()); +} + +TEST(PublicSandboxPolicyTest, ATemplateKeepsThePolicyItWasLoadedWith) +{ + TemplateEnv env; + auto loose = env.FromString("{{ 'abc'.upper() }}"); + ASSERT_TRUE(loose) << loose.error().ToString(); + env.GetSettings().sandboxPolicy = std::make_shared(); + auto sandboxed = env.FromString("{{ 'abc'.upper() }}"); + ASSERT_TRUE(sandboxed) << sandboxed.error().ToString(); + EXPECT_EQ("ABC", loose->RenderAsString({}).value()); + EXPECT_EQ("replaced", sandboxed->RenderAsString({}).value()); + // Unset again: templates loaded from now on have none + env.GetSettings().sandboxPolicy.reset(); + EXPECT_EQ("ABC", RenderOrError(env, "{{ 'abc'.upper() }}")); + EXPECT_EQ("replaced", sandboxed->RenderAsString({}).value()); +} + +TEST(PublicSandboxPolicyTest, SettingsCompareThePolicyByIdentity) +{ + Settings a; + Settings b; + EXPECT_EQ(a, b); + auto policy = std::make_shared(); + a.sandboxPolicy = policy; + EXPECT_NE(a, b); + b.sandboxPolicy = policy; + EXPECT_EQ(a, b); + b.sandboxPolicy = std::make_shared(); + EXPECT_NE(a, b); + b.sandboxPolicy = policy; + b.immutableContainers = true; + EXPECT_NE(a, b); +} diff --git a/test/sandbox_policy_test.cpp b/test/sandbox_policy_test.cpp index 9d144690a..7d9f0246c 100644 --- a/test/sandbox_policy_test.cpp +++ b/test/sandbox_policy_test.cpp @@ -1,5 +1,5 @@ -// The engine's part of the Python module's sandbox (docs/tasks/0354): the SecurityError marker of an unsafe -// attribute's error, Dialect::ImmutableSandbox, the undefined item of sum(attribute=), and the ISandboxPolicy +// The engine's part of the Python module's sandbox (docs/tasks/0354): ErrorCode::SecurityError of an unsafe +// attribute's error, Dialect::ImmutableSandbox, the undefined item of sum(attribute=), and the IEngineSandboxPolicy // callback an environment holds (src/sandbox_policy.h), tried with a toy policy written in C++ #include "../src/dialect.h" @@ -53,7 +53,7 @@ void UseDialect(TemplateEnv& env, detail::Dialect dialect) } // Refuses `secret`, answers a method it replaces by a callable, answers a missing name, and formats by itself -class ToyPolicy final : public ISandboxPolicy +class ToyPolicy final : public IEngineSandboxPolicy { public: [[nodiscard]] std::optional NativeAttribute(const InternalValue&, const std::string& name) const override @@ -73,7 +73,7 @@ class ToyPolicy final : public ISandboxPolicy } return std::nullopt; } - InternalValue Format(const InternalValue&, bool formatMap, const CallParams& params, RenderContext&) const override + std::optional Format(const InternalValue&, bool formatMap, const CallParams& params, RenderContext&) const override { return InternalValue(std::string(formatMap ? "map:" : "format:") + std::to_string(params.posParams.size())); } @@ -81,19 +81,18 @@ class ToyPolicy final : public ISandboxPolicy }; } // namespace -TEST(SandboxEngineTest, UnsafeAttributeErrorCarriesTheSecurityMarker) +TEST(SandboxEngineTest, UnsafeAttributeErrorIsASecurityError) { TemplateEnv env; UseDialect(env, detail::Dialect::ImmutableSandbox); ErrorInfo error; const auto text = RenderOrError(env, "{{ [].append(1) }}", &error); EXPECT_NE(text.find("access to attribute 'append' of 'list' object is unsafe."), std::string::npos) << text; - EXPECT_EQ(error.GetCode(), ErrorCode::UndefinedError); - ASSERT_EQ(error.GetExtraParams().size(), 2u); - EXPECT_EQ(*error.GetExtraParams()[1].GetIf(), SecurityErrorMarker); + EXPECT_EQ(error.GetCode(), ErrorCode::SecurityError); + EXPECT_EQ(error.GetExtraParams().size(), 1u); } -TEST(SandboxEngineTest, PlainUndefinedErrorHasNoMarker) +TEST(SandboxEngineTest, PlainUndefinedErrorIsNoSecurityError) { TemplateEnv env; ErrorInfo error; @@ -113,7 +112,7 @@ TEST(SandboxEngineTest, ImmutableSandboxDialectRefusesOnlyTheMutatingMethods) ErrorInfo error; const auto text = RenderOrError(env, source, &error); EXPECT_NE(text.find("is unsafe."), std::string::npos) << source << ": " << text; - EXPECT_EQ(error.GetExtraParams().size(), 2u) << source; + EXPECT_EQ(error.GetCode(), ErrorCode::SecurityError) << source; } EXPECT_EQ(RenderOrError(env, "{% set l = [3, 1] %}{{ l.index(1) }}{{ l.count(3) }}{{ l.copy() }}{% set d = {'a': 1} %}{{ d.get('a') }}{{ d.keys()|list }}"), "11[3, 1]1['a']"); // Not the transformers dialect: tojson stays Jinja2's From a187334884ed57ae0c510e615dd5b6840830fb70 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 11:53:48 +0000 Subject: [PATCH 04/11] Preflight: no poll scope in an ordinary render's frame; sandbox test tidy fixes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWNL7n7c5XUg2Xoo4ZRjLn --- src/template_impl.h | 23 ++++++++++------------- test/public_sandbox_policy_test.cpp | 12 ++++++++---- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/template_impl.h b/src/template_impl.h index f02d444ae..e19e2833e 100644 --- a/src/template_impl.h +++ b/src/template_impl.h @@ -467,20 +467,17 @@ class TemplateImpl : public ITemplateImpl } // The poll of a render with hooks, installed in `scope`, or null - template + struct NoPollScope + { + }; + static detail::HostPoll InstallPoll(NoPollScope& /*scope*/, const RenderHooks* /*hooks*/) { return nullptr; } static detail::HostPoll InstallPoll(std::optional& scope, const RenderHooks* hooks) { - if constexpr (hasHooks) + if (hooks->poll) { - if (hooks->poll) - { - scope.emplace(hooks->poll); - return hooks->poll; - } + scope.emplace(hooks->poll); } - static_cast(scope); - static_cast(hooks); - return nullptr; + return hooks->poll; } // The limits a render installs: the template's, or with a poll of its own and none, those that count its steps [[nodiscard]] const Settings::Limits* RenderLimitsOf(detail::HostPoll poll) const @@ -520,9 +517,9 @@ class TemplateImpl : public ITemplateImpl // It also keeps the render's copies of the host's data (docs/tasks/0049) const CycleCollector cycles; const InternalValueMap* hostGlobals = hasHooks ? hooks->globals : nullptr; - // Before the limits, which start counting the steps to the first poll - std::optional pollScope; - const auto* poll = InstallPoll(pollScope, hooks); + // Before the limits, which start counting the steps to the first poll. Nothing at all for an ordinary render + std::conditional_t, NoPollScope> pollScope; + const auto poll = InstallPoll(pollScope, hooks); // Before anything else the render does, the conversion of the params included const RenderLimitsScope limitsScope(RenderLimitsOf(poll), m_settings->bigIntBackend); // The tables of the params and the top scope are lent by the thread's workspace and diff --git a/test/public_sandbox_policy_test.cpp b/test/public_sandbox_policy_test.cpp index d166c224d..3ee6683e2 100644 --- a/test/public_sandbox_policy_test.cpp +++ b/test/public_sandbox_policy_test.cpp @@ -34,7 +34,7 @@ class ToyPolicy final : public ISandboxPolicy [[nodiscard]] std::optional NativeAttribute(const Value& obj, std::string_view name) const override { { - const std::lock_guard lock(m_guard); + const std::scoped_lock lock(m_guard); m_asked.push_back(std::string(name) + (obj.IsString() ? " of '" + obj.AsString() + "'" : std::string())); } if (!name.empty() && name[0] == '_') @@ -58,7 +58,7 @@ class ToyPolicy final : public ISandboxPolicy [[nodiscard]] std::vector Asked() const { - const std::lock_guard lock(m_guard); + const std::scoped_lock lock(m_guard); return m_asked; } @@ -88,7 +88,7 @@ class FormattingPolicy final : public ISandboxPolicy } }; -std::string RenderOrError(TemplateEnv& env, const std::string& source, ErrorInfo* errorOut = nullptr, ValuesMap params = {}) +std::string RenderOrError(TemplateEnv& env, const std::string& source, ErrorInfo* errorOut = nullptr, const ValuesMap& params = {}) { auto tpl = env.FromString(source); if (!tpl) @@ -122,7 +122,11 @@ TEST(PublicSandboxPolicyTest, ARefusedAttributeIsASecurityErrorOnUse) EXPECT_EQ(ErrorCode::SecurityError, error.GetCode()) << text; EXPECT_NE(text.find("access to attribute '__class__' is unsafe."), std::string::npos) << text; // An undefined value the host made is described with the flag - EXPECT_TRUE(UndefinedOf(*policy->NativeAttribute(Value(), "_x"))->security); + const auto refused = policy->NativeAttribute(Value(), "_x"); + ASSERT_TRUE(refused.has_value()); + const auto description = UndefinedOf(*refused); + ASSERT_TRUE(description.has_value()); + EXPECT_TRUE(description->security); } TEST(PublicSandboxPolicyTest, ThePolicyIsAskedAboutAttributesAndMethodsFoundOrNot) From de08fde355733e5acffb315a94d3e95cc88549f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 12:03:00 +0000 Subject: [PATCH 05/11] test: checked optional access Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWNL7n7c5XUg2Xoo4ZRjLn --- test/public_sandbox_policy_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/public_sandbox_policy_test.cpp b/test/public_sandbox_policy_test.cpp index 3ee6683e2..3db039e71 100644 --- a/test/public_sandbox_policy_test.cpp +++ b/test/public_sandbox_policy_test.cpp @@ -124,9 +124,9 @@ TEST(PublicSandboxPolicyTest, ARefusedAttributeIsASecurityErrorOnUse) // An undefined value the host made is described with the flag const auto refused = policy->NativeAttribute(Value(), "_x"); ASSERT_TRUE(refused.has_value()); - const auto description = UndefinedOf(*refused); + const auto description = UndefinedOf(refused.value()); ASSERT_TRUE(description.has_value()); - EXPECT_TRUE(description->security); + EXPECT_TRUE(description.value().security); } TEST(PublicSandboxPolicyTest, ThePolicyIsAskedAboutAttributesAndMethodsFoundOrNot) From af5416c0fcbd5768d6682fed38cc311a0509d1d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 12:47:06 +0000 Subject: [PATCH 06/11] sandbox_policy.h: format_map comes from NativeAttribute (verifier note) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWNL7n7c5XUg2Xoo4ZRjLn --- include/jinja2cpp/sandbox_policy.h | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/include/jinja2cpp/sandbox_policy.h b/include/jinja2cpp/sandbox_policy.h index 015216c57..32121fac9 100644 --- a/include/jinja2cpp/sandbox_policy.h +++ b/include/jinja2cpp/sandbox_policy.h @@ -25,7 +25,7 @@ inline namespace v2 * The policy is asked: * - for every attribute or method a template reads from a value of the engine by name (`s.upper`, `l.append`, * `x|attr('n')`, `loop.index`), found or not, before the engine's own answer is used; - * - for `str.format` and `str.format_map`, which it may perform itself (\ref Format). + * - for `str.format`, which it may perform itself (\ref Format). * * Together with \ref Settings::immutableContainers, \ref ErrorCode::SecurityError and the `security` flag of * \ref UndefinedDescription it is what the Python module's `SandboxedEnvironment` and @@ -61,8 +61,9 @@ class JINJA2CPP_EXPORT ISandboxPolicy * * Return the result, or `std::nullopt`, the default, for the engine's own `str.format`, which then reads the * attribute a format field names (`{0.name}`) through \ref NativeAttribute, as Jinja2's `SandboxedFormatter` - * does: a refused one formats as its undefined value. Strings of the engine have no `format_map` unless - * \ref NativeAttribute supplies one. + * does: a refused one formats as its undefined value. The engine asks with `formatMap` false only: its + * strings have no `format_map`, so a policy that wants one returns it from \ref NativeAttribute (a callable + * that calls `Format(self, true, ...)` itself), as the Python module does. * * @param self The string * @param formatMap Whether the call is `format_map`, whose one argument is the mapping From e740760da5f1452acd1a0f0ce97d694f14affd10 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:04:07 +0000 Subject: [PATCH 07/11] Poll the items a loop filter skips and a backend-only limits scope Codex on #811: a template loaded without limits had no step counting on the items a for-loop's if-filter drops, and a big-integer backend alone installed inactive limits, so RenderOptions::poll never ran. The filter loop now counts a step per item, and a poll with no step limit set gets the poll-only limits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWNL7n7c5XUg2Xoo4ZRjLn --- src/statements.cpp | 4 ++++ src/template_impl.h | 8 +++++--- src/template_parser.cpp | 9 --------- test/template_env_test.cpp | 40 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/statements.cpp b/src/statements.cpp index b0112e125..ad29e3cb0 100644 --- a/src/statements.cpp +++ b/src/statements.cpp @@ -1749,6 +1749,10 @@ bool ForStatement::FilterInFrame(IListAccessorEnumerator& items, Span fram bool isFound = false; while (items.MoveNext()) { + // The items the filter skips render no body, and a plain name or constant checks nothing: each item is a + // render step here (Settings::Limits::maxRenderSteps), and reaches a host's poll (RenderOptions::poll) in a + // template loaded without limits too + CheckStack(); // A plain name, the common case, takes the item into its slot and gives it back when // it is kept, without a copy if (rootSlot) diff --git a/src/template_impl.h b/src/template_impl.h index e19e2833e..57b2f46ba 100644 --- a/src/template_impl.h +++ b/src/template_impl.h @@ -482,11 +482,13 @@ class TemplateImpl : public ITemplateImpl // The limits a render installs: the template's, or with a poll of its own and none, those that count its steps [[nodiscard]] const Settings::Limits* RenderLimitsOf(detail::HostPoll poll) const { - if (m_settings->hasRenderLimits) + // A poll counts steps even where the template's own limits count none (a big-integer backend alone installs + // them outside a boost build) + if (poll && !HasRenderLimits(m_settings->settings.limits)) { - return &m_settings->settings.limits; + return &PollOnlyLimits(); } - return poll ? &PollOnlyLimits() : nullptr; + return m_settings->hasRenderLimits ? &m_settings->settings.limits : nullptr; } // `hooks` (with hasHooks set) brings the render's own globals and poll (RenderHooks). A diff --git a/src/template_parser.cpp b/src/template_parser.cpp index 32a0b4d0a..06a5dde8e 100644 --- a/src/template_parser.cpp +++ b/src/template_parser.cpp @@ -336,15 +336,6 @@ StatementsParser::ParseResult StatementsParser::ParseFor(LexScanner& lexer, Stat return MakeUnexpected(parsedExpr.error()); } ifExpr = *parsedExpr; - if (HasRenderLimits(m_settings.limits)) - { - // The items the filter skips render no body, and a plain name or constant checks - // nothing: the wrapper's check counts each item as a render step - // (Settings::Limits::maxRenderSteps). A template without limits does without it - auto evaluator = m_nodes.Make(); - m_nodes[evaluator].SetExpression(ifExpr); - ifExpr = evaluator; - } filterReadsLoop = ReadsName(lexer, filterStart, "loop"); isRecursive = lexer.EatIfEqual(Keyword::Recursive); } diff --git a/test/template_env_test.cpp b/test/template_env_test.cpp index 4f39c21bb..bac8e69ab 100644 --- a/test/template_env_test.cpp +++ b/test/template_env_test.cpp @@ -1,3 +1,5 @@ +#include "big_int_toy_backend.h" + #include #include #include @@ -276,6 +278,44 @@ TEST(RenderOptionsTest, PollKeepsTheTemplatesLimits) EXPECT_GT(polls, 0); } +TEST(RenderOptionsTest, PollReachesTheItemsALoopFilterSkips) +{ + // A template loaded without limits: the items the loop's filter drops render nothing, and are polled all the same + TemplateEnv env; + auto tpl = env.FromString("{% for i in range(20000) if false %}{{ i }}{% endfor %}"); + ASSERT_TRUE(tpl) << tpl.error().ToString(); + int polls = 0; + RenderOptions options; + options.poll = [&polls] { + if (++polls == 3) + { + throw PollStop{}; + } + }; + std::string out; + auto result = tpl->RenderTo(out, {}, options); + ASSERT_FALSE(result); + EXPECT_EQ(3, polls); + ASSERT_TRUE(result.error().GetException()); + EXPECT_THROW(std::rethrow_exception(result.error().GetException()), PollStop); +} + +TEST(RenderOptionsTest, PollCountsStepsWithACustomBigIntBackendAndNoLimits) +{ + // A big-integer backend alone installs the template's limits scope, with no step limit to count against + TemplateEnv env; + ASSERT_TRUE(env.SetBigIntBackend(big_int_toy::Backend())); + auto tpl = env.FromString("{% for i in range(20000) %}{{ i }}{% endfor %}"); + ASSERT_TRUE(tpl) << tpl.error().ToString(); + int polls = 0; + RenderOptions options; + options.poll = [&polls] { ++polls; }; + std::string out; + auto result = tpl->RenderTo(out, {}, options); + ASSERT_TRUE(result) << result.error().ToString(); + EXPECT_GT(polls, 5); +} + TEST(RenderOptionsTest, ARenderACallableStartsIsPolledToo) { TemplateEnv env; From da85d26371806534198f272ec2bda7015635bffb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:37:00 +0000 Subject: [PATCH 08/11] Convert a render's own globals in its limits scope and error boundary Codex on #811: RenderOptions::globals were converted before RenderCore, so a host BigInt past the 2^20-bit cap threw out of RenderTo instead of returning ResourceLimitExceeded, and the conversion ran outside the template's limits scope. RenderCore now converts them after installing the limits, inside its boundary, as it does the params. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWNL7n7c5XUg2Xoo4ZRjLn --- src/template_impl.h | 31 ++++++++++++++++++++++++------- test/template_env_test.cpp | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/template_impl.h b/src/template_impl.h index 57b2f46ba..94b7e2c05 100644 --- a/src/template_impl.h +++ b/src/template_impl.h @@ -432,6 +432,8 @@ class TemplateImpl : public ITemplateImpl { // Replace the environment's globals when set const InternalValueMap* globals = nullptr; + // Or the host's, converted in the render (RenderOptions::globals) + const std::shared_ptr* hostGlobals = nullptr; // The host's poll (detail::t_hostPoll) for the render, or null to keep the thread's const std::function* poll = nullptr; }; @@ -442,14 +444,9 @@ class TemplateImpl : public ITemplateImpl std::optional> RenderWithOptions(Target& os, const ValuesMap& params, const RenderOptions& options) const { RenderHooks hooks; - InternalValueMap globals; if (options.globals) { - for (const auto& [name, value] : *options.globals) - { - globals[name] = LendGlobal(value, options.globals); - } - hooks.globals = &globals; + hooks.hostGlobals = &options.globals; } if (options.poll) { @@ -479,6 +476,24 @@ class TemplateImpl : public ITemplateImpl } return hooks->poll; } + // The globals of a render with hooks, converted into `converted` where the host gave its own, or null + struct NoGlobals + { + }; + static const InternalValueMap* GlobalsOf(NoGlobals& /*converted*/, const RenderHooks* /*hooks*/) { return nullptr; } + static const InternalValueMap* GlobalsOf(InternalValueMap& converted, const RenderHooks* hooks) + { + if (!hooks->hostGlobals) + { + return hooks->globals; + } + const auto& host = *hooks->hostGlobals; + for (const auto& [name, value] : *host) + { + converted[name] = LendGlobal(value, host); + } + return &converted; + } // The limits a render installs: the template's, or with a poll of its own and none, those that count its steps [[nodiscard]] const Settings::Limits* RenderLimitsOf(detail::HostPoll poll) const { @@ -518,12 +533,14 @@ class TemplateImpl : public ITemplateImpl // First, so that it ends last: when nothing of the render holds values any more // It also keeps the render's copies of the host's data (docs/tasks/0049) const CycleCollector cycles; - const InternalValueMap* hostGlobals = hasHooks ? hooks->globals : nullptr; // Before the limits, which start counting the steps to the first poll. Nothing at all for an ordinary render std::conditional_t, NoPollScope> pollScope; const auto poll = InstallPoll(pollScope, hooks); // Before anything else the render does, the conversion of the params included const RenderLimitsScope limitsScope(RenderLimitsOf(poll), m_settings->bigIntBackend); + // Under the limits and in the error boundary, as the params are: a host global may be a big integer past the cap + std::conditional_t convertedGlobals; + const InternalValueMap* hostGlobals = GlobalsOf(convertedGlobals, hooks); // The tables of the params and the top scope are lent by the thread's workspace and // go back to it, emptied, when the render ends (docs/tasks/0160) const WorkspaceMaps maps; diff --git a/test/template_env_test.cpp b/test/template_env_test.cpp index bac8e69ab..0a4e3ad24 100644 --- a/test/template_env_test.cpp +++ b/test/template_env_test.cpp @@ -1,5 +1,7 @@ #include "big_int_toy_backend.h" +#include +#include #include #include #include @@ -200,6 +202,26 @@ TEST(RenderOptionsTest, GlobalsWorkForWideTemplatesAndSinks) EXPECT_EQ(L"w42", collected); } +TEST(RenderOptionsTest, AGlobalPastTheBigIntCapFailsTheRenderAsAParamDoes) +{ + TemplateEnv env; + BigInt huge; + ASSERT_TRUE(BigInt::TryParse(std::string(400000, '7'), huge)); + auto tpl = env.FromString("{{ n }}"); + ASSERT_TRUE(tpl) << tpl.error().ToString(); + std::string out; + auto asParam = tpl->RenderTo(out, { { "n", huge } }, RenderOptions{}); + ASSERT_FALSE(asParam); + EXPECT_EQ(ErrorCode::ResourceLimitExceeded, asParam.error().GetCode()) << asParam.error().ToString(); + + RenderOptions options; + options.globals = GlobalsOf({ { "n", huge } }); + // Threw out of RenderTo before the fix: gtest reports an escaping exception as a failure + auto asGlobal = tpl->RenderTo(out, {}, options); + ASSERT_FALSE(asGlobal); + EXPECT_EQ(ErrorCode::ResourceLimitExceeded, asGlobal.error().GetCode()) << asGlobal.error().ToString(); +} + TEST(RenderOptionsTest, AGlobalACallableKeepsOutlivesTheOptions) { // A callable may keep a value of the globals after the render: the options share their map From 859ce81ba9023d67a60a1b92144c91b3bf5e2eb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 14:56:32 +0000 Subject: [PATCH 09/11] Poll a nested render whose limits scope sets no limit Codex on #811: a render a polled one starts, on an environment with a custom big-integer backend and no limits (non-Boost build), installed a scope with inactive limits and was never polled. RenderLimitsScope now counts steps for an inherited poll whenever its limits set none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWNL7n7c5XUg2Xoo4ZRjLn --- src/recursion_guard.cpp | 5 +++-- test/template_env_test.cpp | 13 ++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/recursion_guard.cpp b/src/recursion_guard.cpp index c159707eb..df54d68ea 100644 --- a/src/recursion_guard.cpp +++ b/src/recursion_guard.cpp @@ -236,8 +236,9 @@ void RenderLimitsScope::Install(const Settings::Limits* limitsOrNone, const BigI m_saved = detail::t_renderLimits; detail::RenderLimits state; state.bigInts = bigInts; - // A render a polled one starts (a host callable rendering another template) is polled too - if (!limitsOrNone && detail::t_hostPoll) + // A render a polled one starts (a host callable rendering another template) is polled too, also where its + // template's limits scope sets no limit (a big-integer backend alone installs one outside a boost build) + if (detail::t_hostPoll && (!limitsOrNone || !HasRenderLimits(*limitsOrNone))) { limitsOrNone = &PollOnlyLimits(); } diff --git a/test/template_env_test.cpp b/test/template_env_test.cpp index 0a4e3ad24..be5e87417 100644 --- a/test/template_env_test.cpp +++ b/test/template_env_test.cpp @@ -338,9 +338,18 @@ TEST(RenderOptionsTest, PollCountsStepsWithACustomBigIntBackendAndNoLimits) EXPECT_GT(polls, 5); } -TEST(RenderOptionsTest, ARenderACallableStartsIsPolledToo) +class RenderOptionsNestedTest : public ::testing::TestWithParam +{ +}; + +TEST_P(RenderOptionsNestedTest, ARenderACallableStartsIsPolledToo) { TemplateEnv env; + // With a custom big-integer backend, the inner template installs a limits scope that sets no limit + if (GetParam()) + { + ASSERT_TRUE(env.SetBigIntBackend(big_int_toy::Backend())); + } auto inner = env.FromString("{% for i in range(5000) %}.{% endfor %}"); ASSERT_TRUE(inner) << inner.error().ToString(); int innerPolls = -1; @@ -361,3 +370,5 @@ TEST(RenderOptionsTest, ARenderACallableStartsIsPolledToo) EXPECT_EQ("5000", out); EXPECT_GT(innerPolls, 0); } + +INSTANTIATE_TEST_SUITE_P(BigIntBackend, RenderOptionsNestedTest, ::testing::Bool()); From dec3a058ef6077c2617fe0848c2f5c1efb399db3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 16:39:05 +0000 Subject: [PATCH 10/11] Keep the hooked render out of template.cpp The perf track held #811 on a +14 instruction cost on every render. Callgrind put it all in template.cpp's copy of ~RenderContext, the one the linker keeps: with RenderCore compiled in the same file, GCC called a robin_hood table destructor out of line there. The RenderOptions entry point (TemplateImpl::RenderWithOptions) now lives in src/template_render_options.cpp, and plain_text and substitute renders count the same as master. The TemplateParser constructor is forced inline, since the move left Load/plain_text +0.51%; it is now +0.24%, 6 of the 9 instructions being the Settings comparison of the two new fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWNL7n7c5XUg2Xoo4ZRjLn --- CMakeLists.txt | 1 + src/template_impl.h | 16 +++----------- src/template_parser.h | 2 +- src/template_render_options.cpp | 37 +++++++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 src/template_render_options.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 51529d22f..09d931519 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -273,6 +273,7 @@ set(JINJA2CPP_SOURCES src/statements.cpp src/string_converter_filter.cpp src/template.cpp + src/template_render_options.cpp src/template_dict.cpp src/template_env.cpp src/template_module.cpp diff --git a/src/template_impl.h b/src/template_impl.h index 94b7e2c05..c6574dd3f 100644 --- a/src/template_impl.h +++ b/src/template_impl.h @@ -440,20 +440,10 @@ class TemplateImpl : public ITemplateImpl // The same with the public options: their globals, lent for the render and kept by any value a // host keeps (LendGlobal), and their poll + // Defined in template_render_options.cpp, so that the RenderCore with hooks is not compiled in template.cpp: + // there it changed what GCC inlines into the ordinary render's ~RenderContext (+14 instructions a render) template - std::optional> RenderWithOptions(Target& os, const ValuesMap& params, const RenderOptions& options) const - { - RenderHooks hooks; - if (options.globals) - { - hooks.hostGlobals = &options.globals; - } - if (options.poll) - { - hooks.poll = &options.poll; - } - return RenderCore(os, params, &hooks); - } + std::optional> RenderWithOptions(Target& os, const ValuesMap& params, const RenderOptions& options) const; // Renders with the params of a ValuesMap or a GenericMap, appending to a string or writing to a // host's sink. Rendering reads the template only, so several threads may render one template at once. diff --git a/src/template_parser.h b/src/template_parser.h index 7364325af..2262e80af 100644 --- a/src/template_parser.h +++ b/src/template_parser.h @@ -628,7 +628,7 @@ class TemplateParser final : public LexerHelper using ParseResult = nonstd::expected, std::vector>; // The tree goes to `nodes` - TemplateParser(const string_t* tpl, const detail::LoadSettings& setts, TemplateEnv* env, const std::string& tplName JINJA2CPP_LIFETIMEBOUND, NodeArena& nodes) + JINJA2CPP_HOT_INLINE TemplateParser(const string_t* tpl, const detail::LoadSettings& setts, TemplateEnv* env, const std::string& tplName JINJA2CPP_LIFETIMEBOUND, NodeArena& nodes) : m_template(tpl) , m_templateName(tplName) , m_settings(setts.settings) diff --git a/src/template_render_options.cpp b/src/template_render_options.cpp new file mode 100644 index 000000000..212c4d62f --- /dev/null +++ b/src/template_render_options.cpp @@ -0,0 +1,37 @@ +#include "template_impl.h" + +#include +#include +#include + +#include +#include + +namespace jinja2 +{ +inline namespace v2 +{ +// Template::RenderTo with RenderOptions (docs/tasks/0357 B3). Kept out of template.cpp: compiled there, the +// RenderCore with hooks made GCC outline part of the ordinary render's ~RenderContext (+14 instructions a render) +template +template +std::optional> TemplateImpl::RenderWithOptions(Target& os, const ValuesMap& params, const RenderOptions& options) const +{ + RenderHooks hooks; + if (options.globals) + { + hooks.hostGlobals = &options.globals; + } + if (options.poll) + { + hooks.poll = &options.poll; + } + return RenderCore(os, params, &hooks); +} + +template std::optional> TemplateImpl::RenderWithOptions(std::string&, const ValuesMap&, const RenderOptions&) const; +template std::optional> TemplateImpl::RenderWithOptions(BasicOutputSink&, const ValuesMap&, const RenderOptions&) const; +template std::optional> TemplateImpl::RenderWithOptions(std::wstring&, const ValuesMap&, const RenderOptions&) const; +template std::optional> TemplateImpl::RenderWithOptions(BasicOutputSink&, const ValuesMap&, const RenderOptions&) const; +} // namespace v2 +} // namespace jinja2 From b45f388e8e6ede8932109c229adeb02faf3af1d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 16:47:21 +0000 Subject: [PATCH 11/11] Drop an include clang-tidy reports unused Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWNL7n7c5XUg2Xoo4ZRjLn --- src/template_render_options.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/template_render_options.cpp b/src/template_render_options.cpp index 212c4d62f..961f422b0 100644 --- a/src/template_render_options.cpp +++ b/src/template_render_options.cpp @@ -5,7 +5,6 @@ #include #include -#include namespace jinja2 {