Repository navigation
fix(templates): collect the filters a render reaches as a least fixpoint over cycles - #286
Merged
Merged
Conversation
…int over cycles The walk collecting the filters a render reaches cut a cycle of includes at a template already on the way (#284). Django renders such a cycle as far as the project's conditions let it turn, so the walk missed what the next levels apply: a template including itself with other bindings, a recursive tree, or a parent including the child that extends it, where Django applies the parent's filters to what the include binds. A cut walk was also walked again on every path: 20 levels of doubled includes closed into a cycle took 56 s. What each template reaches, with the blocks the templates extending it override, is now the least fixpoint of what it applies and what its references reach mapped through their bindings. The pairs a render reaches are grouped into strongly connected components, each computed after those its references reach. In a component, every set starts empty and a pair is recomputed when a set it reads grows, until none grows: the sets only grow, in a finite domain, so this ends. The lists are built from the settled sets only. A pair outside any cycle keeps the order of a walk of each path, so where no cycle is reached nothing changes; a pair in a cycle lists what it applies, then the rest in a canonical order. The order does not depend on the order of the computation. The two tests that pinned the cut walk now expect what Django applies, and the comparison with the walk path by path lets each cycle turn until it settles.
…ings, and trees Django applies the filters of each level of the recursion to what the include binds: v, then n0 for a template including itself with v=n0, and the names of node.children for a tree including itself for each child.
…of includes A cycle through 60 levels of doubled includes, and 40 templates each including every other one, are collected in a subprocess bounded at 60 s. Cut at every template on the way, the former walk took a minute for 20 levels.
…of computation On 500 generated projects of templates extending and including one another, with cycles among them, what each pair reaches is what it applies with what its references reach mapped through them, and the lists are the same, order included, whatever order the templates are inserted in.
…cycle Each combination of the names reaching a filter's value and argument was its own fact. Bindings permuted around a cycle of includes made one fact of every combination: a 272-byte template including itself twice, rotating 14 variables and swapping two of them, gave 60,762 filters and took 12 s to collect, where main takes a millisecond. In a cycle, the names reaching the value and the argument of each filter application are now joined: one fact per application. The analysis reads those names position by position, joining what each one brings, so the join loses nothing. The names of an application only grow, at most once per name, so a cycle settles in time polynomial in its applications and names. Outside cycles, nothing changes. The tests of recursion and of the tree expect one call per filter, the fixpoint is checked by application in cycles, and the comparison with the walk path by path compares the names joined at each application.
…f includes A filter chain over seven variables, in a template including itself twice, rotating 24 variables and swapping two of them, is collected in a subprocess bounded at 60 s, as 7 filters.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #284.
To collect the filters a render reaches,
project_templateswalked each include and extends, and cut a cycle at a template already on the way. Two consequences followed.Filters missed. Django renders a cycle as far as the project's conditions let it turn, so the walk missed what the next levels apply:
Time. A walk the cycle cut was walked again on every path. 20 levels of doubled includes closed into a cycle took 56 s.
What each template reaches, with the blocks the templates extending it override, is now the least fixpoint of what it applies and what its references reach, mapped through their bindings. The design and its measurements are in the issue.
Computation.
Guarantees.
Convergence: the sets only grow, in a finite domain. The result is the least fixpoint, whatever the order of the recomputations.
No partial result: no list is built before its component has settled, and no cycle is cut.
Order:
The order depends only on the settled sets and the templates, not on the order of the computation.
_reaching,_Walkand the cache of #279 are replaced.fix(templates): collect the filters a render reaches as a least fixpoint over cyclestest(templates): cover templates including themselves with other bindings, and treestest(templates): bound the time of collecting filters through cycles of includestest(templates): check the filters are a fixpoint whatever the order of computationdocs: state that a cycle of includes passes its bindings at every leveldocs/usage.mddocs(changelog): announce the filters reached through cycles of includesCHANGELOG.mdfix(templates): join the names reaching each filter application in a cycletest(templates): bound the time of bindings permuted around a cycle of includesReview
Each combination of the names reaching a filter's value and argument was its own fact. Bindings permuted around a cycle therefore made one fact of every combination. The review's template is 272 bytes: a filter chain over
v0tov6, including itself twice, once rotating 14 variables and once swappingv0andv1.mainad4dd04The join. In a cycle, the names reaching the value, and those reaching the argument, are now joined for each filter application: one fact per application.
No bound cuts the computation short: the result is still the complete least fixpoint, joined by application in cycles.
Tests adapted.
The permuted shape, with 24 variables, is a new test of time. On
ad4dd04, it exceeds 60 s, and the recursion, tree and fixpoint tests fail.Mutations. Each of these makes tests fail:
The design comment of #284 is updated with the join.
Results
Django. The bench generates 1,500 projects of four templates extending and including one another, with cycles. Django 5.1.15 renders each template, its filters recording the value they receive.
main0, this change 0main467, this change 0The new tests expect what Django applies when it renders their templates.
Independent reference. It walks every path, letting each cycle turn 3, then 4 times.
Closure. On every pair of the 1,500 projects, the result is what the pair applies, with what its references reach mapped through them.
Order of computation. The lists are the same, order included, with the templates inserted in three other orders: 4,500 runs.
Against
main.Time.
project_templatesalone, Python 3.12:mainEnd to end. The reproduction of #284 reports
exploitable-vulnerabilityatapp/views.py:5, throughb.html:1, whether the view passes the request asvor asother.Tests
Existing tests changed. Both pinned the cut walk, in
tests/test_template_filter_walk.py, 3 cases. The module's docstring follows.test_a_child_its_parent_includes_back_gets_the_same_filters_whichever_is_walked_first, both cases. The parent's list gainsstriptagsreached byother, last: Django rendersb.htmlapplyingstriptagstoother. The comment describing the case changes accordingly.test_the_filters_are_those_of_every_path_with_each_cycle_turned_until_it_settles. Its reference lets each cycle turn 2, then 3 times, within 20,000 steps.New file.
tests/test_template_filter_cycles.pyholds 7 tests, 8 cases:other, and asvfor the control.v=n0, one call ofupperreached byvandn0.upperreached bynode.nameandnode.children.Before the fix. On
main, 9 tests fail:[]instead of a finding.n0andnode.childrenare missing._rendersdoes not exist yet.striptagsreached byotheris missing, and the sets differ from the reference.The control case passes on
main.Mutations. Each of these makes at least one test fail:
Verification
Python 3.12, on
mainat926df83, in an environment installed as the CI installs it (pip install -e ".[dev]"):ruff check .andmypy: clean on each commit;Out of scope
The walk counts a block as rendered when a template extending another defines it and no parent does. Django never renders such a block. That over-approximation is #285; this change leaves it as it is.