mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-31 00:12:37 +00:00
feat: bound template execution in tmplfuncs by refusing call, capping printf/print/println/html/js/urlquery/join output, metering range steps, limiting variables to 64 and function arguments to 32, adding a context-escaping CompileHTML, and make the integration fallback a single non-rescanning pass under the same output cap
This commit is contained in:
@@ -140,7 +140,7 @@ New reply from {{.contact_email}} on campaign {{.campaign_id}} ({{.intent}})
|
||||
| Warmup health changed | `email`, `new_state`, `previous_state`, `reason` |
|
||||
| Campaign action | `contact_email`, `contact_id`, `campaign_id`, `campaign_name`, `first_name`, `last_name`, `company`, `phone` |
|
||||
|
||||
Action text is a **full template, not token substitution**: conditionals, pipelines, helpers, `{{range}}`, and nested fields (`{{.lead.company}}`) all work, and values keep their type. Because numbers stay numbers, `{{if gt .confidence 0.8}}` works directly in a message body. A malformed template never blocks the action; it falls back to plain `{{.key}}` substitution. Unknown keys render empty. `{{range}}` loops over a field of the data (`{{range .lead.tags}}`), never a number or a function's result, and nests at most two deep; `{{template}}` and `{{block}}` are not supported, and output is capped at 1 MB. A template past any of these limits falls back the same way.
|
||||
Action text is a **full template, not token substitution**: conditionals, pipelines, helpers, `{{range}}`, and nested fields (`{{.lead.company}}`) all work, and values keep their type. Because numbers stay numbers, `{{if gt .confidence 0.8}}` works directly in a message body. A malformed template never blocks the action; it falls back to plain `{{.key}}` substitution. Unknown keys render empty. `{{range}}` loops over a field of the data (`{{range .lead.tags}}`), never a number or a function's result, and nests at most two deep; `{{template}}`, `{{block}}` and `call` are not supported, a template declares at most 64 variables and a function takes at most 32 arguments, and output (fallback included) is capped at 1 MB. A template past any of these limits falls back the same way.
|
||||
|
||||
</Fold>
|
||||
|
||||
|
||||
@@ -144,22 +144,32 @@ func compileTemplate(tmpl string) *template.Template {
|
||||
|
||||
// naiveRenderTemplate is a literal {{.key}} substitution (a leading dot is
|
||||
// optional here), used as a safe fallback when a template can't compile/execute
|
||||
// so a single malformed block never blanks the whole value.
|
||||
// so a single malformed block never blanks the whole value. One left-to-right
|
||||
// pass: a substituted value is never rescanned, and values share MaxOutput.
|
||||
func naiveRenderTemplate(tmpl string, data map[string]any) string {
|
||||
out := tmpl
|
||||
var b strings.Builder
|
||||
budget := tmplfuncs.MaxOutput
|
||||
rest := tmpl
|
||||
for {
|
||||
start := strings.Index(out, "{{")
|
||||
start := strings.Index(rest, "{{")
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(out[start:], "}}")
|
||||
end := strings.Index(rest[start:], "}}")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
end += start
|
||||
key := strings.TrimSpace(out[start+2 : end])
|
||||
key = strings.TrimPrefix(key, ".")
|
||||
out = out[:start] + stringFromMap(data, key) + out[end+2:]
|
||||
key := strings.TrimPrefix(strings.TrimSpace(rest[start+2:end]), ".")
|
||||
value := stringFromMap(data, key)
|
||||
if len(value) > budget {
|
||||
value = ""
|
||||
}
|
||||
budget -= len(value)
|
||||
b.WriteString(rest[:start])
|
||||
b.WriteString(value)
|
||||
rest = rest[end+2:]
|
||||
}
|
||||
return strings.TrimSpace(out)
|
||||
b.WriteString(rest)
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
@@ -21,3 +21,10 @@ func TestRenderTemplateRefusesRangeOverNumber(t *testing.T) {
|
||||
t.Fatal("ValidExpression accepted a range over a number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNaiveRenderNeverExpandsSubstitutedValues(t *testing.T) {
|
||||
data := map[string]any{"a": "{{.a}}{{.a}}", "b": "B"}
|
||||
if got := naiveRenderTemplate(`{{if}} {{.a}}|{{b}}`, data); got != "{{.a}}{{.a}}|B" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package tmplfuncs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
var (
|
||||
errCallRefused = errors.New("call is not supported")
|
||||
errPrintfForm = errors.New("printf widths and argument indexes must be literal")
|
||||
)
|
||||
|
||||
// argSize is the printed length of a value, read without printing a string.
|
||||
func argSize(v any) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
if s, ok := v.([]string); ok {
|
||||
n := 2
|
||||
for _, x := range s {
|
||||
n += len(x) + 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
if rv := reflect.ValueOf(v); rv.Kind() == reflect.String {
|
||||
return rv.Len()
|
||||
}
|
||||
return len(fmt.Sprint(v))
|
||||
}
|
||||
|
||||
func argsSize(args []any) int {
|
||||
n := 0
|
||||
for _, a := range args {
|
||||
n += argSize(a) + 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// bounded refuses a result that would pass MaxOutput, before building it.
|
||||
func bounded(size int, build func() string) (string, error) {
|
||||
if size > MaxOutput {
|
||||
return "", ErrOutputLimit
|
||||
}
|
||||
out := build()
|
||||
if len(out) > MaxOutput {
|
||||
return "", ErrOutputLimit
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// literalNumber reads the digits at format[i:], capped past MaxOutput.
|
||||
func literalNumber(format string, i int) (int, int) {
|
||||
n := 0
|
||||
for i < len(format) && format[i] >= '0' && format[i] <= '9' {
|
||||
n = min(n*10+int(format[i]-'0'), MaxOutput+1)
|
||||
i++
|
||||
}
|
||||
return n, i
|
||||
}
|
||||
|
||||
// printfSize is an upper bound on what fmt.Sprintf(format, args...) prints. It
|
||||
// refuses * widths and explicit argument indexes, whose cost it cannot see.
|
||||
func printfSize(format string, args []any) (int, error) {
|
||||
widest := 0
|
||||
for _, a := range args {
|
||||
widest = max(widest, argSize(a))
|
||||
}
|
||||
total := len(format) + argsSize(args)
|
||||
for i := 0; i < len(format); i++ {
|
||||
if format[i] != '%' {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
for i < len(format) && strings.IndexByte("+-# 0", format[i]) >= 0 {
|
||||
i++
|
||||
}
|
||||
width, precision := 0, 0
|
||||
width, i = literalNumber(format, i)
|
||||
if i < len(format) && format[i] == '.' {
|
||||
precision, i = literalNumber(format, i+1)
|
||||
}
|
||||
if i < len(format) && (format[i] == '*' || format[i] == '[') {
|
||||
return 0, errPrintfForm
|
||||
}
|
||||
total += width + precision + widest + 32
|
||||
if total > MaxOutput {
|
||||
return 0, ErrOutputLimit
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// boundedBuiltins replace the text/template builtins that can call into the
|
||||
// data or build a string of any size; each refuses a result past MaxOutput.
|
||||
var boundedBuiltins = template.FuncMap{
|
||||
"call": func(...any) (string, error) { return "", errCallRefused },
|
||||
"printf": func(format string, args ...any) (string, error) {
|
||||
size, err := printfSize(format, args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return bounded(size, func() string { return fmt.Sprintf(format, args...) })
|
||||
},
|
||||
"print": func(args ...any) (string, error) {
|
||||
return bounded(argsSize(args), func() string { return fmt.Sprint(args...) })
|
||||
},
|
||||
"println": func(args ...any) (string, error) {
|
||||
return bounded(argsSize(args)+1, func() string { return fmt.Sprintln(args...) })
|
||||
},
|
||||
"html": func(args ...any) (string, error) {
|
||||
return bounded(argsSize(args), func() string { return template.HTMLEscaper(args...) })
|
||||
},
|
||||
"js": func(args ...any) (string, error) {
|
||||
return bounded(argsSize(args), func() string { return template.JSEscaper(args...) })
|
||||
},
|
||||
"urlquery": func(args ...any) (string, error) {
|
||||
return bounded(argsSize(args), func() string { return template.URLQueryEscaper(args...) })
|
||||
},
|
||||
}
|
||||
|
||||
// boundedJoin is strings.Join over a list, refused past MaxOutput.
|
||||
func boundedJoin(list, sep any) (string, error) {
|
||||
items, _ := ListStrings(list)
|
||||
s := valueString(sep)
|
||||
size := len(s) * len(items)
|
||||
for _, x := range items {
|
||||
size += len(x)
|
||||
}
|
||||
return bounded(size, func() string { return strings.Join(items, s) })
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package tmplfuncs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuiltinsAreBounded(t *testing.T) {
|
||||
big := strings.Repeat("x", MaxOutput/2+1)
|
||||
data := map[string]any{
|
||||
"name": "Ann", "big": big, "list": []string{big, big, big},
|
||||
"fn": func() string { return "called" },
|
||||
}
|
||||
for _, src := range []string{
|
||||
`{{call .fn}}`,
|
||||
`{{printf "%2000000s" .name}}`,
|
||||
`{{printf "%.2000000f" 1.5}}`,
|
||||
`{{printf "%*s" 2000000 .name}}`,
|
||||
`{{printf "%[1]s%[1]s%[1]s" .big}}`,
|
||||
`{{printf "%s%s" .big .big}}`,
|
||||
`{{print .big .big}}`,
|
||||
`{{println .big .big}}`,
|
||||
`{{html .big .big}}`,
|
||||
`{{js .big .big}}`,
|
||||
`{{urlquery .big .big}}`,
|
||||
`{{join .list ","}}`,
|
||||
} {
|
||||
for kind, compile := range map[string]func(string, string) (Executor, error){
|
||||
"text": func(n, s string) (Executor, error) { return Compile(n, s) },
|
||||
"html": func(n, s string) (Executor, error) { return CompileHTML(n, s) },
|
||||
} {
|
||||
tmpl, err := compile("t", src)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if out, err := Execute(tmpl, data); err == nil {
|
||||
t.Errorf("%s %s rendered %d bytes, want a refusal", kind, src, len(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinsStillRender(t *testing.T) {
|
||||
data := map[string]any{"name": "Ann & Co", "n": 3, "list": []string{"a", "b"}}
|
||||
for src, want := range map[string]string{
|
||||
`{{printf "%s-%03d" .name .n}}`: "Ann & Co-003",
|
||||
`{{print .name .n}}`: "Ann & Co3",
|
||||
`{{html .name}}`: "Ann & Co",
|
||||
`{{urlquery .name}}`: "Ann+%26+Co",
|
||||
`{{join .list ", "}}`: "a, b",
|
||||
`{{.name | printf "%q"}}`: `"Ann & Co"`,
|
||||
`{{range .list}}{{.}}{{end}}`: "ab",
|
||||
`{{range $i, $v := .list}}{{$i}}{{end}}`: "01",
|
||||
} {
|
||||
tmpl, err := Compile("t", src)
|
||||
if err != nil {
|
||||
t.Fatalf("Compile(%q): %v", src, err)
|
||||
}
|
||||
if out, err := Execute(tmpl, data); err != nil || out != want {
|
||||
t.Errorf("%s = %q, %v; want %q", src, out, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteBoundsRangeSteps(t *testing.T) {
|
||||
const src = `{{range .n}}{{range $.n}}{{end}}{{end}}`
|
||||
text, err := Compile("t", src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
html, err := CompileHTML("t", src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tmpl := range []Executor{text, html} {
|
||||
if _, err := Execute(tmpl, map[string]any{"n": 100000}); !errors.Is(err, ErrStepLimit) {
|
||||
t.Fatalf("err = %v, want ErrStepLimit", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileRefusesTooManyVariablesAndArgs(t *testing.T) {
|
||||
vars := strings.Repeat(`{{$a := 1}}`, MaxVariables+1)
|
||||
args := `{{print` + strings.Repeat(" 1", MaxArgs+1) + `}}`
|
||||
for _, src := range []string{vars, args} {
|
||||
if _, err := Compile("t", src); err == nil {
|
||||
t.Errorf("Compile accepted %.40s...", src)
|
||||
}
|
||||
if _, err := CompileHTML("t", src); err == nil {
|
||||
t.Errorf("CompileHTML accepted %.40s...", src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileHTMLEscapesInContext(t *testing.T) {
|
||||
tmpl, err := CompileHTML("t", `<a href="{{.u}}" title="{{.n}}">{{.n}}</a>`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Execute(tmpl, map[string]any{"u": "javascript:alert(1)", "n": `<b>"x"</b>`})
|
||||
if err != nil || out != `<a href="#ZgotmplZ" title="<b>"x"</b>"><b>"x"</b></a>` {
|
||||
t.Fatalf("got %q, %v", out, err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package tmplfuncs
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
htmltemplate "html/template"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -13,17 +15,31 @@ import (
|
||||
// MaxOutput bounds what one render of a user-authored template may write.
|
||||
const MaxOutput = 1 << 20
|
||||
|
||||
// MaxSteps bounds how many writes one render may make, which bounds range iterations.
|
||||
const MaxSteps = 1 << 18
|
||||
|
||||
// MaxRangeDepth bounds how deeply {{range}} blocks may nest.
|
||||
const MaxRangeDepth = 2
|
||||
|
||||
// MaxVariables bounds how many variables one template may declare.
|
||||
const MaxVariables = 64
|
||||
|
||||
// MaxArgs bounds how many arguments one command may pass.
|
||||
const MaxArgs = 32
|
||||
|
||||
// ErrOutputLimit is returned when a render passes MaxOutput.
|
||||
var ErrOutputLimit = errors.New("template output exceeds the size limit")
|
||||
|
||||
// ErrStepLimit is returned when a render passes MaxSteps.
|
||||
var ErrStepLimit = errors.New("template render exceeds the step limit")
|
||||
|
||||
var (
|
||||
errRangeSource = errors.New("range only iterates over a field, like {{range .items}}")
|
||||
errRangeDepth = fmt.Errorf("range blocks may nest at most %d deep", MaxRangeDepth)
|
||||
errTemplateUse = errors.New("{{template}} and {{block}} are not supported")
|
||||
errRootAssign = errors.New("$ cannot be reassigned")
|
||||
errVariables = fmt.Errorf("a template may declare at most %d variables", MaxVariables)
|
||||
errArgs = fmt.Errorf("a function may take at most %d arguments", MaxArgs)
|
||||
)
|
||||
|
||||
// Compile parses a user-authored template with the shared funcs and absent keys
|
||||
@@ -36,18 +52,51 @@ func Compile(name, src string) (*template.Template, error) {
|
||||
if err := CheckLimits(t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tt := range t.Templates() {
|
||||
meterRanges(tt.Tree)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// CompileHTML is Compile for an HTML document: html/template escapes every
|
||||
// value for the context it lands in, under the same funcs and limits.
|
||||
func CompileHTML(name, src string) (*htmltemplate.Template, error) {
|
||||
t, err := htmltemplate.New(name).Funcs(funcs).Option("missingkey=zero").Parse(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trees := make([]*parse.Tree, 0, 1)
|
||||
for _, tt := range t.Templates() {
|
||||
trees = append(trees, tt.Tree)
|
||||
}
|
||||
if err := checkTrees(trees); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tree := range trees {
|
||||
meterRanges(tree)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// CheckLimits refuses constructs whose cost is not bounded by the data: a
|
||||
// {{range}} over anything but a field of the data, {{range}} nested past
|
||||
// MaxRangeDepth, {{template}} calls, and reassigning $.
|
||||
// MaxRangeDepth, {{template}} calls, reassigning $, more than MaxVariables
|
||||
// declarations and commands with more than MaxArgs arguments.
|
||||
func CheckLimits(t *template.Template) error {
|
||||
trees := make([]*parse.Tree, 0, 1)
|
||||
for _, tt := range t.Templates() {
|
||||
if tt.Tree == nil || tt.Root == nil {
|
||||
trees = append(trees, tt.Tree)
|
||||
}
|
||||
return checkTrees(trees)
|
||||
}
|
||||
|
||||
func checkTrees(trees []*parse.Tree) error {
|
||||
vars := 0
|
||||
for _, tree := range trees {
|
||||
if tree == nil || tree.Root == nil {
|
||||
continue
|
||||
}
|
||||
if err := checkNode(tt.Root, 0, true); err != nil {
|
||||
if err := checkNode(tree.Root, 0, true, &vars); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -55,21 +104,21 @@ func CheckLimits(t *template.Template) error {
|
||||
}
|
||||
|
||||
// checkNode walks n; dotIsData is whether dot can only hold a value from the data.
|
||||
func checkNode(n parse.Node, depth int, dotIsData bool) error {
|
||||
func checkNode(n parse.Node, depth int, dotIsData bool, vars *int) error {
|
||||
switch x := n.(type) {
|
||||
case *parse.ListNode:
|
||||
if x == nil {
|
||||
return nil
|
||||
}
|
||||
for _, c := range x.Nodes {
|
||||
if err := checkNode(c, depth, dotIsData); err != nil {
|
||||
if err := checkNode(c, depth, dotIsData, vars); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *parse.ActionNode:
|
||||
return checkPipe(x.Pipe)
|
||||
return checkPipe(x.Pipe, vars)
|
||||
case *parse.RangeNode:
|
||||
if err := checkPipe(x.Pipe); err != nil {
|
||||
if err := checkPipe(x.Pipe, vars); err != nil {
|
||||
return err
|
||||
}
|
||||
if depth+1 > MaxRangeDepth {
|
||||
@@ -78,34 +127,35 @@ func checkNode(n parse.Node, depth int, dotIsData bool) error {
|
||||
if !isDataPath(x.Pipe, dotIsData) {
|
||||
return errRangeSource
|
||||
}
|
||||
if err := checkNode(x.List, depth+1, true); err != nil {
|
||||
if err := checkNode(x.List, depth+1, true, vars); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkNode(x.ElseList, depth, dotIsData)
|
||||
return checkNode(x.ElseList, depth, dotIsData, vars)
|
||||
case *parse.IfNode:
|
||||
if err := checkPipe(x.Pipe); err != nil {
|
||||
if err := checkPipe(x.Pipe, vars); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkNode(x.List, depth, dotIsData); err != nil {
|
||||
if err := checkNode(x.List, depth, dotIsData, vars); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkNode(x.ElseList, depth, dotIsData)
|
||||
return checkNode(x.ElseList, depth, dotIsData, vars)
|
||||
case *parse.WithNode:
|
||||
if err := checkPipe(x.Pipe); err != nil {
|
||||
if err := checkPipe(x.Pipe, vars); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkNode(x.List, depth, isDataPath(x.Pipe, dotIsData)); err != nil {
|
||||
if err := checkNode(x.List, depth, isDataPath(x.Pipe, dotIsData), vars); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkNode(x.ElseList, depth, dotIsData)
|
||||
return checkNode(x.ElseList, depth, dotIsData, vars)
|
||||
case *parse.TemplateNode:
|
||||
return errTemplateUse
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPipe refuses a pipeline that declares or assigns $, the data root.
|
||||
func checkPipe(p *parse.PipeNode) error {
|
||||
// checkPipe refuses a pipeline that declares or assigns $, declares past
|
||||
// MaxVariables, or passes a command more than MaxArgs arguments.
|
||||
func checkPipe(p *parse.PipeNode, vars *int) error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -113,11 +163,20 @@ func checkPipe(p *parse.PipeNode) error {
|
||||
if len(v.Ident) > 0 && v.Ident[0] == "$" {
|
||||
return errRootAssign
|
||||
}
|
||||
if !p.IsAssign {
|
||||
*vars++
|
||||
}
|
||||
}
|
||||
if *vars > MaxVariables {
|
||||
return errVariables
|
||||
}
|
||||
for _, c := range p.Cmds {
|
||||
if len(c.Args) > MaxArgs+1 {
|
||||
return errArgs
|
||||
}
|
||||
for _, a := range c.Args {
|
||||
if sub, ok := a.(*parse.PipeNode); ok {
|
||||
if err := checkPipe(sub); err != nil {
|
||||
if err := checkPipe(sub, vars); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -148,9 +207,64 @@ func isPathNode(n parse.Node, dotIsData bool) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Execute renders t into a string, failing once the output passes MaxOutput.
|
||||
func Execute(t *template.Template, data any) (string, error) {
|
||||
w := &cappedWriter{max: MaxOutput}
|
||||
// meterRanges opens every range body with an empty write, so each iteration
|
||||
// counts against MaxSteps even when the body writes nothing.
|
||||
func meterRanges(tree *parse.Tree) {
|
||||
if tree == nil {
|
||||
return
|
||||
}
|
||||
var walk func(n parse.Node)
|
||||
walk = func(n parse.Node) {
|
||||
switch x := n.(type) {
|
||||
case *parse.ListNode:
|
||||
if x == nil {
|
||||
return
|
||||
}
|
||||
for _, c := range x.Nodes {
|
||||
walk(c)
|
||||
}
|
||||
case *parse.RangeNode:
|
||||
walk(x.List)
|
||||
walk(x.ElseList)
|
||||
if x.List != nil {
|
||||
x.List.Nodes = append([]parse.Node{emptyText()}, x.List.Nodes...)
|
||||
}
|
||||
case *parse.IfNode:
|
||||
walk(x.List)
|
||||
walk(x.ElseList)
|
||||
case *parse.WithNode:
|
||||
walk(x.List)
|
||||
walk(x.ElseList)
|
||||
}
|
||||
}
|
||||
walk(tree.Root)
|
||||
}
|
||||
|
||||
// emptyText returns a text node owned by a parsed tree, so copying it is safe.
|
||||
func emptyText() parse.Node {
|
||||
trees, err := parse.Parse("step", " ", "", "")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
n := trees["step"].Root.Nodes[0].(*parse.TextNode)
|
||||
n.Text = n.Text[:0]
|
||||
return n
|
||||
}
|
||||
|
||||
// Executor is a compiled text or HTML template.
|
||||
type Executor interface {
|
||||
Execute(w io.Writer, data any) error
|
||||
}
|
||||
|
||||
// Execute renders t into a string, failing once the output passes MaxOutput
|
||||
// or the render passes MaxSteps.
|
||||
func Execute(t Executor, data any) (string, error) {
|
||||
return ExecuteLimit(t, data, MaxOutput)
|
||||
}
|
||||
|
||||
// ExecuteLimit is Execute with a smaller output budget, for callers sharing one.
|
||||
func ExecuteLimit(t Executor, data any, limit int) (string, error) {
|
||||
w := &cappedWriter{max: min(limit, MaxOutput)}
|
||||
if err := t.Execute(w, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -158,39 +272,53 @@ func Execute(t *template.Template, data any) (string, error) {
|
||||
}
|
||||
|
||||
type cappedWriter struct {
|
||||
b strings.Builder
|
||||
max int
|
||||
b strings.Builder
|
||||
max int
|
||||
steps int
|
||||
}
|
||||
|
||||
func (w *cappedWriter) Write(p []byte) (int, error) {
|
||||
w.steps++
|
||||
if w.steps > MaxSteps {
|
||||
return 0, ErrStepLimit
|
||||
}
|
||||
if w.b.Len()+len(p) > w.max {
|
||||
return 0, ErrOutputLimit
|
||||
}
|
||||
return w.b.Write(p)
|
||||
}
|
||||
|
||||
// IsLimit reports whether err is a render passing one of the limits.
|
||||
func IsLimit(err error) bool {
|
||||
return errors.Is(err, ErrOutputLimit) || errors.Is(err, ErrStepLimit)
|
||||
}
|
||||
|
||||
// CacheCap bounds how many compiled templates a Cache keeps.
|
||||
const CacheCap = 4096
|
||||
|
||||
// Cache maps a template source to its compiled form, nil marking a source
|
||||
// Cache maps a template source to its compiled text template.
|
||||
type Cache = TypedCache[*template.Template]
|
||||
|
||||
// TypedCache maps a template source to its compiled form, nil marking a source
|
||||
// that failed to compile. Past CacheCap a miss is recompiled, not stored.
|
||||
type Cache struct {
|
||||
type TypedCache[T any] struct {
|
||||
m sync.Map
|
||||
n atomic.Int64
|
||||
}
|
||||
|
||||
// Load returns the cached template for src and whether src was cached.
|
||||
func (c *Cache) Load(src string) (*template.Template, bool) {
|
||||
func (c *TypedCache[T]) Load(src string) (T, bool) {
|
||||
v, ok := c.m.Load(src)
|
||||
if !ok {
|
||||
return nil, false
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
t, _ := v.(*template.Template)
|
||||
t, _ := v.(T)
|
||||
return t, true
|
||||
}
|
||||
|
||||
// Store caches t (nil for a known-bad source) unless the cache is full.
|
||||
func (c *Cache) Store(src string, t *template.Template) {
|
||||
func (c *TypedCache[T]) Store(src string, t T) {
|
||||
if c.n.Load() >= CacheCap {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,11 +110,8 @@ var funcs = template.FuncMap{
|
||||
},
|
||||
// List helpers: {{has .ai_labels "Meeting"}} is whole-item membership, so
|
||||
// "Interested" does not match "Not interested"; {{join .ai_labels ", "}}.
|
||||
"has": ListHas,
|
||||
"join": func(list, sep any) string {
|
||||
items, _ := ListStrings(list)
|
||||
return strings.Join(items, valueString(sep))
|
||||
},
|
||||
"has": ListHas,
|
||||
"join": boundedJoin,
|
||||
"hasPrefix": func(s, p any) bool { return strings.HasPrefix(valueString(s), valueString(p)) },
|
||||
"hasSuffix": func(s, p any) bool { return strings.HasSuffix(valueString(s), valueString(p)) },
|
||||
// Fallback: {{.FirstName | default "there"}} -> "there" when the field is blank.
|
||||
@@ -126,6 +123,12 @@ var funcs = template.FuncMap{
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
for name, fn := range boundedBuiltins {
|
||||
funcs[name] = fn
|
||||
}
|
||||
}
|
||||
|
||||
// FuncMap returns the shared template function set. The returned map is shared
|
||||
// (the funcs are stateless), so callers must not mutate it.
|
||||
func FuncMap() template.FuncMap {
|
||||
|
||||
Reference in New Issue
Block a user