From a001ac51952e35bbc376e3fb9f97368d5669044f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 8 Jul 2026 21:08:07 +0200 Subject: [PATCH] Template execution rework --- .github/workflows/build.yaml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/test.yaml | 2 +- .go-version | 1 + Makefile | 64 +- README.md | 1 + docs/releases.md | 1 + server/errors.go | 1 + server/server_template.go | 12 +- server/server_test.go | 78 ++ template/gotext/GENERATED_FROM | 1 + template/gotext/README.md | 92 ++ template/gotext/exec.go | 1148 +++++++++++++++++ template/gotext/fmtsort/sort.go | 154 +++ template/gotext/funcs.go | 774 +++++++++++ template/gotext/option.go | 72 ++ .../gotext/patches/0001-exec-deadline.patch | 151 +++ template/gotext/template.go | 247 ++++ util/sprig/functions.go | 6 +- util/timeout_writer.go | 34 - 20 files changed, 2795 insertions(+), 48 deletions(-) create mode 100644 .go-version create mode 100644 template/gotext/GENERATED_FROM create mode 100644 template/gotext/README.md create mode 100644 template/gotext/exec.go create mode 100644 template/gotext/fmtsort/sort.go create mode 100644 template/gotext/funcs.go create mode 100644 template/gotext/option.go create mode 100644 template/gotext/patches/0001-exec-deadline.patch create mode 100644 template/gotext/template.go delete mode 100644 util/timeout_writer.go diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 333ec3a0..b183757e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -12,7 +12,7 @@ jobs: - name: Install Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.x' + go-version-file: '.go-version' - name: Install node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6eee546b..c1f9a5ec 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -29,7 +29,7 @@ jobs: - name: Install Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.x' + go-version-file: '.go-version' - name: Install node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c89d32bf..ba0e556c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -29,7 +29,7 @@ jobs: - name: Install Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.x' + go-version-file: '.go-version' - name: Install node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: diff --git a/.go-version b/.go-version new file mode 100644 index 00000000..8fe00a57 --- /dev/null +++ b/.go-version @@ -0,0 +1 @@ +1.26.5 diff --git a/Makefile b/Makefile index d7e90f83..d4af7aff 100644 --- a/Makefile +++ b/Makefile @@ -92,7 +92,7 @@ clean: FORCE build: web docs cli -update: web-deps-update cli-deps-update docs-deps-update +update: web-deps-update cli-deps-update docs-deps-update go-check docker pull alpine docker-dev: @@ -273,9 +273,9 @@ cli-build-results: # Test/check targets -check: test web-fmt-check fmt-check vet web-lint lint staticcheck +check: test web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check -checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck +checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check test: cli-test web-test @@ -317,17 +317,67 @@ vet: lint: which golint || go install golang.org/x/lint/golint@latest - go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status + go list ./... | grep -v /vendor/ | grep -vE 'ntfy/v2/template/gotext' | xargs -L1 golint -set_exit_status staticcheck: FORCE rm -rf build/staticcheck which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest mkdir -p build/staticcheck ln -s "go" build/staticcheck/go - PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./... + PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck $$(go list ./... | grep -vE 'ntfy/v2/template/gotext') rm -rf build/staticcheck +# Vendored template targets (see template/README.md) + +TEMPLATE_GO_VERSION := go$(shell cat .go-version 2>/dev/null) + +update-template: + @if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \ + echo "ERROR: local Go $$(go env GOVERSION) != $(TEMPLATE_GO_VERSION) pinned in .go-version."; \ + echo "Bump .go-version and install that toolchain first: go install golang.org/dl/$(TEMPLATE_GO_VERSION)@latest && $(TEMPLATE_GO_VERSION) download"; \ + exit 1; \ + fi + src="$$(go env GOROOT)/src"; \ + cp "$$src/text/template/exec.go" "$$src/text/template/funcs.go" "$$src/text/template/template.go" "$$src/text/template/option.go" template/gotext/; \ + cp "$$src/internal/fmtsort/sort.go" template/gotext/fmtsort/; \ + ( cd template/gotext && for p in patches/*.patch; do echo "Applying $$p"; git apply "$$p" || exit 1; done ) + go env GOVERSION > template/gotext/GENERATED_FROM + @echo "Regenerated template/gotext/ from $(TEMPLATE_GO_VERSION); review with 'git diff'." + +template-check: FORCE + @if [ "$$(cat template/gotext/GENERATED_FROM)" != "$(TEMPLATE_GO_VERSION)" ]; then \ + echo "ERROR: template/gotext was generated from $$(cat template/gotext/GENERATED_FROM), but .go-version pins $(TEMPLATE_GO_VERSION). Run 'make update-template' on the pinned Go."; \ + exit 1; \ + fi + @if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \ + echo "SKIP: local Go $$(go env GOVERSION) != pinned $(TEMPLATE_GO_VERSION); skipping vendored template content check (version marker already verified)."; \ + exit 0; \ + fi + @tmp=$$(mktemp -d); src="$$(go env GOROOT)/src"; \ + mkdir -p "$$tmp/gotext/fmtsort" "$$tmp/patches"; \ + cp "$$src/text/template/exec.go" "$$src/text/template/funcs.go" "$$src/text/template/template.go" "$$src/text/template/option.go" "$$tmp/gotext/"; \ + cp "$$src/internal/fmtsort/sort.go" "$$tmp/gotext/fmtsort/"; \ + cp template/gotext/patches/*.patch "$$tmp/patches/"; \ + ( cd "$$tmp/gotext" && for p in "$$tmp"/patches/*.patch; do git apply "$$p" || exit 1; done ); \ + ok=1; \ + for f in exec.go funcs.go template.go option.go fmtsort/sort.go; do \ + diff -q "$$tmp/gotext/$$f" "template/gotext/$$f" >/dev/null 2>&1 || { echo "DRIFT: template/gotext/$$f differs from GOROOT+patches"; ok=0; }; \ + done; \ + rm -rf "$$tmp"; \ + if [ "$$ok" != 1 ]; then echo "ERROR: template/gotext/ drifted from GOROOT+patches. Run 'make update-template' on Go $(TEMPLATE_GO_VERSION)."; exit 1; fi + +# go-check is advisory only (never fails): it warns when the pinned Go (.go-version) is behind the +# latest upstream release, so template/gotext doesn't silently fall behind on text/template fixes. +go-check: FORCE + @latest=$$(curl -s --max-time 10 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); \ + if [ -n "$$latest" ] && [ "$$latest" != "$(TEMPLATE_GO_VERSION)" ]; then \ + echo ""; \ + echo "note: latest Go is $$latest, but template/gotext is pinned to $(TEMPLATE_GO_VERSION) (.go-version)."; \ + echo " to bump: install $$latest, set .go-version to $${latest#go}, then run 'make update-template'."; \ + fi + + # Releasing targets release: clean cli-deps release-checks docs web check @@ -338,6 +388,10 @@ release-snapshot: clean cli-deps docs web check release-checks: $(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) + if [ "$$(go env GOVERSION)" != "go$$(cat .go-version)" ]; then\ + echo "ERROR: releases must use the pinned Go toolchain (go$$(cat .go-version) from .go-version), but this is $$(go env GOVERSION). This also ensures 'make check' enforces (not skips) the template/gotext drift check.";\ + exit 1;\ + fi if ! grep -q $(LATEST_TAG) docs/install.md; then\ echo "ERROR: Must update docs/install.md with latest tag first.";\ exit 1;\ diff --git a/README.md b/README.md index 58d3d26c..1eb6414a 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,7 @@ Third-party libraries and resources: * [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache * [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages * [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file) +* Go's [text/template](https://pkg.go.dev/text/template) (BSD-3-Clause) is vendored under [template/gotext/](template/gotext/) with a small patch adding an execution deadline (see [template/gotext/README.md](template/gotext/README.md)) * [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page * [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files * [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used) diff --git a/docs/releases.md b/docs/releases.md index 5de94469..a9c1d744 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -37,6 +37,7 @@ since I do have to reset accounts on a regular basis. **Bug fixes + maintenance:** +* Prevent a CPU denial of service via message templates (`Template: yes`) ([GHSA-rhwf-xgc9-m9fp](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-rhwf-xgc9-m9fp)) * `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address * Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution) * Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Kernald](https://github.com/Kernald) for reporting) diff --git a/server/errors.go b/server/errors.go index 0b0f790d..aae55b2e 100644 --- a/server/errors.go +++ b/server/errors.go @@ -138,6 +138,7 @@ var ( errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil} + errHTTPBadRequestTemplateExecutionTimeout = &errHTTP{40055, http.StatusBadRequest, "invalid request: template execution timed out", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} diff --git a/server/server_template.go b/server/server_template.go index bf5ae3e4..b7d0be29 100644 --- a/server/server_template.go +++ b/server/server_template.go @@ -3,13 +3,15 @@ package server import ( "bytes" "encoding/json" + "errors" "os" "path/filepath" "strings" - "text/template" + "time" "gopkg.in/yaml.v2" "heckel.io/ntfy/v2/model" + "heckel.io/ntfy/v2/template/gotext" "heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util/sprig" ) @@ -110,13 +112,17 @@ func (s *Server) renderTemplate(name, tpl, source string) (string, error) { if err := json.Unmarshal([]byte(source), &data); err != nil { return "", errHTTPBadRequestTemplateMessageNotJSON } - t, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl) + t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl) if err != nil { return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error()) } + t.SetExecutionDeadline(time.Now().Add(templateMaxExecutionTime)) // Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp) var buf bytes.Buffer - limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes)) + limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes)) if err := t.Execute(limitWriter, data); err != nil { + if errors.Is(err, gotext.ErrExecutionInterrupted) { + return "", errHTTPBadRequestTemplateExecutionTimeout + } return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error()) } return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines diff --git a/server/server_test.go b/server/server_test.go index 24355fbc..8b01d9e5 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3635,6 +3635,84 @@ func TestServer_MessageTemplate_Range(t *testing.T) { }) } +func TestServer_MessageTemplate_ExecutionTimeout(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + t.Parallel() + s := newTestServer(t, newTestConfig(t, databaseURL)) + // Nested range over a 1000-element JSON field with a no-output body: no Write ever happens, + // so the write-triggered TimeoutWriter never fires. Must be bounded by the executor's + // wall-clock deadline instead (GHSA-rhwf-xgc9-m9fp). + elems := make([]string, 1000) + for i := range elems { + elems[i] = "0" + } + jsonBody := `{"a":[` + strings.Join(elems, ",") + `]}` + msg := `{{range .a}}{{range $.a}}` + strings.Repeat(`{{$x := .}}`, 100) + `{{end}}{{end}}done` + start := time.Now() + response := request(t, s, "POST", "/mytopic", jsonBody, map[string]string{ + "X-Message": msg, + "X-Template": "1", + }) + elapsed := time.Since(start) + require.Equal(t, 400, response.Code) + require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code) + require.Less(t, elapsed, 500*time.Millisecond, "template must be interrupted by the deadline, not run to completion (took %s)", elapsed) + }) +} + +func TestServer_MessageTemplate_GenuineError_NotTimeout(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + t.Parallel() + s := newTestServer(t, newTestConfig(t, databaseURL)) + // A real runtime error (len of an int) must map to execute-failed, not the timeout code. + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ len 5 }}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code) + }) +} + +// slowBody delivers its data after a delay, simulating a slow client upload of the request body. +type slowBody struct { + data []byte + delay time.Duration + done bool +} + +func (b *slowBody) Read(p []byte) (int, error) { + if b.done { + return 0, io.EOF + } + time.Sleep(b.delay) + n := copy(p, b.data) + b.done = true + return n, nil +} + +func (b *slowBody) Close() error { return nil } + +// TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline verifies that a slow request-body +// upload does not consume the template execution deadline: the body is fully read (util.Peek) +// before the deadline starts, so a trivial template still renders even when the upload alone took +// longer than the deadline (GHSA-rhwf-xgc9-m9fp). +func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) { + s := newTestServer(t, newTestConfig(t, "")) + start := time.Now() + response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{ + "Template": "yes", + "X-Message": "hello {{.foo}}", + }, func(r *http.Request) { + r.Body = &slowBody{data: []byte(`{"foo":"bar"}`), delay: 3 * templateMaxExecutionTime} + }) + elapsed := time.Since(start) + require.Greater(t, elapsed, templateMaxExecutionTime, "the slow upload must outlast the exec deadline for this test to be meaningful") + require.Equal(t, 200, response.Code) // Would be 40055 if upload time counted against the deadline + m := toMessage(t, response.Body.String()) + require.Equal(t, "hello bar", m.Message) +} + func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() diff --git a/template/gotext/GENERATED_FROM b/template/gotext/GENERATED_FROM new file mode 100644 index 00000000..9c89591a --- /dev/null +++ b/template/gotext/GENERATED_FROM @@ -0,0 +1 @@ +go1.26.5 diff --git a/template/gotext/README.md b/template/gotext/README.md new file mode 100644 index 00000000..97c4ac38 --- /dev/null +++ b/template/gotext/README.md @@ -0,0 +1,92 @@ +# `template/gotext/` -- vendored `text/template` with an execution deadline + +This directory is a **verbatim copy of Go's standard-library `text/template` package**, plus one +small patch that adds a wall-clock execution deadline. It exists for exactly one reason: to stop +**user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating)) +from burning CPU. + +- **Source:** Go stdlib `text/template` (+ `internal/fmtsort`), `$(go env GOROOT)/src` +- **Version:** pinned in the repo-root [`.go-version`](../../.go-version); recorded in `GENERATED_FROM` +- **Local modifications:** `patches/` (see [The patch](#the-patch)) +- **Update mechanism:** Manual -- `make update-template`, then commit (never autorolled; see [Updating](#updating-when-bumping-the-go-toolchain)) + +## Why this exists + +ntfy lets users send a Go template that is rendered against a JSON body. Go's `text/template` +**cannot be interrupted mid-execution** -- there is no context, no deadline, no cancellation +([golang/go#31107](https://github.com/golang/go/issues/31107) was declined). So a crafted template +with a tight or nested `{{range}}` (e.g. ranging over a large JSON array with a big loop body that +writes no output) can run for tens of seconds on a single request. That is a CPU denial of service +(GHSA-rhwf-xgc9-m9fp). + +There is no way to add an interrupt from the outside -- the executor's per-node `walk` loop is +unexported. The only robust fix is to patch the executor itself. Rather than reach for fragile +heuristics (guessing iteration counts, wrapping every function, etc.), we vendor the package and add +a **single check inside `walk`**: every ~256 nodes it checks a wall-clock deadline and aborts (via +the normal `ExecError` path) if it has passed. This bounds CPU for *any* template shape -- cheap +loops and expensive functions alike -- by construction. + +The one user-facing execution site (`server/server_template.go` `renderTemplate`) sets the deadline +with `SetExecutionDeadline` and maps the resulting error to a `400`. Trusted templates (operator +config: Twilio, `cmd/serve.go`) keep using the standard library -- they are not user-supplied. + +## What's here + +| File | Origin | +|------|--------| +| `exec.go`, `funcs.go`, `template.go`, `option.go` | verbatim from `$(go env GOROOT)/src/text/template/` | +| `fmtsort/sort.go` | verbatim from `$(go env GOROOT)/src/internal/fmtsort/` -- `exec.go` needs it, and `internal/...` packages can't be imported from outside GOROOT, so it comes along | +| `patches/0001-exec-deadline.patch` | our only change (see below) | +| `GENERATED_FROM` | the exact Go version `make update-template` last regenerated this copy from; provenance, written by that target | + +The Go toolchain version this copy is pinned to lives in the repo-root [`.go-version`](../../.go-version) +file (the single source of truth, also consumed by CI and the `make` targets below). `GENERATED_FROM` +must equal it -- `make check` fails otherwise (see below). + +We do **not** vendor `text/template/parse` -- it's a normal importable stdlib package and stays a +plain import. + +## The patch + +`patches/` is a quilt-style ordered series (apply `0001-*`, then `0002-*`, ...). Today there is just +`0001-exec-deadline.patch`, which is small and purely additive: + +- renames the package to `gotext` and rewrites the `internal/fmtsort` import to `heckel.io/ntfy/v2/template/gotext/fmtsort` +- adds `deadline`/`steps` fields to the executor `state` and a `deadline` field + a + `SetExecutionDeadline(time.Time)` method on `Template` +- adds the amortized deadline check at the top of `state.walk` +- adds the exported sentinel `ErrExecutionInterrupted` (detect with `errors.Is`) + +Keeping the patch tiny and additive is deliberate: it makes re-basing onto a new Go release cheap. + +## Updating (when bumping the Go toolchain) + +The copy is **pinned to the Go version in the root `.go-version`**, so it's not frozen -- re-syncing +on a Go bump pulls in all upstream fixes for free. `.go-version` is authoritative and hand-edited; to +bump the toolchain: edit `.go-version`, install that toolchain +(`go install golang.org/dl/@latest && download`), then re-sync: + +``` +make update-template # copies the files from your GOROOT and re-applies patches/*.patch +``` + +`make update-template` **errors** unless your local Go matches `.go-version` -- it validates against +the pin, it never writes it. If the patch hunks no longer apply against the new release, refresh the +patch as part of the bump. + +`make template-check` (wired into `make check`) has two layers: + +1. **Marker check (ungated, runs on any toolchain):** fails if `GENERATED_FROM` != `.go-version`, i.e. + someone bumped the pin but forgot `make update-template` (or vice versa). This catches the common + mistake locally, on any developer's Go. +2. **Content check (gated to the pinned Go):** re-derives the copy from `GOROOT + patches` and diffs it + against what's committed, catching hand-edits and patch problems. It no-ops on a non-pinned + toolchain so it never fails spuriously. + +CI installs exactly `.go-version` (`go-version-file`), so both layers run there. `make release` +additionally refuses to run off the pinned Go, so the content check is never skipped for a release. + +## License + +These files are copyright The Go Authors, under the BSD-3-Clause license (headers preserved in each +file). That is compatible with ntfy's Apache-2.0 / GPLv2 licensing. diff --git a/template/gotext/exec.go b/template/gotext/exec.go new file mode 100644 index 00000000..cfb92515 --- /dev/null +++ b/template/gotext/exec.go @@ -0,0 +1,1148 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gotext + +import ( + "errors" + "fmt" + "io" + "reflect" + "runtime" + "strings" + "text/template/parse" + "time" + + "heckel.io/ntfy/v2/template/gotext/fmtsort" +) + +// maxExecDepth specifies the maximum stack depth of templates within +// templates. This limit is only practically reached by accidentally +// recursive template invocations. This limit allows us to return +// an error instead of triggering a stack overflow. +var maxExecDepth = initMaxExecDepth() + +func initMaxExecDepth() int { + if runtime.GOARCH == "wasm" { + return 1000 + } + return 100000 +} + +// state represents the state of an execution. It's not part of the +// template so that multiple executions of the same template +// can execute in parallel. +type state struct { + tmpl *Template + wr io.Writer + node parse.Node // current node, for errors + vars []variable // push-down stack of variable values. + depth int // the height of the stack of executing templates. + deadline time.Time // ntfy: wall-clock bail-out; zero means no limit + steps int64 // ntfy: node counter for amortized deadline checks +} + +// variable holds the dynamic value of a variable such as $, $x etc. +type variable struct { + name string + value reflect.Value +} + +// push pushes a new variable on the stack. +func (s *state) push(name string, value reflect.Value) { + s.vars = append(s.vars, variable{name, value}) +} + +// mark returns the length of the variable stack. +func (s *state) mark() int { + return len(s.vars) +} + +// pop pops the variable stack up to the mark. +func (s *state) pop(mark int) { + s.vars = s.vars[0:mark] +} + +// setVar overwrites the last declared variable with the given name. +// Used by variable assignments. +func (s *state) setVar(name string, value reflect.Value) { + for i := s.mark() - 1; i >= 0; i-- { + if s.vars[i].name == name { + s.vars[i].value = value + return + } + } + s.errorf("undefined variable: %s", name) +} + +// setTopVar overwrites the top-nth variable on the stack. Used by range iterations. +func (s *state) setTopVar(n int, value reflect.Value) { + s.vars[len(s.vars)-n].value = value +} + +// varValue returns the value of the named variable. +func (s *state) varValue(name string) reflect.Value { + for i := s.mark() - 1; i >= 0; i-- { + if s.vars[i].name == name { + return s.vars[i].value + } + } + s.errorf("undefined variable: %s", name) + return zero +} + +var zero reflect.Value + +type missingValType struct{} + +var missingVal = reflect.ValueOf(missingValType{}) + +var missingValReflectType = reflect.TypeFor[missingValType]() + +func isMissing(v reflect.Value) bool { + return v.IsValid() && v.Type() == missingValReflectType +} + +// at marks the state to be on node n, for error reporting. +func (s *state) at(node parse.Node) { + s.node = node +} + +// doublePercent returns the string with %'s replaced by %%, if necessary, +// so it can be used safely inside a Printf format string. +func doublePercent(str string) string { + return strings.ReplaceAll(str, "%", "%%") +} + +// TODO: It would be nice if ExecError was more broken down, but +// the way ErrorContext embeds the template name makes the +// processing too clumsy. + +// ExecError is the custom error type returned when Execute has an +// error evaluating its template. (If a write error occurs, the actual +// error is returned; it will not be of type ExecError.) +type ExecError struct { + Name string // Name of template. + Err error // Pre-formatted error. +} + +func (e ExecError) Error() string { + return e.Err.Error() +} + +func (e ExecError) Unwrap() error { + return e.Err +} + +// ErrExecutionInterrupted is wrapped into the error returned by Execute when a template exceeds the +// deadline set via Template.SetExecutionDeadline. Detect it with errors.Is. (ntfy addition) +var ErrExecutionInterrupted = errors.New("template execution interrupted") + +// errorf records an ExecError and terminates processing. +func (s *state) errorf(format string, args ...any) { + name := doublePercent(s.tmpl.Name()) + if s.node == nil { + format = fmt.Sprintf("template: %s: %s", name, format) + } else { + location, context := s.tmpl.ErrorContext(s.node) + format = fmt.Sprintf("template: %s: executing %q at <%s>: %s", location, name, doublePercent(context), format) + } + panic(ExecError{ + Name: s.tmpl.Name(), + Err: fmt.Errorf(format, args...), + }) +} + +// writeError is the wrapper type used internally when Execute has an +// error writing to its output. We strip the wrapper in errRecover. +// Note that this is not an implementation of error, so it cannot escape +// from the package as an error value. +type writeError struct { + Err error // Original error. +} + +func (s *state) writeError(err error) { + panic(writeError{ + Err: err, + }) +} + +// errRecover is the handler that turns panics into returns from the top +// level of Parse. +func errRecover(errp *error) { + e := recover() + if e != nil { + switch err := e.(type) { + case runtime.Error: + panic(e) + case writeError: + *errp = err.Err // Strip the wrapper. + case ExecError: + *errp = err // Keep the wrapper. + default: + panic(e) + } + } +} + +// ExecuteTemplate applies the template associated with t that has the given name +// to the specified data object and writes the output to wr. +// If an error occurs executing the template or writing its output, +// execution stops, but partial results may already have been written to +// the output writer. +// A template may be executed safely in parallel, although if parallel +// executions share a Writer the output may be interleaved. +func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error { + tmpl := t.Lookup(name) + if tmpl == nil { + return fmt.Errorf("template: no template %q associated with template %q", name, t.name) + } + return tmpl.Execute(wr, data) +} + +// Execute applies a parsed template to the specified data object, +// and writes the output to wr. +// If an error occurs executing the template or writing its output, +// execution stops, but partial results may already have been written to +// the output writer. +// A template may be executed safely in parallel, although if parallel +// executions share a Writer the output may be interleaved. +// +// If data is a [reflect.Value], the template applies to the concrete +// value that the reflect.Value holds, as in [fmt.Print]. +func (t *Template) Execute(wr io.Writer, data any) error { + return t.execute(wr, data) +} + +func (t *Template) execute(wr io.Writer, data any) (err error) { + defer errRecover(&err) + value, ok := data.(reflect.Value) + if !ok { + value = reflect.ValueOf(data) + } + state := &state{ + tmpl: t, + wr: wr, + vars: []variable{{"$", value}}, + deadline: t.deadline, // ntfy: wall-clock execution bail-out + } + if t.Tree == nil || t.Root == nil { + state.errorf("%q is an incomplete or empty template", t.Name()) + } + state.walk(value, t.Root) + return +} + +// DefinedTemplates returns a string listing the defined templates, +// prefixed by the string "; defined templates are: ". If there are none, +// it returns the empty string. For generating an error message here +// and in [html/template]. +func (t *Template) DefinedTemplates() string { + if t.common == nil { + return "" + } + var b strings.Builder + t.muTmpl.RLock() + defer t.muTmpl.RUnlock() + for name, tmpl := range t.tmpl { + if tmpl.Tree == nil || tmpl.Root == nil { + continue + } + if b.Len() == 0 { + b.WriteString("; defined templates are: ") + } else { + b.WriteString(", ") + } + fmt.Fprintf(&b, "%q", name) + } + return b.String() +} + +// Sentinel errors for use with panic to signal early exits from range loops. +var ( + walkBreak = errors.New("break") + walkContinue = errors.New("continue") +) + +// Walk functions step through the major pieces of the template structure, +// generating output as they go. +func (s *state) walk(dot reflect.Value, node parse.Node) { + s.at(node) + // ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates + // (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp. + if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) { + s.errorf("execution interrupted: %w", ErrExecutionInterrupted) + } + switch node := node.(type) { + case *parse.ActionNode: + // Do not pop variables so they persist until next end. + // Also, if the action declares variables, don't print the result. + val := s.evalPipeline(dot, node.Pipe) + if len(node.Pipe.Decl) == 0 { + s.printValue(node, val) + } + case *parse.BreakNode: + panic(walkBreak) + case *parse.CommentNode: + case *parse.ContinueNode: + panic(walkContinue) + case *parse.IfNode: + s.walkIfOrWith(parse.NodeIf, dot, node.Pipe, node.List, node.ElseList) + case *parse.ListNode: + for _, node := range node.Nodes { + s.walk(dot, node) + } + case *parse.RangeNode: + s.walkRange(dot, node) + case *parse.TemplateNode: + s.walkTemplate(dot, node) + case *parse.TextNode: + if _, err := s.wr.Write(node.Text); err != nil { + s.writeError(err) + } + case *parse.WithNode: + s.walkIfOrWith(parse.NodeWith, dot, node.Pipe, node.List, node.ElseList) + default: + s.errorf("unknown node: %s", node) + } +} + +// walkIfOrWith walks an 'if' or 'with' node. The two control structures +// are identical in behavior except that 'with' sets dot. +func (s *state) walkIfOrWith(typ parse.NodeType, dot reflect.Value, pipe *parse.PipeNode, list, elseList *parse.ListNode) { + defer s.pop(s.mark()) + val := s.evalPipeline(dot, pipe) + truth, ok := isTrue(indirectInterface(val)) + if !ok { + s.errorf("if/with can't use %v", val) + } + if truth { + if typ == parse.NodeWith { + s.walk(val, list) + } else { + s.walk(dot, list) + } + } else if elseList != nil { + s.walk(dot, elseList) + } +} + +// IsTrue reports whether the value is 'true', in the sense of not the zero of its type, +// and whether the value has a meaningful truth value. This is the definition of +// truth used by if and other such actions. +func IsTrue(val any) (truth, ok bool) { + return isTrue(reflect.ValueOf(val)) +} + +func isTrue(val reflect.Value) (truth, ok bool) { + if !val.IsValid() { + // Something like var x interface{}, never set. It's a form of nil. + return false, true + } + switch val.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + truth = val.Len() > 0 + case reflect.Bool: + truth = val.Bool() + case reflect.Complex64, reflect.Complex128: + truth = val.Complex() != 0 + case reflect.Chan, reflect.Func, reflect.Pointer, reflect.UnsafePointer, reflect.Interface: + truth = !val.IsNil() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + truth = val.Int() != 0 + case reflect.Float32, reflect.Float64: + truth = val.Float() != 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + truth = val.Uint() != 0 + case reflect.Struct: + truth = true // Struct values are always true. + default: + return + } + return truth, true +} + +func (s *state) walkRange(dot reflect.Value, r *parse.RangeNode) { + s.at(r) + defer func() { + if r := recover(); r != nil && r != walkBreak { + panic(r) + } + }() + defer s.pop(s.mark()) + val, _ := indirect(s.evalPipeline(dot, r.Pipe)) + // mark top of stack before any variables in the body are pushed. + mark := s.mark() + oneIteration := func(index, elem reflect.Value) { + if len(r.Pipe.Decl) > 0 { + if r.Pipe.IsAssign { + // With two variables, index comes first. + // With one, we use the element. + if len(r.Pipe.Decl) > 1 { + s.setVar(r.Pipe.Decl[0].Ident[0], index) + } else { + s.setVar(r.Pipe.Decl[0].Ident[0], elem) + } + } else { + // Set top var (lexically the second if there + // are two) to the element. + s.setTopVar(1, elem) + } + } + if len(r.Pipe.Decl) > 1 { + if r.Pipe.IsAssign { + s.setVar(r.Pipe.Decl[1].Ident[0], elem) + } else { + // Set next var (lexically the first if there + // are two) to the index. + s.setTopVar(2, index) + } + } + defer s.pop(mark) + defer func() { + // Consume panic(walkContinue) + if r := recover(); r != nil && r != walkContinue { + panic(r) + } + }() + s.walk(elem, r.List) + } + switch val.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + if len(r.Pipe.Decl) > 1 { + s.errorf("can't use %v to iterate over more than one variable", val) + break + } + run := false + for v := range val.Seq() { + run = true + // Pass element as second value, as we do for channels. + oneIteration(reflect.Value{}, v) + } + if !run { + break + } + return + case reflect.Array, reflect.Slice: + if val.Len() == 0 { + break + } + for i := 0; i < val.Len(); i++ { + oneIteration(reflect.ValueOf(i), val.Index(i)) + } + return + case reflect.Map: + if val.Len() == 0 { + break + } + om := fmtsort.Sort(val) + for _, m := range om { + oneIteration(m.Key, m.Value) + } + return + case reflect.Chan: + if val.IsNil() { + break + } + if val.Type().ChanDir() == reflect.SendDir { + s.errorf("range over send-only channel %v", val) + break + } + i := 0 + for ; ; i++ { + elem, ok := val.Recv() + if !ok { + break + } + oneIteration(reflect.ValueOf(i), elem) + } + if i == 0 { + break + } + return + case reflect.Invalid: + break // An invalid value is likely a nil map, etc. and acts like an empty map. + case reflect.Func: + if val.Type().CanSeq() { + if len(r.Pipe.Decl) > 1 { + s.errorf("can't use %v iterate over more than one variable", val) + break + } + run := false + for v := range val.Seq() { + run = true + // Pass element as second value, + // as we do for channels. + oneIteration(reflect.Value{}, v) + } + if !run { + break + } + return + } + if val.Type().CanSeq2() { + run := false + for i, v := range val.Seq2() { + run = true + if len(r.Pipe.Decl) > 1 { + oneIteration(i, v) + } else { + // If there is only one range variable, + // oneIteration will use the + // second value. + oneIteration(reflect.Value{}, i) + } + } + if !run { + break + } + return + } + fallthrough + default: + s.errorf("range can't iterate over %v", val) + } + if r.ElseList != nil { + s.walk(dot, r.ElseList) + } +} + +func (s *state) walkTemplate(dot reflect.Value, t *parse.TemplateNode) { + s.at(t) + tmpl := s.tmpl.Lookup(t.Name) + if tmpl == nil { + s.errorf("template %q not defined", t.Name) + } + if s.depth == maxExecDepth { + s.errorf("exceeded maximum template depth (%v)", maxExecDepth) + } + // Variables declared by the pipeline persist. + dot = s.evalPipeline(dot, t.Pipe) + newState := *s + newState.depth++ + newState.tmpl = tmpl + // No dynamic scoping: template invocations inherit no variables. + newState.vars = []variable{{"$", dot}} + newState.walk(dot, tmpl.Root) +} + +// Eval functions evaluate pipelines, commands, and their elements and extract +// values from the data structure by examining fields, calling methods, and so on. +// The printing of those values happens only through walk functions. + +// evalPipeline returns the value acquired by evaluating a pipeline. If the +// pipeline has a variable declaration, the variable will be pushed on the +// stack. Callers should therefore pop the stack after they are finished +// executing commands depending on the pipeline value. +func (s *state) evalPipeline(dot reflect.Value, pipe *parse.PipeNode) (value reflect.Value) { + if pipe == nil { + return + } + s.at(pipe) + value = missingVal + for _, cmd := range pipe.Cmds { + value = s.evalCommand(dot, cmd, value) // previous value is this one's final arg. + // If the object has type interface{}, dig down one level to the thing inside. + if value.Kind() == reflect.Interface && value.Type().NumMethod() == 0 { + value = value.Elem() + } + } + for _, variable := range pipe.Decl { + if pipe.IsAssign { + s.setVar(variable.Ident[0], value) + } else { + s.push(variable.Ident[0], value) + } + } + return value +} + +func (s *state) notAFunction(args []parse.Node, final reflect.Value) { + if len(args) > 1 || !isMissing(final) { + s.errorf("can't give argument to non-function %s", args[0]) + } +} + +func (s *state) evalCommand(dot reflect.Value, cmd *parse.CommandNode, final reflect.Value) reflect.Value { + firstWord := cmd.Args[0] + switch n := firstWord.(type) { + case *parse.FieldNode: + return s.evalFieldNode(dot, n, cmd.Args, final) + case *parse.ChainNode: + return s.evalChainNode(dot, n, cmd.Args, final) + case *parse.IdentifierNode: + // Must be a function. + return s.evalFunction(dot, n, cmd, cmd.Args, final) + case *parse.PipeNode: + // Parenthesized pipeline. The arguments are all inside the pipeline; final must be absent. + s.notAFunction(cmd.Args, final) + return s.evalPipeline(dot, n) + case *parse.VariableNode: + return s.evalVariableNode(dot, n, cmd.Args, final) + } + s.at(firstWord) + s.notAFunction(cmd.Args, final) + switch word := firstWord.(type) { + case *parse.BoolNode: + return reflect.ValueOf(word.True) + case *parse.DotNode: + return dot + case *parse.NilNode: + s.errorf("nil is not a command") + case *parse.NumberNode: + return s.idealConstant(word) + case *parse.StringNode: + return reflect.ValueOf(word.Text) + } + s.errorf("can't evaluate command %q", firstWord) + panic("not reached") +} + +// idealConstant is called to return the value of a number in a context where +// we don't know the type. In that case, the syntax of the number tells us +// its type, and we use Go rules to resolve. Note there is no such thing as +// a uint ideal constant in this situation - the value must be of int type. +func (s *state) idealConstant(constant *parse.NumberNode) reflect.Value { + // These are ideal constants but we don't know the type + // and we have no context. (If it was a method argument, + // we'd know what we need.) The syntax guides us to some extent. + s.at(constant) + switch { + case constant.IsComplex: + return reflect.ValueOf(constant.Complex128) // incontrovertible. + + case constant.IsFloat && + !isHexInt(constant.Text) && !isRuneInt(constant.Text) && + strings.ContainsAny(constant.Text, ".eEpP"): + return reflect.ValueOf(constant.Float64) + + case constant.IsInt: + n := int(constant.Int64) + if int64(n) != constant.Int64 { + s.errorf("%s overflows int", constant.Text) + } + return reflect.ValueOf(n) + + case constant.IsUint: + s.errorf("%s overflows int", constant.Text) + } + return zero +} + +func isRuneInt(s string) bool { + return len(s) > 0 && s[0] == '\'' +} + +func isHexInt(s string) bool { + return len(s) > 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X') && !strings.ContainsAny(s, "pP") +} + +func (s *state) evalFieldNode(dot reflect.Value, field *parse.FieldNode, args []parse.Node, final reflect.Value) reflect.Value { + s.at(field) + return s.evalFieldChain(dot, dot, field, field.Ident, args, final) +} + +func (s *state) evalChainNode(dot reflect.Value, chain *parse.ChainNode, args []parse.Node, final reflect.Value) reflect.Value { + s.at(chain) + if len(chain.Field) == 0 { + s.errorf("internal error: no fields in evalChainNode") + } + if chain.Node.Type() == parse.NodeNil { + s.errorf("indirection through explicit nil in %s", chain) + } + // (pipe).Field1.Field2 has pipe as .Node, fields as .Field. Eval the pipeline, then the fields. + pipe := s.evalArg(dot, nil, chain.Node) + return s.evalFieldChain(dot, pipe, chain, chain.Field, args, final) +} + +func (s *state) evalVariableNode(dot reflect.Value, variable *parse.VariableNode, args []parse.Node, final reflect.Value) reflect.Value { + // $x.Field has $x as the first ident, Field as the second. Eval the var, then the fields. + s.at(variable) + value := s.varValue(variable.Ident[0]) + if len(variable.Ident) == 1 { + s.notAFunction(args, final) + return value + } + return s.evalFieldChain(dot, value, variable, variable.Ident[1:], args, final) +} + +// evalFieldChain evaluates .X.Y.Z possibly followed by arguments. +// dot is the environment in which to evaluate arguments, while +// receiver is the value being walked along the chain. +func (s *state) evalFieldChain(dot, receiver reflect.Value, node parse.Node, ident []string, args []parse.Node, final reflect.Value) reflect.Value { + n := len(ident) + for i := 0; i < n-1; i++ { + receiver = s.evalField(dot, ident[i], node, nil, missingVal, receiver) + } + // Now if it's a method, it gets the arguments. + return s.evalField(dot, ident[n-1], node, args, final, receiver) +} + +func (s *state) evalFunction(dot reflect.Value, node *parse.IdentifierNode, cmd parse.Node, args []parse.Node, final reflect.Value) reflect.Value { + s.at(node) + name := node.Ident + function, isBuiltin, ok := findFunction(name, s.tmpl) + if !ok { + s.errorf("%q is not a defined function", name) + } + return s.evalCall(dot, function, isBuiltin, cmd, name, args, final) +} + +// evalField evaluates an expression like (.Field) or (.Field arg1 arg2). +// The 'final' argument represents the return value from the preceding +// value of the pipeline, if any. +func (s *state) evalField(dot reflect.Value, fieldName string, node parse.Node, args []parse.Node, final, receiver reflect.Value) reflect.Value { + if !receiver.IsValid() { + if s.tmpl.option.missingKey == mapError { // Treat invalid value as missing map key. + s.errorf("nil data; no entry for key %q", fieldName) + } + return zero + } + typ := receiver.Type() + receiver, isNil := indirect(receiver) + if receiver.Kind() == reflect.Interface && isNil { + // Calling a method on a nil interface can't work. The + // MethodByName method call below would panic. + s.errorf("nil pointer evaluating %s.%s", typ, fieldName) + return zero + } + + // Unless it's an interface, need to get to a value of type *T to guarantee + // we see all methods of T and *T. + ptr := receiver + if ptr.Kind() != reflect.Interface && ptr.Kind() != reflect.Pointer && ptr.CanAddr() { + ptr = ptr.Addr() + } + if method := ptr.MethodByName(fieldName); method.IsValid() { + return s.evalCall(dot, method, false, node, fieldName, args, final) + } + hasArgs := len(args) > 1 || !isMissing(final) + // It's not a method; must be a field of a struct or an element of a map. + switch receiver.Kind() { + case reflect.Struct: + tField, ok := receiver.Type().FieldByName(fieldName) + if ok { + field, err := receiver.FieldByIndexErr(tField.Index) + if !tField.IsExported() { + s.errorf("%s is an unexported field of struct type %s", fieldName, typ) + } + if err != nil { + s.errorf("%v", err) + } + // If it's a function, we must call it. + if hasArgs { + s.errorf("%s has arguments but cannot be invoked as function", fieldName) + } + return field + } + case reflect.Map: + // If it's a map, attempt to use the field name as a key. + nameVal := reflect.ValueOf(fieldName) + if nameVal.Type().AssignableTo(receiver.Type().Key()) { + if hasArgs { + s.errorf("%s is not a method but has arguments", fieldName) + } + result := receiver.MapIndex(nameVal) + if !result.IsValid() { + switch s.tmpl.option.missingKey { + case mapInvalid: + // Just use the invalid value. + case mapZeroValue: + result = reflect.Zero(receiver.Type().Elem()) + case mapError: + s.errorf("map has no entry for key %q", fieldName) + } + } + return result + } + case reflect.Pointer: + etyp := receiver.Type().Elem() + if etyp.Kind() == reflect.Struct { + if _, ok := etyp.FieldByName(fieldName); !ok { + // If there's no such field, say "can't evaluate" + // instead of "nil pointer evaluating". + break + } + } + if isNil { + s.errorf("nil pointer evaluating %s.%s", typ, fieldName) + } + } + s.errorf("can't evaluate field %s in type %s", fieldName, typ) + panic("not reached") +} + +var ( + errorType = reflect.TypeFor[error]() + fmtStringerType = reflect.TypeFor[fmt.Stringer]() + reflectValueType = reflect.TypeFor[reflect.Value]() +) + +// evalCall executes a function or method call. If it's a method, fun already has the receiver bound, so +// it looks just like a function call. The arg list, if non-nil, includes (in the manner of the shell), arg[0] +// as the function itself. +func (s *state) evalCall(dot, fun reflect.Value, isBuiltin bool, node parse.Node, name string, args []parse.Node, final reflect.Value) reflect.Value { + if args != nil { + args = args[1:] // Zeroth arg is function name/node; not passed to function. + } + typ := fun.Type() + numIn := len(args) + if !isMissing(final) { + numIn++ + } + numFixed := len(args) + if typ.IsVariadic() { + numFixed = typ.NumIn() - 1 // last arg is the variadic one. + if numIn < numFixed { + s.errorf("wrong number of args for %s: want at least %d got %d", name, typ.NumIn()-1, len(args)) + } + } else if numIn != typ.NumIn() { + s.errorf("wrong number of args for %s: want %d got %d", name, typ.NumIn(), numIn) + } + if err := goodFunc(name, typ); err != nil { + s.errorf("%v", err) + } + + unwrap := func(v reflect.Value) reflect.Value { + if v.Type() == reflectValueType { + v = v.Interface().(reflect.Value) + } + return v + } + + // Special case for builtin and/or, which short-circuit. + if isBuiltin && (name == "and" || name == "or") { + argType := typ.In(0) + var v reflect.Value + for _, arg := range args { + v = s.evalArg(dot, argType, arg).Interface().(reflect.Value) + if truth(v) == (name == "or") { + // This value was already unwrapped + // by the .Interface().(reflect.Value). + return v + } + } + if !final.Equal(missingVal) { + // The last argument to and/or is coming from + // the pipeline. We didn't short circuit on an earlier + // argument, so we are going to return this one. + // We don't have to evaluate final, but we do + // have to check its type. Then, since we are + // going to return it, we have to unwrap it. + v = unwrap(s.validateType(final, argType)) + } + return v + } + + // Build the arg list. + argv := make([]reflect.Value, numIn) + // Args must be evaluated. Fixed args first. + i := 0 + for ; i < numFixed && i < len(args); i++ { + argv[i] = s.evalArg(dot, typ.In(i), args[i]) + } + // Now the ... args. + if typ.IsVariadic() { + argType := typ.In(typ.NumIn() - 1).Elem() // Argument is a slice. + for ; i < len(args); i++ { + argv[i] = s.evalArg(dot, argType, args[i]) + } + } + // Add final value if necessary. + if !isMissing(final) { + t := typ.In(typ.NumIn() - 1) + if typ.IsVariadic() { + if numIn-1 < numFixed { + // The added final argument corresponds to a fixed parameter of the function. + // Validate against the type of the actual parameter. + t = typ.In(numIn - 1) + } else { + // The added final argument corresponds to the variadic part. + // Validate against the type of the elements of the variadic slice. + t = t.Elem() + } + } + argv[i] = s.validateType(final, t) + } + + // Special case for the "call" builtin. + // Insert the name of the callee function as the first argument. + if isBuiltin && name == "call" { + var calleeName string + if len(args) == 0 { + // final must be present or we would have errored out above. + calleeName = final.String() + } else { + calleeName = args[0].String() + } + argv = append([]reflect.Value{reflect.ValueOf(calleeName)}, argv...) + fun = reflect.ValueOf(call) + } + + v, err := safeCall(fun, argv) + // If we have an error that is not nil, stop execution and return that + // error to the caller. + if err != nil { + s.at(node) + s.errorf("error calling %s: %w", name, err) + } + return unwrap(v) +} + +// canBeNil reports whether an untyped nil can be assigned to the type. See reflect.Zero. +func canBeNil(typ reflect.Type) bool { + switch typ.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return true + case reflect.Struct: + return typ == reflectValueType + } + return false +} + +// validateType guarantees that the value is valid and assignable to the type. +func (s *state) validateType(value reflect.Value, typ reflect.Type) reflect.Value { + if !value.IsValid() { + if typ == nil { + // An untyped nil interface{}. Accept as a proper nil value. + return reflect.ValueOf(nil) + } + if canBeNil(typ) { + // Like above, but use the zero value of the non-nil type. + return reflect.Zero(typ) + } + s.errorf("invalid value; expected %s", typ) + } + if typ == reflectValueType && value.Type() != typ { + return reflect.ValueOf(value) + } + if typ != nil && !value.Type().AssignableTo(typ) { + if value.Kind() == reflect.Interface && !value.IsNil() { + value = value.Elem() + if value.Type().AssignableTo(typ) { + return value + } + // fallthrough + } + // Does one dereference or indirection work? We could do more, as we + // do with method receivers, but that gets messy and method receivers + // are much more constrained, so it makes more sense there than here. + // Besides, one is almost always all you need. + switch { + case value.Kind() == reflect.Pointer && value.Type().Elem().AssignableTo(typ): + value = value.Elem() + if !value.IsValid() { + s.errorf("dereference of nil pointer of type %s", typ) + } + case reflect.PointerTo(value.Type()).AssignableTo(typ) && value.CanAddr(): + value = value.Addr() + default: + s.errorf("wrong type for value; expected %s; got %s", typ, value.Type()) + } + } + return value +} + +func (s *state) evalArg(dot reflect.Value, typ reflect.Type, n parse.Node) reflect.Value { + s.at(n) + switch arg := n.(type) { + case *parse.DotNode: + return s.validateType(dot, typ) + case *parse.NilNode: + if canBeNil(typ) { + return reflect.Zero(typ) + } + s.errorf("cannot assign nil to %s", typ) + case *parse.FieldNode: + return s.validateType(s.evalFieldNode(dot, arg, []parse.Node{n}, missingVal), typ) + case *parse.VariableNode: + return s.validateType(s.evalVariableNode(dot, arg, nil, missingVal), typ) + case *parse.PipeNode: + return s.validateType(s.evalPipeline(dot, arg), typ) + case *parse.IdentifierNode: + return s.validateType(s.evalFunction(dot, arg, arg, nil, missingVal), typ) + case *parse.ChainNode: + return s.validateType(s.evalChainNode(dot, arg, nil, missingVal), typ) + } + switch typ.Kind() { + case reflect.Bool: + return s.evalBool(typ, n) + case reflect.Complex64, reflect.Complex128: + return s.evalComplex(typ, n) + case reflect.Float32, reflect.Float64: + return s.evalFloat(typ, n) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return s.evalInteger(typ, n) + case reflect.Interface: + if typ.NumMethod() == 0 { + return s.evalEmptyInterface(dot, n) + } + case reflect.Struct: + if typ == reflectValueType { + return reflect.ValueOf(s.evalEmptyInterface(dot, n)) + } + case reflect.String: + return s.evalString(typ, n) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return s.evalUnsignedInteger(typ, n) + } + s.errorf("can't handle %s for arg of type %s", n, typ) + panic("not reached") +} + +func (s *state) evalBool(typ reflect.Type, n parse.Node) reflect.Value { + s.at(n) + if n, ok := n.(*parse.BoolNode); ok { + value := reflect.New(typ).Elem() + value.SetBool(n.True) + return value + } + s.errorf("expected bool; found %s", n) + panic("not reached") +} + +func (s *state) evalString(typ reflect.Type, n parse.Node) reflect.Value { + s.at(n) + if n, ok := n.(*parse.StringNode); ok { + value := reflect.New(typ).Elem() + value.SetString(n.Text) + return value + } + s.errorf("expected string; found %s", n) + panic("not reached") +} + +func (s *state) evalInteger(typ reflect.Type, n parse.Node) reflect.Value { + s.at(n) + if n, ok := n.(*parse.NumberNode); ok && n.IsInt { + value := reflect.New(typ).Elem() + value.SetInt(n.Int64) + return value + } + s.errorf("expected integer; found %s", n) + panic("not reached") +} + +func (s *state) evalUnsignedInteger(typ reflect.Type, n parse.Node) reflect.Value { + s.at(n) + if n, ok := n.(*parse.NumberNode); ok && n.IsUint { + value := reflect.New(typ).Elem() + value.SetUint(n.Uint64) + return value + } + s.errorf("expected unsigned integer; found %s", n) + panic("not reached") +} + +func (s *state) evalFloat(typ reflect.Type, n parse.Node) reflect.Value { + s.at(n) + if n, ok := n.(*parse.NumberNode); ok && n.IsFloat { + value := reflect.New(typ).Elem() + value.SetFloat(n.Float64) + return value + } + s.errorf("expected float; found %s", n) + panic("not reached") +} + +func (s *state) evalComplex(typ reflect.Type, n parse.Node) reflect.Value { + if n, ok := n.(*parse.NumberNode); ok && n.IsComplex { + value := reflect.New(typ).Elem() + value.SetComplex(n.Complex128) + return value + } + s.errorf("expected complex; found %s", n) + panic("not reached") +} + +func (s *state) evalEmptyInterface(dot reflect.Value, n parse.Node) reflect.Value { + s.at(n) + switch n := n.(type) { + case *parse.BoolNode: + return reflect.ValueOf(n.True) + case *parse.DotNode: + return dot + case *parse.FieldNode: + return s.evalFieldNode(dot, n, nil, missingVal) + case *parse.IdentifierNode: + return s.evalFunction(dot, n, n, nil, missingVal) + case *parse.NilNode: + // NilNode is handled in evalArg, the only place that calls here. + s.errorf("evalEmptyInterface: nil (can't happen)") + case *parse.NumberNode: + return s.idealConstant(n) + case *parse.StringNode: + return reflect.ValueOf(n.Text) + case *parse.VariableNode: + return s.evalVariableNode(dot, n, nil, missingVal) + case *parse.PipeNode: + return s.evalPipeline(dot, n) + } + s.errorf("can't handle assignment of %s to empty interface argument", n) + panic("not reached") +} + +// indirect returns the item at the end of indirection, and a bool to indicate +// if it's nil. If the returned bool is true, the returned value's kind will be +// either a pointer or interface. +func indirect(v reflect.Value) (rv reflect.Value, isNil bool) { + for ; v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface; v = v.Elem() { + if v.IsNil() { + return v, true + } + } + return v, false +} + +// indirectInterface returns the concrete value in an interface value, +// or else the zero reflect.Value. +// That is, if v represents the interface value x, the result is the same as reflect.ValueOf(x): +// the fact that x was an interface value is forgotten. +func indirectInterface(v reflect.Value) reflect.Value { + if v.Kind() != reflect.Interface { + return v + } + if v.IsNil() { + return reflect.Value{} + } + return v.Elem() +} + +// printValue writes the textual representation of the value to the output of +// the template. +func (s *state) printValue(n parse.Node, v reflect.Value) { + s.at(n) + iface, ok := printableValue(v) + if !ok { + s.errorf("can't print %s of type %s", n, v.Type()) + } + _, err := fmt.Fprint(s.wr, iface) + if err != nil { + s.writeError(err) + } +} + +// printableValue returns the, possibly indirected, interface value inside v that +// is best for a call to formatted printer. +func printableValue(v reflect.Value) (any, bool) { + if v.Kind() == reflect.Pointer { + v, _ = indirect(v) // fmt.Fprint handles nil. + } + if !v.IsValid() { + return "", true + } + + if !v.Type().Implements(errorType) && !v.Type().Implements(fmtStringerType) { + if v.CanAddr() && (reflect.PointerTo(v.Type()).Implements(errorType) || reflect.PointerTo(v.Type()).Implements(fmtStringerType)) { + v = v.Addr() + } else { + switch v.Kind() { + case reflect.Chan, reflect.Func: + return nil, false + } + } + } + return v.Interface(), true +} diff --git a/template/gotext/fmtsort/sort.go b/template/gotext/fmtsort/sort.go new file mode 100644 index 00000000..f51cdc70 --- /dev/null +++ b/template/gotext/fmtsort/sort.go @@ -0,0 +1,154 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package fmtsort provides a general stable ordering mechanism +// for maps, on behalf of the fmt and text/template packages. +// It is not guaranteed to be efficient and works only for types +// that are valid map keys. +package fmtsort + +import ( + "cmp" + "reflect" + "slices" +) + +// Note: Throughout this package we avoid calling reflect.Value.Interface as +// it is not always legal to do so and it's easier to avoid the issue than to face it. + +// SortedMap is a slice of KeyValue pairs that simplifies sorting +// and iterating over map entries. +// +// Each KeyValue pair contains a map key and its corresponding value. +type SortedMap []KeyValue + +// KeyValue holds a single key and value pair found in a map. +type KeyValue struct { + Key, Value reflect.Value +} + +// Sort accepts a map and returns a SortedMap that has the same keys and +// values but in a stable sorted order according to the keys, modulo issues +// raised by unorderable key values such as NaNs. +// +// The ordering rules are more general than with Go's < operator: +// +// - when applicable, nil compares low +// - ints, floats, and strings order by < +// - NaN compares less than non-NaN floats +// - bool compares false before true +// - complex compares real, then imag +// - pointers compare by machine address +// - channel values compare by machine address +// - structs compare each field in turn +// - arrays compare each element in turn. +// Otherwise identical arrays compare by length. +// - interface values compare first by reflect.Type describing the concrete type +// and then by concrete value as described in the previous rules. +func Sort(mapValue reflect.Value) SortedMap { + if mapValue.Type().Kind() != reflect.Map { + return nil + } + // Note: this code is arranged to not panic even in the presence + // of a concurrent map update. The runtime is responsible for + // yelling loudly if that happens. See issue 33275. + n := mapValue.Len() + sorted := make(SortedMap, 0, n) + iter := mapValue.MapRange() + for iter.Next() { + sorted = append(sorted, KeyValue{iter.Key(), iter.Value()}) + } + slices.SortStableFunc(sorted, func(a, b KeyValue) int { + return compare(a.Key, b.Key) + }) + return sorted +} + +// compare compares two values of the same type. It returns -1, 0, 1 +// according to whether a > b (1), a == b (0), or a < b (-1). +// If the types differ, it returns -1. +// See the comment on Sort for the comparison rules. +func compare(aVal, bVal reflect.Value) int { + aType, bType := aVal.Type(), bVal.Type() + if aType != bType { + return -1 // No good answer possible, but don't return 0: they're not equal. + } + switch aVal.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return cmp.Compare(aVal.Int(), bVal.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return cmp.Compare(aVal.Uint(), bVal.Uint()) + case reflect.String: + return cmp.Compare(aVal.String(), bVal.String()) + case reflect.Float32, reflect.Float64: + return cmp.Compare(aVal.Float(), bVal.Float()) + case reflect.Complex64, reflect.Complex128: + a, b := aVal.Complex(), bVal.Complex() + if c := cmp.Compare(real(a), real(b)); c != 0 { + return c + } + return cmp.Compare(imag(a), imag(b)) + case reflect.Bool: + a, b := aVal.Bool(), bVal.Bool() + switch { + case a == b: + return 0 + case a: + return 1 + default: + return -1 + } + case reflect.Pointer, reflect.UnsafePointer: + return cmp.Compare(aVal.Pointer(), bVal.Pointer()) + case reflect.Chan: + if c, ok := nilCompare(aVal, bVal); ok { + return c + } + return cmp.Compare(aVal.Pointer(), bVal.Pointer()) + case reflect.Struct: + for i := 0; i < aVal.NumField(); i++ { + if c := compare(aVal.Field(i), bVal.Field(i)); c != 0 { + return c + } + } + return 0 + case reflect.Array: + for i := 0; i < aVal.Len(); i++ { + if c := compare(aVal.Index(i), bVal.Index(i)); c != 0 { + return c + } + } + return 0 + case reflect.Interface: + if c, ok := nilCompare(aVal, bVal); ok { + return c + } + c := compare(reflect.ValueOf(aVal.Elem().Type()), reflect.ValueOf(bVal.Elem().Type())) + if c != 0 { + return c + } + return compare(aVal.Elem(), bVal.Elem()) + default: + // Certain types cannot appear as keys (maps, funcs, slices), but be explicit. + panic("bad type in compare: " + aType.String()) + } +} + +// nilCompare checks whether either value is nil. If not, the boolean is false. +// If either value is nil, the boolean is true and the integer is the comparison +// value. The comparison is defined to be 0 if both are nil, otherwise the one +// nil value compares low. Both arguments must represent a chan, func, +// interface, map, pointer, or slice. +func nilCompare(aVal, bVal reflect.Value) (int, bool) { + if aVal.IsNil() { + if bVal.IsNil() { + return 0, true + } + return -1, true + } + if bVal.IsNil() { + return 1, true + } + return 0, false +} diff --git a/template/gotext/funcs.go b/template/gotext/funcs.go new file mode 100644 index 00000000..734fe7bf --- /dev/null +++ b/template/gotext/funcs.go @@ -0,0 +1,774 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gotext + +import ( + "errors" + "fmt" + "io" + "net/url" + "reflect" + "strings" + "sync" + "unicode" + "unicode/utf8" +) + +// FuncMap is the type of the map defining the mapping from names to functions. +// Each function must have either a single return value, or two return values of +// which the second has type error. In that case, if the second (error) +// return value evaluates to non-nil during execution, execution terminates and +// Execute returns that error. +// +// Errors returned by Execute wrap the underlying error; call [errors.AsType] to +// unwrap them. +// +// When template execution invokes a function with an argument list, that list +// must be assignable to the function's parameter types. Functions meant to +// apply to arguments of arbitrary type can use parameters of type interface{} or +// of type [reflect.Value]. Similarly, functions meant to return a result of arbitrary +// type can return interface{} or [reflect.Value]. +type FuncMap map[string]any + +// builtins returns the FuncMap. +// It is not a global variable so the linker can dead code eliminate +// more when this isn't called. See golang.org/issue/36021. +// TODO: revert this back to a global map once golang.org/issue/2559 is fixed. +func builtins() FuncMap { + return FuncMap{ + "and": and, + "call": emptyCall, + "html": HTMLEscaper, + "index": index, + "slice": slice, + "js": JSEscaper, + "len": length, + "not": not, + "or": or, + "print": fmt.Sprint, + "printf": fmt.Sprintf, + "println": fmt.Sprintln, + "urlquery": URLQueryEscaper, + + // Comparisons + "eq": eq, // == + "ge": ge, // >= + "gt": gt, // > + "le": le, // <= + "lt": lt, // < + "ne": ne, // != + } +} + +// builtinFuncs lazily computes & caches the builtinFuncs map. +var builtinFuncs = sync.OnceValue(func() map[string]reflect.Value { + funcMap := builtins() + m := make(map[string]reflect.Value, len(funcMap)) + addValueFuncs(m, funcMap) + return m +}) + +// addValueFuncs adds to values the functions in funcs, converting them to reflect.Values. +func addValueFuncs(out map[string]reflect.Value, in FuncMap) { + for name, fn := range in { + if !goodName(name) { + panic(fmt.Errorf("function name %q is not a valid identifier", name)) + } + v := reflect.ValueOf(fn) + if v.Kind() != reflect.Func { + panic("value for " + name + " not a function") + } + if err := goodFunc(name, v.Type()); err != nil { + panic(err) + } + out[name] = v + } +} + +// addFuncs adds to values the functions in funcs. It does no checking of the input - +// call addValueFuncs first. +func addFuncs(out, in FuncMap) { + for name, fn := range in { + out[name] = fn + } +} + +// goodFunc reports whether the function or method has the right result signature. +func goodFunc(name string, typ reflect.Type) error { + // We allow functions with 1 result or 2 results where the second is an error. + switch numOut := typ.NumOut(); { + case numOut == 1: + return nil + case numOut == 2 && typ.Out(1) == errorType: + return nil + case numOut == 2: + return fmt.Errorf("invalid function signature for %s: second return value should be error; is %s", name, typ.Out(1)) + default: + return fmt.Errorf("function %s has %d return values; should be 1 or 2", name, typ.NumOut()) + } +} + +// goodName reports whether the function name is a valid identifier. +func goodName(name string) bool { + if name == "" { + return false + } + for i, r := range name { + switch { + case r == '_': + case i == 0 && !unicode.IsLetter(r): + return false + case !unicode.IsLetter(r) && !unicode.IsDigit(r): + return false + } + } + return true +} + +// findFunction looks for a function in the template, and global map. +func findFunction(name string, tmpl *Template) (v reflect.Value, isBuiltin, ok bool) { + if tmpl != nil && tmpl.common != nil { + tmpl.muFuncs.RLock() + defer tmpl.muFuncs.RUnlock() + if fn := tmpl.execFuncs[name]; fn.IsValid() { + return fn, false, true + } + } + if fn := builtinFuncs()[name]; fn.IsValid() { + return fn, true, true + } + return reflect.Value{}, false, false +} + +// prepareArg checks if value can be used as an argument of type argType, and +// converts an invalid value to appropriate zero if possible. +func prepareArg(value reflect.Value, argType reflect.Type) (reflect.Value, error) { + if !value.IsValid() { + if !canBeNil(argType) { + return reflect.Value{}, fmt.Errorf("value is nil; should be of type %s", argType) + } + value = reflect.Zero(argType) + } + if value.Type().AssignableTo(argType) { + return value, nil + } + if intLike(value.Kind()) && intLike(argType.Kind()) && value.Type().ConvertibleTo(argType) { + value = value.Convert(argType) + return value, nil + } + return reflect.Value{}, fmt.Errorf("value has type %s; should be %s", value.Type(), argType) +} + +func intLike(typ reflect.Kind) bool { + switch typ { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return true + } + return false +} + +// indexArg checks if a reflect.Value can be used as an index, and converts it to int if possible. +func indexArg(index reflect.Value, cap int) (int, error) { + var x int64 + switch index.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + x = index.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + x = int64(index.Uint()) + case reflect.Invalid: + return 0, fmt.Errorf("cannot index slice/array with nil") + default: + return 0, fmt.Errorf("cannot index slice/array with type %s", index.Type()) + } + if x < 0 || int(x) < 0 || int(x) > cap { + return 0, fmt.Errorf("index out of range: %d", x) + } + return int(x), nil +} + +// Indexing. + +// index returns the result of indexing its first argument by the following +// arguments. Thus "index x 1 2 3" is, in Go syntax, x[1][2][3]. Each +// indexed item must be a map, slice, or array. +func index(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) { + item = indirectInterface(item) + if !item.IsValid() { + return reflect.Value{}, fmt.Errorf("index of untyped nil") + } + for _, index := range indexes { + index = indirectInterface(index) + var isNil bool + if item, isNil = indirect(item); isNil { + return reflect.Value{}, fmt.Errorf("index of nil pointer") + } + switch item.Kind() { + case reflect.Array, reflect.Slice, reflect.String: + x, err := indexArg(index, item.Len()) + if err != nil { + return reflect.Value{}, err + } + item = item.Index(x) + case reflect.Map: + index, err := prepareArg(index, item.Type().Key()) + if err != nil { + return reflect.Value{}, err + } + if x := item.MapIndex(index); x.IsValid() { + item = x + } else { + item = reflect.Zero(item.Type().Elem()) + } + case reflect.Invalid: + // the loop holds invariant: item.IsValid() + panic("unreachable") + default: + return reflect.Value{}, fmt.Errorf("can't index item of type %s", item.Type()) + } + } + return item, nil +} + +// Slicing. + +// slice returns the result of slicing its first argument by the remaining +// arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2], while "slice x" +// is x[:], "slice x 1" is x[1:], and "slice x 1 2 3" is x[1:2:3]. The first +// argument must be a string, slice, or array. +func slice(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) { + item = indirectInterface(item) + if !item.IsValid() { + return reflect.Value{}, fmt.Errorf("slice of untyped nil") + } + var isNil bool + if item, isNil = indirect(item); isNil { + return reflect.Value{}, fmt.Errorf("slice of nil pointer") + } + if len(indexes) > 3 { + return reflect.Value{}, fmt.Errorf("too many slice indexes: %d", len(indexes)) + } + var cap int + switch item.Kind() { + case reflect.String: + if len(indexes) == 3 { + return reflect.Value{}, fmt.Errorf("cannot 3-index slice a string") + } + cap = item.Len() + case reflect.Array, reflect.Slice: + cap = item.Cap() + default: + return reflect.Value{}, fmt.Errorf("can't slice item of type %s", item.Type()) + } + // set default values for cases item[:], item[i:]. + idx := [3]int{0, item.Len()} + for i, index := range indexes { + x, err := indexArg(index, cap) + if err != nil { + return reflect.Value{}, err + } + idx[i] = x + } + // given item[i:j], make sure i <= j. + if idx[0] > idx[1] { + return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[0], idx[1]) + } + if len(indexes) < 3 { + return item.Slice(idx[0], idx[1]), nil + } + // given item[i:j:k], make sure i <= j <= k. + if idx[1] > idx[2] { + return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[1], idx[2]) + } + return item.Slice3(idx[0], idx[1], idx[2]), nil +} + +// Length + +// length returns the length of the item, with an error if it has no defined length. +func length(item reflect.Value) (int, error) { + item, isNil := indirect(item) + if isNil { + return 0, fmt.Errorf("len of nil pointer") + } + switch item.Kind() { + case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice, reflect.String: + return item.Len(), nil + } + return 0, fmt.Errorf("len of type %s", item.Type()) +} + +// Function invocation + +func emptyCall(fn reflect.Value, args ...reflect.Value) reflect.Value { + panic("unreachable") // implemented as a special case in evalCall +} + +// call returns the result of evaluating the first argument as a function. +// The function must return 1 result, or 2 results, the second of which is an error. +func call(name string, fn reflect.Value, args ...reflect.Value) (reflect.Value, error) { + fn = indirectInterface(fn) + if !fn.IsValid() { + return reflect.Value{}, fmt.Errorf("call of nil") + } + typ := fn.Type() + if typ.Kind() != reflect.Func { + return reflect.Value{}, fmt.Errorf("non-function %s of type %s", name, typ) + } + + if err := goodFunc(name, typ); err != nil { + return reflect.Value{}, err + } + numIn := typ.NumIn() + var dddType reflect.Type + if typ.IsVariadic() { + if len(args) < numIn-1 { + return reflect.Value{}, fmt.Errorf("wrong number of args for %s: got %d want at least %d", name, len(args), numIn-1) + } + dddType = typ.In(numIn - 1).Elem() + } else { + if len(args) != numIn { + return reflect.Value{}, fmt.Errorf("wrong number of args for %s: got %d want %d", name, len(args), numIn) + } + } + argv := make([]reflect.Value, len(args)) + for i, arg := range args { + arg = indirectInterface(arg) + // Compute the expected type. Clumsy because of variadics. + argType := dddType + if !typ.IsVariadic() || i < numIn-1 { + argType = typ.In(i) + } + + var err error + if argv[i], err = prepareArg(arg, argType); err != nil { + return reflect.Value{}, fmt.Errorf("arg %d: %w", i, err) + } + } + return safeCall(fn, argv) +} + +// safeCall runs fun.Call(args), and returns the resulting value and error, if +// any. If the call panics, the panic value is returned as an error. +func safeCall(fun reflect.Value, args []reflect.Value) (val reflect.Value, err error) { + defer func() { + if r := recover(); r != nil { + if e, ok := r.(error); ok { + err = e + } else { + err = fmt.Errorf("%v", r) + } + } + }() + ret := fun.Call(args) + if len(ret) == 2 && !ret[1].IsNil() { + return ret[0], ret[1].Interface().(error) + } + return ret[0], nil +} + +// Boolean logic. + +func truth(arg reflect.Value) bool { + t, _ := isTrue(indirectInterface(arg)) + return t +} + +// and computes the Boolean AND of its arguments, returning +// the first false argument it encounters, or the last argument. +func and(arg0 reflect.Value, args ...reflect.Value) reflect.Value { + panic("unreachable") // implemented as a special case in evalCall +} + +// or computes the Boolean OR of its arguments, returning +// the first true argument it encounters, or the last argument. +func or(arg0 reflect.Value, args ...reflect.Value) reflect.Value { + panic("unreachable") // implemented as a special case in evalCall +} + +// not returns the Boolean negation of its argument. +func not(arg reflect.Value) bool { + return !truth(arg) +} + +// Comparison. + +// TODO: Perhaps allow comparison between signed and unsigned integers. + +var ( + errBadComparisonType = errors.New("invalid type for comparison") + errNoComparison = errors.New("missing argument for comparison") +) + +type kind int + +const ( + invalidKind kind = iota + boolKind + complexKind + intKind + floatKind + stringKind + uintKind +) + +func basicKind(v reflect.Value) (kind, error) { + switch v.Kind() { + case reflect.Bool: + return boolKind, nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return intKind, nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return uintKind, nil + case reflect.Float32, reflect.Float64: + return floatKind, nil + case reflect.Complex64, reflect.Complex128: + return complexKind, nil + case reflect.String: + return stringKind, nil + } + return invalidKind, errBadComparisonType +} + +// isNil returns true if v is the zero reflect.Value, or nil of its type. +func isNil(v reflect.Value) bool { + if !v.IsValid() { + return true + } + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + } + return false +} + +// canCompare reports whether v1 and v2 are both the same kind, or one is nil. +// Called only when dealing with nillable types, or there's about to be an error. +func canCompare(v1, v2 reflect.Value) bool { + k1 := v1.Kind() + k2 := v2.Kind() + if k1 == k2 { + return true + } + // We know the type can be compared to nil. + return k1 == reflect.Invalid || k2 == reflect.Invalid +} + +// eq evaluates the comparison a == b || a == c || ... +func eq(arg1 reflect.Value, arg2 ...reflect.Value) (bool, error) { + arg1 = indirectInterface(arg1) + if len(arg2) == 0 { + return false, errNoComparison + } + k1, _ := basicKind(arg1) + for _, arg := range arg2 { + arg = indirectInterface(arg) + k2, _ := basicKind(arg) + truth := false + if k1 != k2 { + // Special case: Can compare integer values regardless of type's sign. + switch { + case k1 == intKind && k2 == uintKind: + truth = arg1.Int() >= 0 && uint64(arg1.Int()) == arg.Uint() + case k1 == uintKind && k2 == intKind: + truth = arg.Int() >= 0 && arg1.Uint() == uint64(arg.Int()) + default: + if arg1.IsValid() && arg.IsValid() { + return false, fmt.Errorf("incompatible types for comparison: %v and %v", arg1.Type(), arg.Type()) + } + } + } else { + switch k1 { + case boolKind: + truth = arg1.Bool() == arg.Bool() + case complexKind: + truth = arg1.Complex() == arg.Complex() + case floatKind: + truth = arg1.Float() == arg.Float() + case intKind: + truth = arg1.Int() == arg.Int() + case stringKind: + truth = arg1.String() == arg.String() + case uintKind: + truth = arg1.Uint() == arg.Uint() + default: + if !canCompare(arg1, arg) { + return false, fmt.Errorf("non-comparable types %s: %v, %s: %v", arg1, arg1.Type(), arg.Type(), arg) + } + if isNil(arg1) || isNil(arg) { + truth = isNil(arg) == isNil(arg1) + } else { + if !arg.Type().Comparable() { + return false, fmt.Errorf("non-comparable type %s: %v", arg, arg.Type()) + } + truth = arg1.Interface() == arg.Interface() + } + } + } + if truth { + return true, nil + } + } + return false, nil +} + +// ne evaluates the comparison a != b. +func ne(arg1, arg2 reflect.Value) (bool, error) { + // != is the inverse of ==. + equal, err := eq(arg1, arg2) + return !equal, err +} + +// lt evaluates the comparison a < b. +func lt(arg1, arg2 reflect.Value) (bool, error) { + arg1 = indirectInterface(arg1) + k1, err := basicKind(arg1) + if err != nil { + return false, err + } + arg2 = indirectInterface(arg2) + k2, err := basicKind(arg2) + if err != nil { + return false, err + } + truth := false + if k1 != k2 { + // Special case: Can compare integer values regardless of type's sign. + switch { + case k1 == intKind && k2 == uintKind: + truth = arg1.Int() < 0 || uint64(arg1.Int()) < arg2.Uint() + case k1 == uintKind && k2 == intKind: + truth = arg2.Int() >= 0 && arg1.Uint() < uint64(arg2.Int()) + default: + return false, fmt.Errorf("incompatible types for comparison: %v and %v", arg1.Type(), arg2.Type()) + } + } else { + switch k1 { + case boolKind, complexKind: + return false, errBadComparisonType + case floatKind: + truth = arg1.Float() < arg2.Float() + case intKind: + truth = arg1.Int() < arg2.Int() + case stringKind: + truth = arg1.String() < arg2.String() + case uintKind: + truth = arg1.Uint() < arg2.Uint() + default: + panic("invalid kind") + } + } + return truth, nil +} + +// le evaluates the comparison <= b. +func le(arg1, arg2 reflect.Value) (bool, error) { + // <= is < or ==. + lessThan, err := lt(arg1, arg2) + if lessThan || err != nil { + return lessThan, err + } + return eq(arg1, arg2) +} + +// gt evaluates the comparison a > b. +func gt(arg1, arg2 reflect.Value) (bool, error) { + // > is the inverse of <=. + lessOrEqual, err := le(arg1, arg2) + if err != nil { + return false, err + } + return !lessOrEqual, nil +} + +// ge evaluates the comparison a >= b. +func ge(arg1, arg2 reflect.Value) (bool, error) { + // >= is the inverse of <. + lessThan, err := lt(arg1, arg2) + if err != nil { + return false, err + } + return !lessThan, nil +} + +// HTML escaping. + +var ( + htmlQuot = []byte(""") // shorter than """ + htmlApos = []byte("'") // shorter than "'" and apos was not in HTML until HTML5 + htmlAmp = []byte("&") + htmlLt = []byte("<") + htmlGt = []byte(">") + htmlNull = []byte("\uFFFD") +) + +// HTMLEscape writes to w the escaped HTML equivalent of the plain text data b. +func HTMLEscape(w io.Writer, b []byte) { + last := 0 + for i, c := range b { + var html []byte + switch c { + case '\000': + html = htmlNull + case '"': + html = htmlQuot + case '\'': + html = htmlApos + case '&': + html = htmlAmp + case '<': + html = htmlLt + case '>': + html = htmlGt + default: + continue + } + w.Write(b[last:i]) + w.Write(html) + last = i + 1 + } + w.Write(b[last:]) +} + +// HTMLEscapeString returns the escaped HTML equivalent of the plain text data s. +func HTMLEscapeString(s string) string { + // Avoid allocation if we can. + if !strings.ContainsAny(s, "'\"&<>\000") { + return s + } + var b strings.Builder + HTMLEscape(&b, []byte(s)) + return b.String() +} + +// HTMLEscaper returns the escaped HTML equivalent of the textual +// representation of its arguments. +func HTMLEscaper(args ...any) string { + return HTMLEscapeString(evalArgs(args)) +} + +// JavaScript escaping. + +var ( + jsLowUni = []byte(`\u00`) + hex = []byte("0123456789ABCDEF") + + jsBackslash = []byte(`\\`) + jsApos = []byte(`\'`) + jsQuot = []byte(`\"`) + jsLt = []byte(`\u003C`) + jsGt = []byte(`\u003E`) + jsAmp = []byte(`\u0026`) + jsEq = []byte(`\u003D`) +) + +// JSEscape writes to w the escaped JavaScript equivalent of the plain text data b. +func JSEscape(w io.Writer, b []byte) { + last := 0 + for i := 0; i < len(b); i++ { + c := b[i] + + if !jsIsSpecial(rune(c)) { + // fast path: nothing to do + continue + } + w.Write(b[last:i]) + + if c < utf8.RuneSelf { + // Quotes, slashes and angle brackets get quoted. + // Control characters get written as \u00XX. + switch c { + case '\\': + w.Write(jsBackslash) + case '\'': + w.Write(jsApos) + case '"': + w.Write(jsQuot) + case '<': + w.Write(jsLt) + case '>': + w.Write(jsGt) + case '&': + w.Write(jsAmp) + case '=': + w.Write(jsEq) + default: + w.Write(jsLowUni) + t, b := c>>4, c&0x0f + w.Write(hex[t : t+1]) + w.Write(hex[b : b+1]) + } + } else { + // Unicode rune. + r, size := utf8.DecodeRune(b[i:]) + if unicode.IsPrint(r) { + w.Write(b[i : i+size]) + } else { + fmt.Fprintf(w, "\\u%04X", r) + } + i += size - 1 + } + last = i + 1 + } + w.Write(b[last:]) +} + +// JSEscapeString returns the escaped JavaScript equivalent of the plain text data s. +func JSEscapeString(s string) string { + // Avoid allocation if we can. + if strings.IndexFunc(s, jsIsSpecial) < 0 { + return s + } + var b strings.Builder + JSEscape(&b, []byte(s)) + return b.String() +} + +func jsIsSpecial(r rune) bool { + switch r { + case '\\', '\'', '"', '<', '>', '&', '=': + return true + } + return r < ' ' || utf8.RuneSelf <= r +} + +// JSEscaper returns the escaped JavaScript equivalent of the textual +// representation of its arguments. +func JSEscaper(args ...any) string { + return JSEscapeString(evalArgs(args)) +} + +// URLQueryEscaper returns the escaped value of the textual representation of +// its arguments in a form suitable for embedding in a URL query. +func URLQueryEscaper(args ...any) string { + return url.QueryEscape(evalArgs(args)) +} + +// evalArgs formats the list of arguments into a string. It is therefore equivalent to +// +// fmt.Sprint(args...) +// +// except that each argument is indirected (if a pointer), as required, +// using the same rules as the default string evaluation during template +// execution. +func evalArgs(args []any) string { + ok := false + var s string + // Fast path for simple common case. + if len(args) == 1 { + s, ok = args[0].(string) + } + if !ok { + for i, arg := range args { + a, ok := printableValue(reflect.ValueOf(arg)) + if ok { + args[i] = a + } // else let fmt do its thing + } + s = fmt.Sprint(args...) + } + return s +} diff --git a/template/gotext/option.go b/template/gotext/option.go new file mode 100644 index 00000000..9e0344c1 --- /dev/null +++ b/template/gotext/option.go @@ -0,0 +1,72 @@ +// Copyright 2015 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This file contains the code to handle template options. + +package gotext + +import "strings" + +// missingKeyAction defines how to respond to indexing a map with a key that is not present. +type missingKeyAction int + +const ( + mapInvalid missingKeyAction = iota // Return an invalid reflect.Value. + mapZeroValue // Return the zero value for the map element. + mapError // Error out +) + +type option struct { + missingKey missingKeyAction +} + +// Option sets options for the template. Options are described by +// strings, either a simple string or "key=value". There can be at +// most one equals sign in an option string. If the option string +// is unrecognized or otherwise invalid, Option panics. +// +// Known options: +// +// missingkey: Control the behavior during execution if a map is +// indexed with a key that is not present in the map. +// +// "missingkey=default" or "missingkey=invalid" +// The default behavior: Do nothing and continue execution. +// If printed, the result of the index operation is the string +// "". +// "missingkey=zero" +// The operation returns the zero value for the map type's element. +// "missingkey=error" +// Execution stops immediately with an error. +func (t *Template) Option(opt ...string) *Template { + t.init() + for _, s := range opt { + t.setOption(s) + } + return t +} + +func (t *Template) setOption(opt string) { + if opt == "" { + panic("empty option string") + } + // key=value + if key, value, ok := strings.Cut(opt, "="); ok { + switch key { + case "missingkey": + switch value { + case "invalid", "default": + t.option.missingKey = mapInvalid + return + case "zero": + t.option.missingKey = mapZeroValue + return + case "error": + t.option.missingKey = mapError + return + } + } + } + panic("unrecognized option: " + opt) +} diff --git a/template/gotext/patches/0001-exec-deadline.patch b/template/gotext/patches/0001-exec-deadline.patch new file mode 100644 index 00000000..333bbdf6 --- /dev/null +++ b/template/gotext/patches/0001-exec-deadline.patch @@ -0,0 +1,151 @@ +diff -ruN a/exec.go b/exec.go +--- a/exec.go 2026-07-08 15:38:43.551040811 +0200 ++++ b/exec.go 2026-07-08 15:38:43.553556913 +0200 +@@ -2,17 +2,19 @@ + // Use of this source code is governed by a BSD-style + // license that can be found in the LICENSE file. + +-package template ++package gotext + + import ( + "errors" + "fmt" +- "internal/fmtsort" + "io" + "reflect" + "runtime" + "strings" + "text/template/parse" ++ "time" ++ ++ "heckel.io/ntfy/v2/template/gotext/fmtsort" + ) + + // maxExecDepth specifies the maximum stack depth of templates within +@@ -32,11 +34,13 @@ + // template so that multiple executions of the same template + // can execute in parallel. + type state struct { +- tmpl *Template +- wr io.Writer +- node parse.Node // current node, for errors +- vars []variable // push-down stack of variable values. +- depth int // the height of the stack of executing templates. ++ tmpl *Template ++ wr io.Writer ++ node parse.Node // current node, for errors ++ vars []variable // push-down stack of variable values. ++ depth int // the height of the stack of executing templates. ++ deadline time.Time // ntfy: wall-clock bail-out; zero means no limit ++ steps int64 // ntfy: node counter for amortized deadline checks + } + + // variable holds the dynamic value of a variable such as $, $x etc. +@@ -131,6 +135,10 @@ + return e.Err + } + ++// ErrExecutionInterrupted is wrapped into the error returned by Execute when a template exceeds the ++// deadline set via Template.SetExecutionDeadline. Detect it with errors.Is. (ntfy addition) ++var ErrExecutionInterrupted = errors.New("template execution interrupted") ++ + // errorf records an ExecError and terminates processing. + func (s *state) errorf(format string, args ...any) { + name := doublePercent(s.tmpl.Name()) +@@ -214,9 +222,10 @@ + value = reflect.ValueOf(data) + } + state := &state{ +- tmpl: t, +- wr: wr, +- vars: []variable{{"$", value}}, ++ tmpl: t, ++ wr: wr, ++ vars: []variable{{"$", value}}, ++ deadline: t.deadline, // ntfy: wall-clock execution bail-out + } + if t.Tree == nil || t.Root == nil { + state.errorf("%q is an incomplete or empty template", t.Name()) +@@ -260,6 +269,11 @@ + // generating output as they go. + func (s *state) walk(dot reflect.Value, node parse.Node) { + s.at(node) ++ // ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates ++ // (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp. ++ if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) { ++ s.errorf("execution interrupted: %w", ErrExecutionInterrupted) ++ } + switch node := node.(type) { + case *parse.ActionNode: + // Do not pop variables so they persist until next end. +diff -ruN a/funcs.go b/funcs.go +--- a/funcs.go 2026-07-08 15:38:43.551127782 +0200 ++++ b/funcs.go 2026-07-08 15:38:43.553627333 +0200 +@@ -2,7 +2,7 @@ + // Use of this source code is governed by a BSD-style + // license that can be found in the LICENSE file. + +-package template ++package gotext + + import ( + "errors" +diff -ruN a/option.go b/option.go +--- a/option.go 2026-07-08 15:38:43.551232856 +0200 ++++ b/option.go 2026-07-08 15:38:43.553688366 +0200 +@@ -4,7 +4,7 @@ + + // This file contains the code to handle template options. + +-package template ++package gotext + + import "strings" + +diff -ruN a/template.go b/template.go +--- a/template.go 2026-07-08 15:38:43.551182684 +0200 ++++ b/template.go 2026-07-08 15:38:43.553660525 +0200 +@@ -2,20 +2,22 @@ + // Use of this source code is governed by a BSD-style + // license that can be found in the LICENSE file. + +-package template ++package gotext + + import ( + "maps" + "reflect" + "sync" + "text/template/parse" ++ "time" + ) + + // common holds the information shared by related templates. + type common struct { +- tmpl map[string]*Template // Map from name to defined templates. +- muTmpl sync.RWMutex // protects tmpl +- option option ++ tmpl map[string]*Template // Map from name to defined templates. ++ muTmpl sync.RWMutex // protects tmpl ++ option option ++ deadline time.Time // ntfy: wall-clock execution deadline (zero = none) + // We use two maps, one for parsing and one for execution. + // This separation makes the API cleaner since it doesn't + // expose reflection to the client. +@@ -49,6 +51,15 @@ + return t.name + } + ++// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping ++// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates ++// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.) ++func (t *Template) SetExecutionDeadline(deadline time.Time) *Template { ++ t.init() ++ t.deadline = deadline ++ return t ++} ++ + // New allocates a new, undefined template associated with the given one and with the same + // delimiters. The association, which is transitive, allows one template to + // invoke another with a {{template}} action. diff --git a/template/gotext/template.go b/template/gotext/template.go new file mode 100644 index 00000000..cbadd1e0 --- /dev/null +++ b/template/gotext/template.go @@ -0,0 +1,247 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gotext + +import ( + "maps" + "reflect" + "sync" + "text/template/parse" + "time" +) + +// common holds the information shared by related templates. +type common struct { + tmpl map[string]*Template // Map from name to defined templates. + muTmpl sync.RWMutex // protects tmpl + option option + deadline time.Time // ntfy: wall-clock execution deadline (zero = none) + // We use two maps, one for parsing and one for execution. + // This separation makes the API cleaner since it doesn't + // expose reflection to the client. + muFuncs sync.RWMutex // protects parseFuncs and execFuncs + parseFuncs FuncMap + execFuncs map[string]reflect.Value +} + +// Template is the representation of a parsed template. The *parse.Tree +// field is exported only for use by [html/template] and should be treated +// as unexported by all other clients. +type Template struct { + name string + *parse.Tree + *common + leftDelim string + rightDelim string +} + +// New allocates a new, undefined template with the given name. +func New(name string) *Template { + t := &Template{ + name: name, + } + t.init() + return t +} + +// Name returns the name of the template. +func (t *Template) Name() string { + return t.name +} + +// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping +// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates +// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.) +func (t *Template) SetExecutionDeadline(deadline time.Time) *Template { + t.init() + t.deadline = deadline + return t +} + +// New allocates a new, undefined template associated with the given one and with the same +// delimiters. The association, which is transitive, allows one template to +// invoke another with a {{template}} action. +// +// Because associated templates share underlying data, template construction +// cannot be done safely in parallel. Once the templates are constructed, they +// can be executed in parallel. +func (t *Template) New(name string) *Template { + t.init() + nt := &Template{ + name: name, + common: t.common, + leftDelim: t.leftDelim, + rightDelim: t.rightDelim, + } + return nt +} + +// init guarantees that t has a valid common structure. +func (t *Template) init() { + if t.common == nil { + c := new(common) + c.tmpl = make(map[string]*Template) + c.parseFuncs = make(FuncMap) + c.execFuncs = make(map[string]reflect.Value) + t.common = c + } +} + +// Clone returns a duplicate of the template, including all associated +// templates. The actual representation is not copied, but the name space of +// associated templates is, so further calls to [Template.Parse] in the copy will add +// templates to the copy but not to the original. Clone can be used to prepare +// common templates and use them with variant definitions for other templates +// by adding the variants after the clone is made. +func (t *Template) Clone() (*Template, error) { + nt := t.copy(nil) + nt.init() + if t.common == nil { + return nt, nil + } + nt.option = t.option + t.muTmpl.RLock() + defer t.muTmpl.RUnlock() + for k, v := range t.tmpl { + if k == t.name { + nt.tmpl[t.name] = nt + continue + } + // The associated templates share nt's common structure. + tmpl := v.copy(nt.common) + nt.tmpl[k] = tmpl + } + t.muFuncs.RLock() + defer t.muFuncs.RUnlock() + maps.Copy(nt.parseFuncs, t.parseFuncs) + maps.Copy(nt.execFuncs, t.execFuncs) + return nt, nil +} + +// copy returns a shallow copy of t, with common set to the argument. +func (t *Template) copy(c *common) *Template { + return &Template{ + name: t.name, + Tree: t.Tree, + common: c, + leftDelim: t.leftDelim, + rightDelim: t.rightDelim, + } +} + +// AddParseTree associates the argument parse tree with the template t, giving +// it the specified name. If the template has not been defined, this tree becomes +// its definition. If it has been defined and already has that name, the existing +// definition is replaced; otherwise a new template is created, defined, and returned. +func (t *Template) AddParseTree(name string, tree *parse.Tree) (*Template, error) { + t.init() + t.muTmpl.Lock() + defer t.muTmpl.Unlock() + nt := t + if name != t.name { + nt = t.New(name) + } + // Even if nt == t, we need to install it in the common.tmpl map. + if t.associate(nt, tree) || nt.Tree == nil { + nt.Tree = tree + } + return nt, nil +} + +// Templates returns a slice of defined templates associated with t. +func (t *Template) Templates() []*Template { + if t.common == nil { + return nil + } + // Return a slice so we don't expose the map. + t.muTmpl.RLock() + defer t.muTmpl.RUnlock() + m := make([]*Template, 0, len(t.tmpl)) + for _, v := range t.tmpl { + m = append(m, v) + } + return m +} + +// Delims sets the action delimiters to the specified strings, to be used in +// subsequent calls to [Template.Parse], [Template.ParseFiles], or [Template.ParseGlob]. Nested template +// definitions will inherit the settings. An empty delimiter stands for the +// corresponding default: {{ or }}. +// The return value is the template, so calls can be chained. +func (t *Template) Delims(left, right string) *Template { + t.init() + t.leftDelim = left + t.rightDelim = right + return t +} + +// Funcs adds the elements of the argument map to the template's function map. +// It must be called before the template is parsed. +// It panics if a value in the map is not a function with appropriate return +// type or if the name cannot be used syntactically as a function in a template. +// It is legal to overwrite elements of the map. The return value is the template, +// so calls can be chained. +func (t *Template) Funcs(funcMap FuncMap) *Template { + t.init() + t.muFuncs.Lock() + defer t.muFuncs.Unlock() + addValueFuncs(t.execFuncs, funcMap) + addFuncs(t.parseFuncs, funcMap) + return t +} + +// Lookup returns the template with the given name that is associated with t. +// It returns nil if there is no such template or the template has no definition. +func (t *Template) Lookup(name string) *Template { + if t.common == nil { + return nil + } + t.muTmpl.RLock() + defer t.muTmpl.RUnlock() + return t.tmpl[name] +} + +// Parse parses text as a template body for t. +// Named template definitions ({{define ...}} or {{block ...}} statements) in text +// define additional templates associated with t and are removed from the +// definition of t itself. +// +// Templates can be redefined in successive calls to Parse. +// A template definition with a body containing only white space and comments +// is considered empty and will not replace an existing template's body. +// This allows using Parse to add new named template definitions without +// overwriting the main template body. +func (t *Template) Parse(text string) (*Template, error) { + t.init() + t.muFuncs.RLock() + trees, err := parse.Parse(t.name, text, t.leftDelim, t.rightDelim, t.parseFuncs, builtins()) + t.muFuncs.RUnlock() + if err != nil { + return nil, err + } + // Add the newly parsed trees, including the one for t, into our common structure. + for name, tree := range trees { + if _, err := t.AddParseTree(name, tree); err != nil { + return nil, err + } + } + return t, nil +} + +// associate installs the new template into the group of templates associated +// with t. The two are already known to share the common structure. +// The boolean return value reports whether to store this tree as t.Tree. +func (t *Template) associate(new *Template, tree *parse.Tree) bool { + if new.common != t.common { + panic("internal error: associate not common") + } + if old := t.tmpl[new.name]; old != nil && parse.IsEmptyTree(tree.Root) && old.Tree != nil { + // If a template by that name exists, + // don't replace it with an empty template. + return false + } + t.tmpl[new.name] = new + return true +} diff --git a/util/sprig/functions.go b/util/sprig/functions.go index 8dbb23f8..72b46aaf 100644 --- a/util/sprig/functions.go +++ b/util/sprig/functions.go @@ -5,7 +5,6 @@ import ( "path/filepath" "reflect" "strings" - "text/template" "time" ) @@ -21,8 +20,9 @@ const ( // // tpl := template.New("foo").Funcs(sprig.FuncMap())) // -// TxtFuncMap returns a 'text/template'.FuncMap -func TxtFuncMap() template.FuncMap { +// TxtFuncMap returns the function map as a plain map[string]any, assignable to any text/template or +// html/template FuncMap (including ntfy's vendored internal/template). +func TxtFuncMap() map[string]any { return map[string]any{ // Date functions "ago": dateAgo, diff --git a/util/timeout_writer.go b/util/timeout_writer.go deleted file mode 100644 index d531916d..00000000 --- a/util/timeout_writer.go +++ /dev/null @@ -1,34 +0,0 @@ -package util - -import ( - "errors" - "io" - "time" -) - -// ErrWriteTimeout is returned when a write timed out -var ErrWriteTimeout = errors.New("write operation failed due to timeout") - -// TimeoutWriter wraps an io.Writer that will time out after the given timeout -type TimeoutWriter struct { - writer io.Writer - timeout time.Duration - start time.Time -} - -// NewTimeoutWriter creates a new TimeoutWriter -func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter { - return &TimeoutWriter{ - writer: w, - timeout: timeout, - start: time.Now(), - } -} - -// Write implements the io.Writer interface, failing if called after the timeout period from creation. -func (tw *TimeoutWriter) Write(p []byte) (n int, err error) { - if time.Since(tw.start) > tw.timeout { - return 0, ErrWriteTimeout - } - return tw.writer.Write(p) -}