caddy/replacer_test.go

687 lines
15 KiB
Go

// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package caddy
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func TestReplacer(t *testing.T) {
type testCase struct {
input, expect, empty string
}
rep := testReplacer()
// ReplaceAll
for i, tc := range []testCase{
{
input: "{",
expect: "{",
},
{
input: `\{`,
expect: `{`,
},
{
input: "foo{",
expect: "foo{",
},
{
input: `foo\{`,
expect: `foo{`,
},
{
input: "foo{bar",
expect: "foo{bar",
},
{
input: `foo\{bar`,
expect: `foo{bar`,
},
{
input: "foo{bar}",
expect: "foo",
},
{
input: `foo\{bar\}`,
expect: `foo{bar}`,
},
{
input: "}",
expect: "}",
},
{
input: `\}`,
expect: `}`,
},
{
input: "{}",
expect: "",
},
{
input: `\{\}`,
expect: `{}`,
},
{
input: `{"json": "object"}`,
expect: "",
},
{
input: `\{"json": "object"}`,
expect: `{"json": "object"}`,
},
{
input: `\{"json": "object"\}`,
expect: `{"json": "object"}`,
},
{
input: `\{"json": "object{bar}"\}`,
expect: `{"json": "object"}`,
},
{
input: `\{"json": \{"nested": "object"\}\}`,
expect: `{"json": {"nested": "object"}}`,
},
{
input: `\{"json": \{"nested": "{bar}"\}\}`,
expect: `{"json": {"nested": ""}}`,
},
{
input: `pre \{"json": \{"nested": "{bar}"\}\}`,
expect: `pre {"json": {"nested": ""}}`,
},
{
input: `\{"json": \{"nested": "{bar}"\}\} post`,
expect: `{"json": {"nested": ""}} post`,
},
{
input: `pre \{"json": \{"nested": "{bar}"\}\} post`,
expect: `pre {"json": {"nested": ""}} post`,
},
{
input: `{{`,
expect: "{{",
},
{
input: `{{}`,
expect: "",
},
{
input: `{"json": "object"\}`,
expect: "",
},
{
input: `{unknown}`,
empty: "-",
expect: "-",
},
{
input: `back\slashes`,
expect: `back\slashes`,
},
{
input: `double back\\slashes`,
expect: `double back\\slashes`,
},
{
input: `placeholder {with \{ brace} in name`,
expect: `placeholder in name`,
},
{
input: `placeholder {with \} brace} in name`,
expect: `placeholder in name`,
},
{
input: `placeholder {with \} \} braces} in name`,
expect: `placeholder in name`,
},
{
input: `\{'group':'default','max_age':3600,'endpoints':[\{'url':'https://some.domain.local/a/d/g'\}],'include_subdomains':true\}`,
expect: `{'group':'default','max_age':3600,'endpoints':[{'url':'https://some.domain.local/a/d/g'}],'include_subdomains':true}`,
},
{
input: `{}{}{}{\\\\}\\\\`,
expect: `{\\\}\\\\`,
},
{
input: string([]byte{0x26, 0x00, 0x83, 0x7B, 0x84, 0x07, 0x5C, 0x7D, 0x84}),
expect: string([]byte{0x26, 0x00, 0x83, 0x7B, 0x84, 0x07, 0x7D, 0x84}),
},
{
input: `\\}`,
expect: `\}`,
},
} {
actual := rep.ReplaceAll(tc.input, tc.empty)
if actual != tc.expect {
t.Errorf("Test %d: '%s': expected '%s' but got '%s'",
i, tc.input, tc.expect, actual)
}
}
}
func TestReplacerSet(t *testing.T) {
rep := testReplacer()
for _, tc := range []struct {
variable string
value any
}{
{
variable: "test1",
value: "val1",
},
{
variable: "asdf",
value: "123",
},
{
variable: "numbers",
value: 123.456,
},
{
variable: "äöü",
value: "öö_äü",
},
{
variable: "with space",
value: "space value",
},
{
variable: "1",
value: "test-123",
},
{
variable: "mySuper_IP",
value: "1.2.3.4",
},
{
variable: "testEmpty",
value: "",
},
} {
rep.Set(tc.variable, tc.value)
// test if key is added
if val, ok := rep.static[tc.variable]; ok {
if val != tc.value {
t.Errorf("Expected value '%s' for key '%s' got '%s'", tc.value, tc.variable, val)
}
} else {
t.Errorf("Expected existing key '%s' found nothing", tc.variable)
}
}
// test if all keys are still there (by length)
length := len(rep.static)
if len(rep.static) != 8 {
t.Errorf("Expected length '%v' got '%v'", 7, length)
}
}
func TestReplacerReplaceKnown(t *testing.T) {
rep := Replacer{
mapMutex: &sync.RWMutex{},
providers: []replacementProvider{
// split our possible vars to two functions (to test if both functions are called)
ReplacerFunc(func(key string) (val any, ok bool) {
switch key {
case "test1":
return "val1", true
case "asdf":
return "123", true
case "äöü":
return "öö_äü", true
case "with space":
return "space value", true
default:
return "NOOO", false
}
}),
ReplacerFunc(func(key string) (val any, ok bool) {
switch key {
case "1":
return "test-123", true
case "mySuper_IP":
return "1.2.3.4", true
case "testEmpty":
return "", true
default:
return "NOOO", false
}
}),
},
}
for _, tc := range []struct {
testInput string
expected string
}{
{
// test vars without space
testInput: "{test1}{asdf}{äöü}{1}{with space}{mySuper_IP}",
expected: "val1123öö_äütest-123space value1.2.3.4",
},
{
// test vars with space
testInput: "{test1} {asdf} {äöü} {1} {with space} {mySuper_IP} ",
expected: "val1 123 öö_äü test-123 space value 1.2.3.4 ",
},
{
// test with empty val
testInput: "{test1} {testEmpty} {asdf} {1} ",
expected: "val1 EMPTY 123 test-123 ",
},
{
// test vars with not finished placeholders
testInput: "{te{test1}{as{{df{1}",
expected: "{teval1{as{{dftest-123",
},
{
// test with non existing vars
testInput: "{test1} {nope} {1} ",
expected: "val1 {nope} test-123 ",
},
} {
actual := rep.ReplaceKnown(tc.testInput, "EMPTY")
// test if all are replaced as expected
if actual != tc.expected {
t.Errorf("Expected '%s' got '%s' for '%s'", tc.expected, actual, tc.testInput)
}
}
}
func TestReplacerDelete(t *testing.T) {
rep := Replacer{
mapMutex: &sync.RWMutex{},
static: map[string]any{
"key1": "val1",
"key2": "val2",
"key3": "val3",
"key4": "val4",
},
}
startLen := len(rep.static)
toDel := []string{
"key2", "key4",
}
for _, key := range toDel {
rep.Delete(key)
// test if key is removed from static map
if _, ok := rep.static[key]; ok {
t.Errorf("Expected '%s' to be removed. It is still in static map.", key)
}
}
// check if static slice is smaller
expected := startLen - len(toDel)
actual := len(rep.static)
if len(rep.static) != expected {
t.Errorf("Expected length '%v' got length '%v'", expected, actual)
}
}
func TestReplacerMap(t *testing.T) {
rep := testReplacer()
for i, tc := range []ReplacerFunc{
func(key string) (val any, ok bool) {
return "", false
},
func(key string) (val any, ok bool) {
return "", false
},
} {
rep.Map(tc)
// test if function (which listens on specific key) is added by checking length
if len(rep.providers) == i+1 {
// check if the last function is the one we just added
pTc := fmt.Sprintf("%p", tc)
pRep := fmt.Sprintf("%p", rep.providers[i])
if pRep != pTc {
t.Errorf("Expected func pointer '%s' got '%s'", pTc, pRep)
}
} else {
t.Errorf("Expected providers length '%v' got length '%v'", i+1, len(rep.providers))
}
}
}
func TestReplacerNew(t *testing.T) {
repl := NewReplacer()
if len(repl.providers) != 3 {
t.Errorf("Expected providers length '%v' got length '%v'", 3, len(repl.providers))
}
// test if default global replacements are added as the first provider
hostname, _ := os.Hostname()
wd, _ := os.Getwd()
os.Setenv("CADDY_REPLACER_TEST", "envtest")
defer os.Setenv("CADDY_REPLACER_TEST", "")
for _, tc := range []struct {
variable string
value string
}{
{
variable: "system.hostname",
value: hostname,
},
{
variable: "system.slash",
value: string(filepath.Separator),
},
{
variable: "system.os",
value: runtime.GOOS,
},
{
variable: "system.arch",
value: runtime.GOARCH,
},
{
variable: "system.wd",
value: wd,
},
{
variable: "env.CADDY_REPLACER_TEST",
value: "envtest",
},
} {
if val, ok := repl.providers[0].replace(tc.variable); ok {
if val != tc.value {
t.Errorf("Expected value '%s' for key '%s' got '%s'", tc.value, tc.variable, val)
}
} else {
t.Errorf("Expected key '%s' to be recognized by first provider", tc.variable)
}
}
// test if file provider is added as the second provider
for _, tc := range []struct {
variable string
value string
}{
{
variable: "file.caddytest/integration/testdata/foo.txt",
value: "foo",
},
{
variable: "file.caddytest/integration/testdata/foo_with_trailing_newline.txt",
value: "foo",
},
{
variable: "file.caddytest/integration/testdata/foo_with_multiple_trailing_newlines.txt",
value: "foo" + getEOL(),
},
} {
if val, ok := repl.providers[1].replace(tc.variable); ok {
if val != tc.value {
t.Errorf("Expected value '%s' for key '%s' got '%s'", tc.value, tc.variable, val)
}
} else {
t.Errorf("Expected key '%s' to be recognized by second provider", tc.variable)
}
}
}
func getEOL() string {
if os.PathSeparator == '\\' {
return "\r\n" // Windows EOL
}
return "\n" // Unix and modern macOS EOL
}
func TestReplacerNewWithoutFile(t *testing.T) {
repl := NewReplacer().WithoutFile()
for _, tc := range []struct {
variable string
value string
notFound bool
}{
{
variable: "file.caddytest/integration/testdata/foo.txt",
notFound: true,
},
{
variable: "system.os",
value: runtime.GOOS,
},
} {
if val, ok := repl.Get(tc.variable); ok && !tc.notFound {
if val != tc.value {
t.Errorf("Expected value '%s' for key '%s' got '%s'", tc.value, tc.variable, val)
}
} else if !tc.notFound {
t.Errorf("Expected key '%s' to be recognized", tc.variable)
}
}
}
func TestReplacerTimeNowRFC3339(t *testing.T) {
originalNowFunc := nowFunc
t.Cleanup(func() { nowFunc = originalNowFunc })
for _, tc := range []struct {
name string
now time.Time
want string
}{
{
name: "UTC",
now: time.Date(2026, time.October, 3, 4, 30, 0, 123456789, time.UTC),
want: "2026-10-03T04:30:00Z",
},
{
name: "numeric offset",
now: time.Date(2026, time.October, 3, 10, 15, 0, 123456789, time.FixedZone("NPT", 5*60*60+45*60)),
want: "2026-10-03T10:15:00+05:45",
},
} {
t.Run(tc.name, func(t *testing.T) {
nowFunc = func() time.Time { return tc.now }
got, ok := NewReplacer().GetString("time.now.rfc3339")
if !ok {
t.Fatal("time.now.rfc3339 placeholder was not found")
}
if got != tc.want {
t.Fatalf("time.now.rfc3339 = %q; want %q", got, tc.want)
}
})
}
}
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
}
rep := testReplacer()
rep.Set("str", "a string")
rep.Set("int", 123.456)
for _, bm := range []testCase{
{
name: "no placeholder",
input: `simple string`,
},
{
name: "string replacement",
input: `str={str}`,
},
{
name: "int replacement",
input: `int={int}`,
},
{
name: "placeholder",
input: `{"json": "object"}`,
},
{
name: "escaped placeholder",
input: `\{"json": \{"nested": "{bar}"\}\}`,
},
} {
b.Run(bm.name, func(b *testing.B) {
for b.Loop() {
rep.ReplaceAll(bm.input, bm.empty)
}
})
}
}
func testReplacer() Replacer {
return Replacer{
providers: make([]replacementProvider, 0),
static: make(map[string]any),
mapMutex: &sync.RWMutex{},
}
}