mirror of
https://github.com/JanDeDobbeleer/oh-my-posh.git
synced 2026-08-24 10:14:12 -05:00
fix(template): gate cmd/readFile/stat/glob behind trusted templates
template.Render's func map exposed cmd/readFile/stat/glob/env/expandenv to any string passed as the template argument, with no way to tell a template the user authored in their config from one built at runtime out of external data (filesystem names, command output, ...). That distinction is exactly what let a malicious folder name reach cmd (fixed for that one call site in a prior commit); nothing stopped the same class of bug from reappearing at a future call site. Split Render into two explicitly named functions instead: RenderTrusted keeps the full func map and is for template text read verbatim from a config field (segment/block/palette templates, mapped_locations keys, folder_separator_template, ...) — every existing call site converts to it. RenderUntrusted drops cmd/readFile/stat/glob/env/expandenv and is for text that may contain or be composed from runtime data; pt.Path in path.go's setStyle(), the one sink that re-renders a string composed from raw filesystem folder names, uses it. Neither name is shorter or more "default" than the other, so there's no ambient plain Render a future call site could reach for without first deciding which one it means. The parsed-template cache key includes the trust level so a trusted and an untrusted render of identical text can never share a cached *template.Template and its func map. Entire-Checkpoint: 597e5a60d968
This commit is contained in:
committed by
Jan De Dobbeleer
parent
7ec9fb8eb0
commit
88ddbe0b0a
+1
-1
@@ -160,7 +160,7 @@ func (c Ansi) ResolveTemplate() Ansi {
|
||||
return emptyColor
|
||||
}
|
||||
|
||||
text, err := template.Render(string(c), nil)
|
||||
text, err := template.RenderTrusted(string(c), nil)
|
||||
if err != nil {
|
||||
return Transparent
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func (p Palette) resolveColor(colorName Ansi, depth int, originalColorName *Ansi
|
||||
}
|
||||
|
||||
if strings.Contains(color.String(), "{{") {
|
||||
rendered, err := template.Render(color.String(), nil)
|
||||
rendered, err := template.RenderTrusted(color.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (cfg *Config) getPalette() color.Palette {
|
||||
return cfg.Palette
|
||||
}
|
||||
|
||||
key, err := template.Render(cfg.Palettes.Template, nil)
|
||||
key, err := template.RenderTrusted(cfg.Palettes.Template, nil)
|
||||
if err != nil {
|
||||
return cfg.Palette
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
type SegmentStyle string
|
||||
|
||||
func (s *SegmentStyle) resolve(context any) SegmentStyle {
|
||||
value, err := template.Render(string(*s), context)
|
||||
value, err := template.RenderTrusted(string(*s), context)
|
||||
|
||||
// default to Plain
|
||||
if err != nil || value == "" {
|
||||
@@ -301,7 +301,7 @@ func (segment *Segment) renderFallback(index int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
text, err := template.Render(segment.FallbackTemplate, segment.writer)
|
||||
text, err := template.RenderTrusted(segment.FallbackTemplate, segment.writer)
|
||||
if err != nil {
|
||||
text = err.Error()
|
||||
}
|
||||
@@ -580,7 +580,7 @@ func (segment *Segment) string() string {
|
||||
segment.Template = segment.writer.Template()
|
||||
}
|
||||
|
||||
text, err := template.Render(segment.Template, segment.writer)
|
||||
text, err := template.RenderTrusted(segment.Template, segment.writer)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func (e *Engine) pwd() {
|
||||
}
|
||||
|
||||
// Allow template logic to define when to enable the PWD (when supported)
|
||||
pwdType, err := template.Render(e.Config.PWD, nil)
|
||||
pwdType, err := template.RenderTrusted(e.Config.PWD, nil)
|
||||
if err != nil || pwdType == "" {
|
||||
return
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func (e *Engine) shouldFill(filler string, padLength int) (string, bool) {
|
||||
}()
|
||||
|
||||
var err error
|
||||
if filler, err = template.Render(filler, e); err != nil {
|
||||
if filler, err = template.RenderTrusted(filler, e); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ func (e *Engine) shouldFill(filler string, padLength int) (string, bool) {
|
||||
}
|
||||
|
||||
func (e *Engine) getTitleTemplateText() string {
|
||||
if txt, err := template.Render(e.Config.ConsoleTitleTemplate, nil); err == nil {
|
||||
if txt, err := template.RenderTrusted(e.Config.ConsoleTitleTemplate, nil); err == nil {
|
||||
return txt
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@ func (e *Engine) ExtraPrompt(promptType ExtraPromptType) string {
|
||||
}
|
||||
}
|
||||
|
||||
promptText, err := template.Render(getTemplate(prompt.Template), nil)
|
||||
promptText, err := template.RenderTrusted(getTemplate(prompt.Template), nil)
|
||||
if err != nil {
|
||||
promptText = err.Error()
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func (e *Engine) renderRightTemplate(prompt *config.Segment, background, foregro
|
||||
return "", 0
|
||||
}
|
||||
|
||||
text, err := template.Render(prompt.RightTemplate, nil)
|
||||
text, err := template.RenderTrusted(prompt.RightTemplate, nil)
|
||||
if err != nil {
|
||||
text = err.Error()
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func (h *HTTP) Enabled() bool {
|
||||
method := h.options.String(METHOD, "GET")
|
||||
timeout := h.options.Int(options.HTTPTimeout, 10000)
|
||||
|
||||
if resolved, err := template.Render(url, nil); err == nil {
|
||||
if resolved, err := template.RenderTrusted(url, nil); err == nil {
|
||||
url = resolved
|
||||
}
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@ func (l *Language) buildVersionURL() {
|
||||
return
|
||||
}
|
||||
|
||||
url, err := template.Render(versionURLTemplate, l.Version)
|
||||
url, err := template.RenderTrusted(versionURLTemplate, l.Version)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func (m Map) Template(option Option, defaultValue string, context any) string {
|
||||
return value
|
||||
}
|
||||
|
||||
resolved, err := template.Render(value, context)
|
||||
resolved, err := template.RenderTrusted(value, context)
|
||||
if err != nil {
|
||||
debugf("%s: template error, using raw value: %s", option, err)
|
||||
return value
|
||||
|
||||
@@ -282,7 +282,12 @@ func (pt *Path) setStyle() {
|
||||
}
|
||||
|
||||
// make sure we resolve all templates
|
||||
if txt, err := template.Render(pt.Path, pt); err == nil {
|
||||
//
|
||||
// pt.Path is composed from raw filesystem folder names (untrusted) plus
|
||||
// already-rendered config templates, so it must never get the func map
|
||||
// entries that touch the OS (cmd/readFile/stat/glob) — use the restricted
|
||||
// renderer, not template.Render.
|
||||
if txt, err := template.RenderUntrusted(pt.Path, pt); err == nil {
|
||||
pt.Path = txt
|
||||
}
|
||||
}
|
||||
@@ -293,7 +298,7 @@ func (pt *Path) getMaxWidth() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
txt, err := template.Render(width, pt)
|
||||
txt, err := template.RenderTrusted(width, pt)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
return 0
|
||||
@@ -320,7 +325,7 @@ func (pt *Path) getFolderSeparator() string {
|
||||
return separator
|
||||
}
|
||||
|
||||
txt, err := template.Render(separatorTemplate, pt)
|
||||
txt, err := template.RenderTrusted(separatorTemplate, pt)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
@@ -652,7 +657,7 @@ func (pt *Path) setMappedLocations() {
|
||||
continue
|
||||
}
|
||||
|
||||
location, err := template.Render(key, pt)
|
||||
location, err := template.RenderTrusted(key, pt)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func renderTemplateNoTrimSpace(env *mock.Environment, segmentTemplate string, co
|
||||
}
|
||||
template.Init(env, nil, nil)
|
||||
|
||||
text, err := template.Render(segmentTemplate, context)
|
||||
text, err := template.RenderTrusted(segmentTemplate, context)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
@@ -630,6 +630,13 @@ var testFullAndFolderPathCases = []testFullAndFolderPathCase{
|
||||
{Style: Full, Pwd: homeDir + abc, Expected: "~/abc"},
|
||||
{Style: Full, Pwd: homeDir + abc, Expected: homeDir + abc, DisableMappedLocations: true},
|
||||
{Style: Full, Pwd: abcd, Expected: abcd},
|
||||
|
||||
// A folder name containing template syntax must never execute the `cmd` function
|
||||
// once it is spliced into pt.Path and re-rendered in setStyle(): the render uses
|
||||
// the restricted func map, so parsing fails and pt.Path keeps its raw, unexecuted
|
||||
// text instead.
|
||||
{Style: Full, Pwd: homeDir + "/{{ cmd `whoami` }}", Expected: "~/{{ cmd `whoami` }}"},
|
||||
{Style: FolderType, Pwd: homeDir + "/{{ cmd `whoami` }}", Expected: "{{ cmd `whoami` }}"},
|
||||
}
|
||||
|
||||
var testFullPathCustomMappedLocationsCases = []testFullPathCustomMappedLocationsCase{
|
||||
|
||||
@@ -421,6 +421,16 @@ var testFullAndFolderPathCases = []testFullAndFolderPathCase{
|
||||
{Style: Full, FolderSeparatorIcon: `\`, Pwd: homeDirWindows, Expected: "~", PathSeparator: `\`, GOOS: runtime.WINDOWS},
|
||||
{Style: Full, FolderSeparatorIcon: `\`, Pwd: homeDirWindows + "\\abc", Expected: "~\\abc", PathSeparator: `\`, GOOS: runtime.WINDOWS},
|
||||
{Style: Full, FolderSeparatorIcon: `\`, Pwd: "C:\\Users\\posh", Expected: "C:\\Users\\posh", PathSeparator: `\`, GOOS: runtime.WINDOWS},
|
||||
|
||||
// A folder name containing template syntax must never execute the `cmd` function
|
||||
// once it is spliced into pt.Path and re-rendered in setStyle(): the render uses
|
||||
// the restricted func map, so parsing fails and pt.Path keeps its raw, unexecuted
|
||||
// text instead.
|
||||
{
|
||||
Style: FolderType, FolderSeparatorIcon: `\`,
|
||||
Pwd: homeDirWindows + "\\{{ cmd `whoami` }}", Expected: "{{ cmd `whoami` }}",
|
||||
PathSeparator: `\`, GOOS: runtime.WINDOWS,
|
||||
},
|
||||
}
|
||||
|
||||
var testFullPathCustomMappedLocationsCases = []testFullPathCustomMappedLocationsCase{
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@ func (s *Scm) formatBranch(branch string) string {
|
||||
return branch
|
||||
}
|
||||
|
||||
txt, err := template.Render(branchTemplate, struct{ Branch, Upstream string }{Branch: branch, Upstream: s.Upstream})
|
||||
txt, err := template.RenderTrusted(branchTemplate, struct{ Branch, Upstream string }{Branch: branch, Upstream: s.Upstream})
|
||||
if err != nil {
|
||||
return branch
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (s *Status) formatStatus(status int, pipeStatus string) string {
|
||||
}
|
||||
|
||||
if pipeStatus == "" {
|
||||
if txt, err := template.Render(statusTemplate, s); err == nil {
|
||||
if txt, err := template.RenderTrusted(statusTemplate, s); err == nil {
|
||||
return txt
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func (s *Status) formatStatus(status int, pipeStatus string) string {
|
||||
|
||||
context.Code = code
|
||||
|
||||
txt, err := template.Render(statusTemplate, context)
|
||||
txt, err := template.RenderTrusted(statusTemplate, context)
|
||||
if err != nil {
|
||||
write(codeStr)
|
||||
continue
|
||||
|
||||
@@ -19,7 +19,7 @@ func BenchmarkRenderPlain(b *testing.B) {
|
||||
setupTemplateBench()
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
_, _ = Render("plain text without any template markers at all", nil)
|
||||
_, _ = RenderTrusted("plain text without any template markers at all", nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func BenchmarkRenderSimple(b *testing.B) {
|
||||
data := ctx{Shell: "pwsh", UserName: "jandedobbeleer"}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
_, _ = Render("{{ .Shell }} {{ .UserName }}", data)
|
||||
_, _ = RenderTrusted("{{ .Shell }} {{ .UserName }}", data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func BenchmarkRenderRepeated(b *testing.B) {
|
||||
tmpl := `{{ if .Root }}# {{ end }}{{ .Folder }}{{ if .Branch }} on {{ .Branch }}{{ end }} {{ if .Version }}v{{ .Version }}{{ end }}`
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
_, _ = Render(tmpl, data)
|
||||
_, _ = RenderTrusted(tmpl, data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestCmd(t *testing.T) {
|
||||
Cache = new(cache.Template)
|
||||
Init(e, nil, nil)
|
||||
|
||||
text, err := Render(tc.Template, nil)
|
||||
text, err := RenderTrusted(tc.Template, nil)
|
||||
if tc.ShouldError {
|
||||
assert.Error(t, err, tc.Case)
|
||||
continue
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestDateFromStringEpoch(t *testing.T) {
|
||||
Cache = new(cache.Template)
|
||||
Init(env, nil, nil)
|
||||
|
||||
text, err := Render(tc.Template, tc.Context)
|
||||
text, err := RenderTrusted(tc.Template, tc.Context)
|
||||
assert.NoError(t, err, tc.Case)
|
||||
if tc.Expected != "" {
|
||||
assert.Equal(t, tc.Expected, text, tc.Case)
|
||||
@@ -114,7 +114,7 @@ func TestDateAndHTMLDateFunctions(t *testing.T) {
|
||||
Cache = new(cache.Template)
|
||||
Init(env, nil, nil)
|
||||
|
||||
text, err := Render(tc.Template, tc.Context)
|
||||
text, err := RenderTrusted(tc.Template, tc.Context)
|
||||
assert.NoError(t, err, tc.Case)
|
||||
assert.Equal(t, tc.Expected, text, tc.Case)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestGlob(t *testing.T) {
|
||||
Init(env, nil, nil)
|
||||
|
||||
for _, tc := range cases {
|
||||
text, err := Render(tc.Template, nil)
|
||||
text, err := RenderTrusted(tc.Template, nil)
|
||||
if tc.ShouldError {
|
||||
assert.Error(t, err)
|
||||
continue
|
||||
|
||||
@@ -8,13 +8,25 @@ import (
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
)
|
||||
|
||||
// sharedFuncMap is built exactly once and reused across all template constructions.
|
||||
var sharedFuncMap = sync.OnceValue(func() template.FuncMap {
|
||||
// dangerousFuncs execute host commands or touch the filesystem/environment
|
||||
// directly. They are only exposed to templates rendered via RenderTrusted
|
||||
// (see text.go) — never to RenderUntrusted, which may contain or be composed
|
||||
// from runtime data.
|
||||
var dangerousFuncs = map[string]bool{
|
||||
"cmd": true,
|
||||
"readFile": true,
|
||||
"stat": true,
|
||||
"glob": true,
|
||||
"env": true,
|
||||
"expandenv": true,
|
||||
}
|
||||
|
||||
// baseFuncMap returns the funcs available regardless of trust level.
|
||||
func baseFuncMap() map[string]any {
|
||||
fm := map[string]any{
|
||||
"secondsRound": secondsRound,
|
||||
"url": url,
|
||||
"path": filePath,
|
||||
"glob": glob,
|
||||
"matchP": matchP,
|
||||
"findP": findP,
|
||||
"replaceP": replaceP,
|
||||
@@ -25,9 +37,6 @@ var sharedFuncMap = sync.OnceValue(func() template.FuncMap {
|
||||
"hresult": hresult,
|
||||
"trunc": trunc,
|
||||
"truncE": TruncE,
|
||||
"cmd": cmd,
|
||||
"readFile": readFile,
|
||||
"stat": stat,
|
||||
"dir": filepath.Dir,
|
||||
"base": filepath.Base,
|
||||
// Locale-aware date/time formatting using OS regional settings.
|
||||
@@ -42,15 +51,51 @@ var sharedFuncMap = sync.OnceValue(func() template.FuncMap {
|
||||
}
|
||||
|
||||
for key, fun := range sprig.TxtFuncMap() {
|
||||
if dangerousFuncs[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := fm[key]; !ok {
|
||||
fm[key] = fun
|
||||
}
|
||||
}
|
||||
|
||||
return fm
|
||||
}
|
||||
|
||||
// sharedFuncMap is built exactly once and reused across all trusted template constructions.
|
||||
var sharedFuncMap = sync.OnceValue(func() template.FuncMap {
|
||||
fm := baseFuncMap()
|
||||
|
||||
fm["cmd"] = cmd
|
||||
fm["readFile"] = readFile
|
||||
fm["stat"] = stat
|
||||
fm["glob"] = glob
|
||||
|
||||
sprigFuncs := sprig.TxtFuncMap()
|
||||
if fn, ok := sprigFuncs["env"]; ok {
|
||||
fm["env"] = fn
|
||||
}
|
||||
|
||||
if fn, ok := sprigFuncs["expandenv"]; ok {
|
||||
fm["expandenv"] = fn
|
||||
}
|
||||
|
||||
return template.FuncMap(fm)
|
||||
})
|
||||
|
||||
// funcMap returns the shared merged FuncMap (built once, reused everywhere).
|
||||
func funcMap() template.FuncMap {
|
||||
return sharedFuncMap()
|
||||
// restrictedFuncMap is built exactly once and reused across all restricted template
|
||||
// constructions (see RenderRestricted). It never contains dangerousFuncs.
|
||||
var restrictedFuncMap = sync.OnceValue(func() template.FuncMap {
|
||||
return template.FuncMap(baseFuncMap())
|
||||
})
|
||||
|
||||
// funcMap returns the merged FuncMap for the given trust level (built once per
|
||||
// level, reused everywhere).
|
||||
func funcMap(trusted bool) template.FuncMap {
|
||||
if trusted {
|
||||
return sharedFuncMap()
|
||||
}
|
||||
|
||||
return restrictedFuncMap()
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestUrl(t *testing.T) {
|
||||
Init(env, nil, nil)
|
||||
|
||||
for _, tc := range cases {
|
||||
text, err := Render(tc.Template, nil)
|
||||
text, err := RenderTrusted(tc.Template, nil)
|
||||
if tc.ShouldError {
|
||||
assert.Error(t, err)
|
||||
continue
|
||||
@@ -57,7 +57,7 @@ func TestPath(t *testing.T) {
|
||||
Init(env, nil, nil)
|
||||
|
||||
for _, tc := range cases {
|
||||
text, _ := Render(tc.Template, nil)
|
||||
text, _ := RenderTrusted(tc.Template, nil)
|
||||
|
||||
assert.Equal(t, tc.Expected, text, tc.Case)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func (l List) Join(context any) string {
|
||||
buffer := text.NewBuilder()
|
||||
|
||||
for _, tmpl := range l {
|
||||
value, err := Render(tmpl, context)
|
||||
value, err := RenderTrusted(tmpl, context)
|
||||
if err != nil || len(strings.TrimSpace(value)) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func (l List) FirstMatch(context any, defaultValue string) string {
|
||||
}
|
||||
|
||||
for _, tmpl := range l {
|
||||
value, err := Render(tmpl, context)
|
||||
value, err := RenderTrusted(tmpl, context)
|
||||
if err != nil || len(strings.TrimSpace(value)) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func TestLocaleShortDateFallback(t *testing.T) {
|
||||
|
||||
tmpl := `{{ localeShortDate .T }}`
|
||||
ctx := struct{ T time.Time }{T: time.Unix(0, 0).UTC()}
|
||||
got, err := Render(tmpl, ctx)
|
||||
got, err := RenderTrusted(tmpl, ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "1970-01-01", got)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func TestLocaleShortTimeFallback(t *testing.T) {
|
||||
|
||||
tmpl := `{{ localeShortTime .T }}`
|
||||
ctx := struct{ T time.Time }{T: time.Unix(0, 0).UTC()}
|
||||
got, err := Render(tmpl, ctx)
|
||||
got, err := RenderTrusted(tmpl, ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "00:00", got)
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func TestLocaleShortDateWithISOLayout(t *testing.T) {
|
||||
// knownEpoch = 2019-06-13 20:39:39 UTC (from date_test.go)
|
||||
tmpl := `{{ localeShortDate .T }}`
|
||||
ctx := struct{ T time.Time }{T: time.Unix(knownEpoch, 0).UTC()}
|
||||
got, err := Render(tmpl, ctx)
|
||||
got, err := RenderTrusted(tmpl, ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "2019-06-13", got)
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func TestLocaleShortTimeWith24hLayout(t *testing.T) {
|
||||
|
||||
tmpl := `{{ localeShortTime .T }}`
|
||||
ctx := struct{ T time.Time }{T: time.Unix(knownEpoch, 0).UTC()}
|
||||
got, err := Render(tmpl, ctx)
|
||||
got, err := RenderTrusted(tmpl, ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "20:39", got)
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func TestLocaleShortDateWith12hUSLayout(t *testing.T) {
|
||||
|
||||
tmpl := `{{ localeShortDate .T }} {{ localeShortTime .T }}`
|
||||
ctx := struct{ T time.Time }{T: time.Unix(knownEpoch, 0).UTC()}
|
||||
got, err := Render(tmpl, ctx)
|
||||
got, err := RenderTrusted(tmpl, ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "6/13/2019 8:39 PM", got)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestHResult(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
text, err := Render(tc.Template, nil)
|
||||
text, err := RenderTrusted(tc.Template, nil)
|
||||
if tc.ShouldError {
|
||||
assert.Error(t, err)
|
||||
continue
|
||||
|
||||
@@ -16,12 +16,12 @@ func TestTextPool(t *testing.T) {
|
||||
Init(env, nil, nil)
|
||||
|
||||
// Test rendering
|
||||
result, err := Render("Hello {{ .Name }}", map[string]any{"Name": "World"})
|
||||
result, err := RenderTrusted("Hello {{ .Name }}", map[string]any{"Name": "World"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Hello World", result)
|
||||
|
||||
// Test empty template
|
||||
result2, err := Render("", nil)
|
||||
result2, err := RenderTrusted("", nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", result2)
|
||||
}
|
||||
|
||||
+22
-15
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"unicode"
|
||||
@@ -47,14 +48,19 @@ func (t *renderer) release() {
|
||||
}
|
||||
|
||||
// templateCacheKey returns the key used to look up a cached *template.Template.
|
||||
// It encodes the raw (unpatched) template text and, when context is non-nil,
|
||||
// the reflect.Type of the context so that two different struct types whose
|
||||
// patchTemplate output would differ (via hasField) get separate cache entries.
|
||||
// For map[string]any contexts the key also includes the sorted exported key
|
||||
// names, since patchTemplate output depends on which keys are present.
|
||||
func templateCacheKey(rawText string, ctx any) string {
|
||||
// It encodes the raw (unpatched) template text, the trust level (parsed
|
||||
// templates are bound to a func map at Parse time, so a trusted and a
|
||||
// restricted render of identical text must never share a cache entry), and,
|
||||
// when context is non-nil, the reflect.Type of the context so that two
|
||||
// different struct types whose patchTemplate output would differ (via
|
||||
// hasField) get separate cache entries. For map[string]any contexts the key
|
||||
// also includes the sorted exported key names, since patchTemplate output
|
||||
// depends on which keys are present.
|
||||
func templateCacheKey(rawText string, trusted bool, ctx any) string {
|
||||
key := rawText + "\x00" + strconv.FormatBool(trusted)
|
||||
|
||||
if ctx == nil {
|
||||
return rawText
|
||||
return key
|
||||
}
|
||||
|
||||
t := reflect.TypeOf(ctx)
|
||||
@@ -67,11 +73,11 @@ func templateCacheKey(rawText string, ctx any) string {
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return rawText + "\x00" + t.String() + "\x00" + strings.Join(keys, "\x01")
|
||||
return key + "\x00" + t.String() + "\x00" + strings.Join(keys, "\x01")
|
||||
}
|
||||
}
|
||||
|
||||
return rawText + "\x00" + t.String()
|
||||
return key + "\x00" + t.String()
|
||||
}
|
||||
|
||||
// parsedTemplate returns a fully-parsed *template.Template for text.
|
||||
@@ -79,10 +85,11 @@ func templateCacheKey(rawText string, ctx any) string {
|
||||
// return the cached value. Concurrent first-renders of the same template
|
||||
// may both parse, but LoadOrStore ensures only one result is shared.
|
||||
func parsedTemplate(text *Text) (*template.Template, error) {
|
||||
// Key on the raw, unpatched template text plus the context type so that
|
||||
// cache hits can skip patchTemplate entirely: patching is only needed
|
||||
// the first time a given (raw template, context type) pair is seen.
|
||||
key := templateCacheKey(text.template, text.context)
|
||||
// Key on the raw, unpatched template text plus the trust level and context
|
||||
// type so that cache hits can skip patchTemplate entirely: patching is only
|
||||
// needed the first time a given (raw template, trust level, context type)
|
||||
// combination is seen.
|
||||
key := templateCacheKey(text.template, text.trusted, text.context)
|
||||
|
||||
if cached, ok := parsedTemplates.Load(key); ok {
|
||||
return cached.(*template.Template), nil
|
||||
@@ -91,8 +98,8 @@ func parsedTemplate(text *Text) (*template.Template, error) {
|
||||
// Cache miss: patch the raw template into its executable form.
|
||||
text.patchTemplate()
|
||||
|
||||
// Parse into a fresh template with the shared func map and settings.
|
||||
tmpl, err := template.New("cache").Funcs(funcMap()).Parse(text.template)
|
||||
// Parse into a fresh template with the func map matching this render's trust level.
|
||||
tmpl, err := template.New("cache").Funcs(funcMap(text.trusted)).Parse(text.template)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestRoundSeconds(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
text, err := Render(tc.Template, nil)
|
||||
text, err := RenderTrusted(tc.Template, nil)
|
||||
if tc.ShouldError {
|
||||
assert.Error(t, err)
|
||||
continue
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestTrunc(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
text, err := Render(tc.Template, nil)
|
||||
text, err := RenderTrusted(tc.Template, nil)
|
||||
if tc.ShouldError {
|
||||
assert.Error(t, err)
|
||||
continue
|
||||
|
||||
+27
-4
@@ -13,24 +13,46 @@ import (
|
||||
type Text struct {
|
||||
context Data
|
||||
template string
|
||||
trusted bool
|
||||
}
|
||||
|
||||
// New returns a Text instance from the pool with the given template and context
|
||||
func get(template string, context any) *Text {
|
||||
func get(template string, trusted bool, context any) *Text {
|
||||
if textPool == nil {
|
||||
// Fallback if pool is not initialized yet
|
||||
return &Text{context: context, template: template}
|
||||
return &Text{context: context, template: template, trusted: trusted}
|
||||
}
|
||||
|
||||
text := textPool.Get()
|
||||
text.template = template
|
||||
text.trusted = trusted
|
||||
text.context = context
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
func Render(template string, context any) (string, error) {
|
||||
t := get(template, context)
|
||||
// RenderTrusted executes a template the caller has verified was authored by
|
||||
// the user in their own configuration (a segment/block/palette/etc. template
|
||||
// field), as opposed to text assembled at runtime from external data
|
||||
// (filesystem names, command output, API responses, ...). The full func map
|
||||
// is available, including cmd/readFile/stat/glob.
|
||||
//
|
||||
// Only call this with a string read verbatim from a config field — passing
|
||||
// runtime-composed text here defeats the whole point of the split with
|
||||
// RenderUntrusted.
|
||||
func RenderTrusted(template string, context any) (string, error) {
|
||||
return render(template, true, context)
|
||||
}
|
||||
|
||||
// RenderUntrusted executes a template that may contain, or be composed from,
|
||||
// runtime data rather than user-authored config text. cmd/readFile/stat/glob
|
||||
// and env/expandenv are not available.
|
||||
func RenderUntrusted(template string, context any) (string, error) {
|
||||
return render(template, false, context)
|
||||
}
|
||||
|
||||
func render(template string, trusted bool, context any) (string, error) {
|
||||
t := get(template, trusted, context)
|
||||
defer t.release()
|
||||
|
||||
if !strings.Contains(t.template, "{{") || !strings.Contains(t.template, "}}") {
|
||||
@@ -47,6 +69,7 @@ func Render(template string, context any) (string, error) {
|
||||
func (t *Text) release() {
|
||||
t.context = nil
|
||||
t.template = ""
|
||||
t.trusted = false
|
||||
|
||||
if textPool != nil {
|
||||
textPool.Put(t)
|
||||
|
||||
@@ -162,7 +162,7 @@ func TestRenderTemplate(t *testing.T) {
|
||||
Cache = new(cache.Template)
|
||||
Init(env, nil, nil)
|
||||
|
||||
text, err := Render(tc.Template, tc.Context)
|
||||
text, err := RenderTrusted(tc.Template, tc.Context)
|
||||
if tc.ShouldError {
|
||||
assert.Error(t, err)
|
||||
continue
|
||||
@@ -250,7 +250,7 @@ func TestRenderTemplateEnvVar(t *testing.T) {
|
||||
}
|
||||
Init(env, nil, nil)
|
||||
|
||||
text, err := Render(tc.Template, tc.Context)
|
||||
text, err := RenderTrusted(tc.Template, tc.Context)
|
||||
if tc.ShouldError {
|
||||
assert.Error(t, err)
|
||||
continue
|
||||
@@ -400,7 +400,7 @@ func TestSegmentContains(t *testing.T) {
|
||||
Init(env, nil, nil)
|
||||
|
||||
for _, tc := range cases {
|
||||
text, _ := Render(tc.Template, nil)
|
||||
text, _ := RenderTrusted(tc.Template, nil)
|
||||
assert.Equal(t, tc.Expected, text, tc.Case)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user