From 4e8de214bc77bce405a5355a9d17d4845823c684 Mon Sep 17 00:00:00 2001 From: Mohammed Al Sahaf Date: Sun, 25 Oct 2026 01:32:06 +0300 Subject: [PATCH] replacer: cache file lookups and cap them (#8248) * replacer: cache file lookups and cap them Signed-off-by: Mohammed Al Sahaf * replacer: cache repeated file placeholder lookups Memoize file placeholder results per replacement call, using an 8 MiB LRU for contents. Preserve empty and failed-read results without changing the output for unknown placeholders. Signed-off-by: Mohammed Al Sahaf --------- Signed-off-by: Mohammed Al Sahaf --- replacer.go | 82 +++++++++++++++++++++++++++++++- replacer_test.go | 120 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) diff --git a/replacer.go b/replacer.go index 050c85466..7a324165d 100644 --- a/replacer.go +++ b/replacer.go @@ -16,6 +16,7 @@ package caddy import ( "bytes" + "container/list" "fmt" "io" "net/http" @@ -192,6 +193,9 @@ func (r *Replacer) replace(input, empty string, // fail fast if too many placeholders are unclosed var unclosedCount int + // {file.*} lookups hit the disk, so repeats within one call are memoized + var files *fileCache + scan: for i := 0; i < len(input); i++ { // check for escaped braces @@ -236,7 +240,17 @@ scan: key := input[i+1 : end] // try to get a value for this key, handle empty values accordingly - val, found := r.Get(key) + var val any + var found bool + isFileKey := strings.HasPrefix(key, filePrefix) + if files == nil && isFileKey && r.hasFileProvider() { + files = new(fileCache) + } + if files != nil && isFileKey { + val, found = files.get(key, r.Get) + } else { + val, found = r.Get(key) + } if !found { // placeholder is unknown (unrecognized); handle accordingly if errOnUnknown { @@ -372,6 +386,69 @@ func (f fileReplacementProvider) replace(key string) (any, bool) { return string(body), true } +func (r *Replacer) hasFileProvider() bool { + for _, p := range r.providers { + if _, ok := p.(fileReplacementProvider); ok { + return true + } + } + return false +} + +// fileCache memoizes {file.*} lookups for one replace call. Contents live in a +// byte-bounded LRU; misses and empty results cost no more than their key. +type fileCache struct { + lru *list.List + entries map[string]*list.Element + size int + emptyResults map[string]any +} + +type fileCacheEntry struct { + key string + val any + size int +} + +func (c *fileCache) get(key string, load func(string) (any, bool)) (any, bool) { + if el, ok := c.entries[key]; ok { + c.lru.MoveToFront(el) + return el.Value.(*fileCacheEntry).val, true + } + if val, ok := c.emptyResults[key]; ok { + return val, true + } + + val, found := load(key) + if !found { + return val, found + } + switch s, isStr := val.(string); { + case val == nil || (isStr && s == ""): + if c.emptyResults == nil { + c.emptyResults = make(map[string]any) + } + c.emptyResults[key] = val + case isStr && len(s) <= maxFileCacheBytes: + c.add(key, val, len(s)) + } + return val, found +} + +func (c *fileCache) add(key string, val any, size int) { + if c.lru == nil { + c.lru = list.New() + c.entries = make(map[string]*list.Element) + } + for c.size+size > maxFileCacheBytes { + evicted := c.lru.Remove(c.lru.Back()).(*fileCacheEntry) + delete(c.entries, evicted.key) + c.size -= evicted.size + } + c.entries[key] = c.lru.PushFront(&fileCacheEntry{key: key, val: val, size: size}) + c.size += size +} + // globalDefaultReplacementProvider handles replacements // that can be used in any context, such as system variables, // time, or environment variables. @@ -453,3 +530,6 @@ const ReplacerCtxKey CtxKey = "replacer" const phOpen, phClose, phEscape = '{', '}', '\\' const filePrefix = "file." + +// maxFileCacheBytes bounds the memoized file contents to 8 MiB (8,388,608 bytes) per replace call. +const maxFileCacheBytes = 8 << 20 diff --git a/replacer_test.go b/replacer_test.go index 4b3b3217d..eecff5541 100644 --- a/replacer_test.go +++ b/replacer_test.go @@ -19,6 +19,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "sync" "testing" "time" @@ -518,6 +519,125 @@ func TestReplacerTimeNowRFC3339(t *testing.T) { } } +func TestReplacerRepeatedFilePlaceholders(t *testing.T) { + dir := t.TempDir() + fileA := filepath.Join(dir, "a.txt") + fileB := filepath.Join(dir, "b.txt") + missing := filepath.Join(dir, "missing.txt") + if err := os.WriteFile(fileA, []byte("alpha\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fileB, []byte("beta"), 0o600); err != nil { + t.Fatal(err) + } + phA, phB, phMissing := "{file."+fileA+"}", "{file."+fileB+"}", "{file."+missing+"}" + + for i, tc := range []struct { + repl func() *Replacer + known bool + input string + expected string + }{ + { + repl: NewReplacer, + input: phA + "|" + phA + "|" + phB + "|" + phA, + expected: "alpha|alpha|beta|alpha", + }, + { + repl: NewReplacer, + input: strings.Repeat(phA, 20000), + expected: strings.Repeat("alpha", 20000), + }, + { + repl: NewReplacer, + known: true, + input: phMissing + "|" + phMissing, + expected: "EMPTY|EMPTY", + }, + { + repl: NewReplacer, + known: false, + input: phMissing + "|" + phMissing, + expected: "EMPTY|EMPTY", + }, + { + repl: NewEmptyReplacer, + known: true, + input: phMissing + "|" + phMissing, + expected: phMissing + "|" + phMissing, + }, + { + repl: func() *Replacer { return NewReplacer().WithoutFile() }, + known: true, + input: phA + "|" + phA, + expected: phA + "|" + phA, + }, + } { + var actual string + if tc.known { + actual = tc.repl().ReplaceKnown(tc.input, "EMPTY") + } else { + actual = tc.repl().ReplaceAll(tc.input, "EMPTY") + } + if actual != tc.expected { + t.Errorf("Test %d: expected %.80q, got %.80q", i, tc.expected, actual) + } + } +} + +func TestFileCacheEviction(t *testing.T) { + big := strings.Repeat("x", maxFileCacheBytes/2+1) + var loads int + load := func(key string) (any, bool) { + loads++ + if key == "file.unknown" { + return nil, false + } + return key + big, true + } + + var c fileCache + for i, tc := range []struct { + key string + found bool + wantLoads int + }{ + {key: "file.a", found: true, wantLoads: 1}, + {key: "file.a", found: true, wantLoads: 1}, + {key: "file.b", found: true, wantLoads: 2}, // evicts file.a + {key: "file.b", found: true, wantLoads: 2}, + {key: "file.a", found: true, wantLoads: 3}, + {key: "file.unknown", found: false, wantLoads: 4}, + {key: "file.unknown", found: false, wantLoads: 5}, + } { + val, found := c.get(tc.key, load) + if found != tc.found { + t.Errorf("Test %d: expected found=%t, got %t", i, tc.found, found) + } + if tc.found && val != tc.key+big { + t.Errorf("Test %d: wrong value for %s", i, tc.key) + } + if loads != tc.wantLoads { + t.Errorf("Test %d: expected %d loads, got %d", i, tc.wantLoads, loads) + } + if c.size > maxFileCacheBytes { + t.Errorf("Test %d: cache holds %d bytes, over the %d limit", i, c.size, maxFileCacheBytes) + } + } +} + +func BenchmarkReplacerRepeatedFile(b *testing.B) { + file := filepath.Join(b.TempDir(), "a.txt") + if err := os.WriteFile(file, []byte("alpha"), 0o600); err != nil { + b.Fatal(err) + } + input := strings.Repeat("{file."+file+"}", 1000) + rep := NewReplacer() + for b.Loop() { + rep.ReplaceAll(input, "") + } +} + func BenchmarkReplacer(b *testing.B) { type testCase struct { name, input, empty string