validate.go

220 lines
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220
package application

import (
	"fmt"
	"io/fs"
	"reflect"
	"strings"
	"text/template/parse"
)

// builtins are text/template's predeclared functions. They are always valid call
// targets, so the validator must not flag them.
var builtins = map[string]bool{
	"and": true, "call": true, "html": true, "index": true, "slice": true,
	"js": true, "len": true, "not": true, "or": true, "print": true,
	"printf": true, "println": true, "urlquery": true,
	"eq": true, "ge": true, "gt": true, "le": true, "lt": true, "ne": true,
}

// Validate walks every template — layouts, partials, and view files — and reports
// references that cannot resolve: a method or field on a controller accessor that
// the controller does not have, or a template that failed to parse (for example
// one still calling {{gone.Method}} after its controller was deleted).
//
// It is deliberately conservative. It only reports a reference it can prove is
// broken and stays silent on anything whose type it cannot determine — notably
// raw {{.Field}} access on render data, whose type is not known until execution.
// That makes a non-empty result trustworthy: it means a genuinely broken
// template, which is why New treats it as fatal in production, and why this is
// exported so a test can assert zero issues and gate a bad build in CI too.
func (app *App) Validate() []error {
	known := app.knownFuncs()
	issues := append([]error(nil), app.parseErrors...)

	// Layouts and partials are parsed into the base at boot.
	for _, t := range app.base.Templates() {
		issues = append(issues, validateTree(app, t.Name(), t.Tree, known)...)
	}
	// View files are parsed on demand at render time; parse each now so the
	// validator sees the templates users actually hit.
	for _, name := range app.viewFiles() {
		tmpl, err := app.loadView(name)
		if err != nil {
			issues = append(issues, fmt.Errorf("view %q: %w", name, err))
			continue
		}
		if t := tmpl.Lookup(name); t != nil {
			issues = append(issues, validateTree(app, name, t.Tree, known)...)
		}
	}
	return issues
}

// viewFiles lists the standalone view files under views/ — everything that is not
// a layout, partial, or static asset, addressed by its path relative to views/.
func (ve *ViewEngine) viewFiles() []string {
	var files []string
	fs.WalkDir(ve.fs, "views", func(path string, d fs.DirEntry, err error) error {
		if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") {
			return nil
		}
		rel := strings.TrimPrefix(path, "views/")
		if strings.HasPrefix(rel, "layouts/") || strings.HasPrefix(rel, "partials/") || strings.HasPrefix(rel, "static/") {
			return nil
		}
		files = append(files, rel)
		return nil
	})
	return files
}

// knownFuncs is every name valid in call position: the template builtins, the
// framework-injected funcs, every user-registered func, and every controller
// (each is a niladic accessor func in a template).
func (app *App) knownFuncs() map[string]bool {
	known := map[string]bool{"dict": true, "nonce": true, "frontend_script": true}
	for name := range builtins {
		known[name] = true
	}
	for name := range app.funcs {
		known[name] = true
	}
	for name := range app.controllers {
		known[name] = true
	}
	return known
}

// validateTree walks one parse tree and returns the references it can prove broken.
func validateTree(app *App, name string, tree *parse.Tree, known map[string]bool) []error {
	if tree == nil || tree.Root == nil {
		return nil
	}
	v := &viewValidator{app: app, known: known, tmpl: name}
	v.walk(tree.Root)
	return v.issues
}

type viewValidator struct {
	app    *App
	known  map[string]bool
	tmpl   string
	issues []error
}

func (v *viewValidator) errf(format string, args ...any) {
	v.issues = append(v.issues, fmt.Errorf("template %q: "+format, append([]any{v.tmpl}, args...)...))
}

// walk recurses the parse tree, validating every pipeline it finds.
func (v *viewValidator) walk(node parse.Node) {
	switch n := node.(type) {
	case *parse.ListNode:
		if n == nil {
			return
		}
		for _, child := range n.Nodes {
			v.walk(child)
		}
	case *parse.ActionNode:
		v.pipe(n.Pipe)
	case *parse.IfNode:
		v.pipe(n.Pipe)
		v.walk(n.List)
		v.walk(n.ElseList)
	case *parse.RangeNode:
		v.pipe(n.Pipe)
		v.walk(n.List)
		v.walk(n.ElseList)
	case *parse.WithNode:
		v.pipe(n.Pipe)
		v.walk(n.List)
		v.walk(n.ElseList)
	case *parse.TemplateNode:
		v.pipe(n.Pipe)
	}
}

// pipe validates every command in a pipeline.
func (v *viewValidator) pipe(p *parse.PipeNode) {
	if p == nil {
		return
	}
	for _, cmd := range p.Cmds {
		for _, arg := range cmd.Args {
			v.arg(arg)
		}
	}
}

// arg validates a single command argument. It only ever reports a reference it
// can prove wrong; unknown shapes are left alone.
func (v *viewValidator) arg(node parse.Node) {
	switch n := node.(type) {
	case *parse.IdentifierNode:
		if !v.known[n.Ident] {
			v.errf("calls unknown function %q", n.Ident)
		}
	case *parse.ChainNode:
		v.chain(n)
	case *parse.PipeNode:
		v.pipe(n)
	}
}

// chain validates X.Field.Field... where X is a term. It only checks chains
// rooted at a controller accessor — the {{auth.CurrentUser}} pattern — because
// those are the ones whose types are known. Anything else it leaves alone.
func (v *viewValidator) chain(n *parse.ChainNode) {
	ident, ok := n.Node.(*parse.IdentifierNode)
	if !ok {
		return // rooted at a variable, dot, or paren expr — not our concern
	}
	if !v.known[ident.Ident] {
		v.errf("calls unknown function %q", ident.Ident)
		return
	}
	ctrl, ok := v.app.controllers[ident.Ident]
	if !ok {
		return // a known func, but not a controller — cannot introspect its result
	}
	t := reflect.TypeOf(ctrl)
	for _, field := range n.Field {
		next, ok := memberType(t, field)
		if !ok {
			v.errf("%s has no method or field %q", ident.Ident, field)
			return
		}
		if next == nil {
			return // resolved, but result not introspectable — stop checking
		}
		t = next
	}
}

// memberType resolves a method or field named member on type t. It returns the
// result type (for chaining) and whether the member exists. A nil type with ok
// true means "exists, but not worth introspecting further" (a method with no
// usable return, or a non-struct result), which stops chain validation cleanly.
func memberType(t reflect.Type, member string) (reflect.Type, bool) {
	if t == nil {
		return nil, false
	}
	if m, ok := t.MethodByName(member); ok {
		if m.Type.NumOut() == 0 {
			return nil, true
		}
		return m.Type.Out(0), true
	}
	st := t
	if st.Kind() == reflect.Pointer {
		st = st.Elem()
	}
	if st.Kind() == reflect.Struct {
		if f, ok := st.FieldByName(member); ok {
			return f.Type, true
		}
	}
	return nil, false
}