Files
gotenberg/pkg/gotenberg/pattern_test.go
2026-09-17 15:33:55 +02:00

100 lines
2.9 KiB
Go

package gotenberg
import (
"strings"
"testing"
"time"
"github.com/dlclark/regexp2/v2"
)
// mustPattern compiles a pattern the way the production lists are built.
func mustPattern(t *testing.T, expr string) *regexp2.Regexp {
t.Helper()
re := regexp2.MustCompile(expr, regexp2.None)
re.MatchTimeout = PatternMatchTimeout
return re
}
func TestMatchPattern(t *testing.T) {
// The abort [MatchPattern] absorbs cannot be staged here: it needs the
// whole process to lose the CPU around one specific match, which no test
// can schedule. What is testable is the other half of the contract, that
// retrying never turns a genuine runaway into a pass.
// See https://github.com/gotenberg/gotenberg/issues/1659.
for _, tc := range []struct {
scenario string
pattern *regexp2.Regexp
s string
expectMatch bool
expectError bool
}{
{
scenario: "deny-list match",
pattern: mustPattern(t, `^file:(?!//\/tmp/).*`),
s: "file:///etc/passwd",
expectMatch: true,
},
{
scenario: "no match against Gotenberg's own working directory",
pattern: mustPattern(t, `^file:(?!//\/tmp/).*`),
s: "file:///tmp/1a2b3c4d/5e6f7a8b/9c0d1e2f.html",
expectMatch: false,
},
{
scenario: "no match",
pattern: mustPattern(t, `^https://example\.com/`),
s: "https://example.org/",
expectMatch: false,
},
{
scenario: "catastrophic backtracking still aborts",
pattern: mustPattern(t, `^https://example\.com/(a+)+$`),
s: "https://example.com/" + strings.Repeat("a", 40) + "!",
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ok, err := MatchPattern(tc.pattern, tc.s)
if tc.expectError && err == nil {
t.Fatal("expected an error but got none")
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if ok != tc.expectMatch {
t.Fatalf("expected match %t but got %t", tc.expectMatch, ok)
}
})
}
}
func TestMatchPatternBoundsCatastrophicPattern(t *testing.T) {
// Proving a runaway is genuine costs one ceiling per attempt, so the
// worst case is patternMatchAttempts of them plus regexp2's clock period
// on each, roughly a second. The bound that matters is the one this
// replaced: before the ceiling existed, the same match was allowed to
// burn a core for the caller's whole 30s budget.
pattern := mustPattern(t, `^https://example\.com/(a+)+$`)
s := "https://example.com/" + strings.Repeat("a", 40) + "!"
start := time.Now()
_, err := MatchPattern(pattern, s)
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected an error from a catastrophic pattern")
}
// Generous headroom over the expected second keeps this stable on a
// loaded CI box while still failing if the bound is gone.
if elapsed > 5*time.Second {
t.Fatalf("match took %s, want at most %d ceilings of %s", elapsed, patternMatchAttempts, PatternMatchTimeout)
}
}