Repository: caddyserver/caddy (language: go)

Issue:
Glob matcher for file names on the *filesystem*
[Placeholder for a feature requested in this community discussion](https://caddy.community/t/matching-admin-with-filesystem-admin-index-md4-html).

`file_server, try_files, file, url, path_regexp` and other matchers are very powerful for matching requests and uri paths with wildcards/regex.

However they do not seem to accommodate regex/wildcards for file names on the caddy *filesystem*.

The closest is to this is the configuration for…

    ‘try_policy’: ‘first_exist|smallest_size|largest_size|most_recent_modified’

…because this hints at rules to select from multiple matching files on the filesystem, but it does not provide the flexible globbing available to request matching.

A suggested implementation approach from the community discussion linked above is:

> I think it would essentially mean some kind of first_glob policy; would be the same as first_exist but using [filepath.Glob()](https://pkg.go.dev/path/filepath#Glob) to match for files instead, returning the first result from that (if there’s multiple results then… result is kinda undefined).

The community discussion also explains one known use case.

It would be great if Caddy could do regex/wilcard/glob filesystem matching for this and similar use cases.


Discussion / hints:
I think this would be more like a glob sort, i.e. use the configured filename pattern to get a list of files, then sort by one of those criteria.
So:

1) Glob search of filesystem
2) If only one file (not folders) matches, return that file
3) If more than one file matches, apply the try_files rules to select one of them:

first_exist|smallest_size|largest_size|most_recent_modified

Sound right?
Kind of, yeah.

This is a weird one. I've spent some time on this today, and I can't figure out what to do if a request comes in with a path of `/*` -- like, we don't want clients to be able to send glob patterns, right? So if the `{path}` placeholder is used in a `try_files` config, we'd need to treat that differently... like, we want the server admin/config to dictate when globbing is done, not clients.

I don't think it's a security concern as we'd never match files that are configured as hidden (i.e. a regular HTTP request to those files would still load them). But it could be surprising.

Part of me is thinking glob expansion might only be performed if there are no placeholders in the `try_files` config. Make sense? Any other ideas?
> Part of me is thinking glob expansion might only be performed if there are no placeholders in the try_files config.

That would actually make it useless, unfortunately. Placeholders are necessary, otherwise you can only rewrite to static locations.

Fix patch:
diff --git a/modules/caddyhttp/fileserver/caddyfile.go b/modules/caddyhttp/fileserver/caddyfile.go
index a3cf9e4dc3e..1a0424c15a4 100644
--- a/modules/caddyhttp/fileserver/caddyfile.go
+++ b/modules/caddyhttp/fileserver/caddyfile.go
@@ -36,17 +36,16 @@ func init() {
 // parseCaddyfile parses the file_server directive. It enables the static file
 // server and configures it with this syntax:
 //
-//    file_server [<matcher>] [browse] {
-//        fs            <backend...>
-//        root          <path>
-//        hide          <files...>
-//        index         <files...>
-//        browse        [<template_file>]
-//        precompressed <formats...>
-//        status        <status>
-//        disable_canonical_uris
-//    }
-//
+//	file_server [<matcher>] [browse] {
+//	    fs            <backend...>
+//	    root          <path>
+//	    hide          <files...>
+//	    index         <files...>
+//	    browse        [<template_file>]
+//	    precompressed <formats...>
+//	    status        <status>
+//	    disable_canonical_uris
+//	}
 func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
 	var fsrv FileServer
 
@@ -177,22 +176,23 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
 // with a rewrite directive, so this is not a standard handler directive.
 // A try_files directive has this syntax (notice no matcher tokens accepted):
 //
-//    try_files <files...>
+//	try_files <files...> {
+//		policy first_exist|smallest_size|largest_size|most_recently_modified
+//	}
 //
 // and is basically shorthand for:
 //
-//    @try_files {
-//        file {
-//            try_files <files...>
-//        }
-//    }
-//    rewrite @try_files {http.matchers.file.relative}
+//	@try_files file {
+//		try_files <files...>
+//		policy first_exist|smallest_size|largest_size|most_recently_modified
+//	}
+//	rewrite @try_files {http.matchers.file.relative}
 //
 // This directive rewrites request paths only, preserving any other part
 // of the URI, unless the part is explicitly given in the file list. For
 // example, if any of the files in the list have a query string:
 //
-//    try_files {path} index.php?{query}&p={path}
+//	try_files {path} index.php?{query}&p={path}
 //
 // then the query string will not be treated as part of the file name; and
 // if that file matches, the given query string will replace any query string
@@ -207,6 +207,27 @@ func parseTryFiles(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
 		return nil, h.ArgErr()
 	}
 
+	// parse out the optional try policy
+	var tryPolicy string
+	for nesting := h.Nesting(); h.NextBlock(nesting); {
+		switch h.Val() {
+		case "policy":
+			if tryPolicy != "" {
+				return nil, h.Err("try policy already configured")
+			}
+			if !h.NextArg() {
+				return nil, h.ArgErr()
+			}
+			tryPolicy = h.Val()
+
+			switch tryPolicy {
+			case tryPolicyFirstExist, tryPolicyLargestSize, tryPolicySmallestSize, tryPolicyMostRecentlyMod:
+			default:
+				return nil, h.Errf("unrecognized try policy: %s", tryPolicy)
+			}
+		}
+	}
+
 	// makeRoute returns a route that tries the files listed in try
 	// and then rewrites to the matched file; userQueryString is
 	// appended to the rewrite rule.
@@ -215,7 +236,7 @@ func parseTryFiles(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
 			URI: "{http.matchers.file.relative}" + userQueryString,
 		}
 		matcherSet := caddy.ModuleMap{
-			"file": h.JSON(MatchFile{TryFiles: try}),
+			"file": h.JSON(MatchFile{TryFiles: try, TryPolicy: tryPolicy}),
 		}
 		return h.NewRoute(matcherSet, handler)
 	}
diff --git a/modules/caddyhttp/fileserver/matcher.go b/modules/caddyhttp/fileserver/matcher.go
index 5cade25750a..a5dab6eb93d 100644
--- a/modules/caddyhttp/fileserver/matcher.go
+++ b/modules/caddyhttp/fileserver/matcher.go
@@ -21,9 +21,10 @@ import (
 	"net/http"
 	"os"
 	"path"
+	"path/filepath"
+	"runtime"
 	"strconv"
 	"strings"
-	"time"
 
 	"github.com/caddyserver/caddy/v2"
 	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
@@ -33,6 +34,7 @@ import (
 	"github.com/google/cel-go/common/operators"
 	"github.com/google/cel-go/common/types/ref"
 	"github.com/google/cel-go/parser"
+	"go.uber.org/zap"
 	exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
 )
 
@@ -55,6 +57,9 @@ func init() {
 // the matched file is a directory, "file" otherwise.
 // - `{http.matchers.file.remainder}` Set to the remainder
 // of the path if the path was split by `split_path`.
+//
+// Even though file matching may depend on the OS path
+// separator, the placeholder values always use /.
 type MatchFile struct {
 	// The file system implementation to use. By default, the
 	// local disk file system will be used.
@@ -101,6 +106,8 @@ type MatchFile struct {
 	// Each delimiter must appear at the end of a URI path
 	// component in order to be used as a split delimiter.
 	SplitPath []string `json:"split_path,omitempty"`
+
+	logger *zap.Logger
 }
 
 // CaddyModule returns the Caddy module information.
@@ -113,12 +120,11 @@ func (MatchFile) CaddyModule() caddy.ModuleInfo {
 
 // UnmarshalCaddyfile sets up the matcher from Caddyfile tokens. Syntax:
 //
-//     file <files...> {
-//         root <path>
-//         try_files <files...>
-//         try_policy first_exist|smallest_size|largest_size|most_recently_modified
-//     }
-//
+//	file <files...> {
+//	    root      <path>
+//	    try_files <files...>
+//	    try_policy first_exist|smallest_size|largest_size|most_recently_modified
+//	}
 func (m *MatchFile) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
 	for d.Next() {
 		m.TryFiles = append(m.TryFiles, d.RemainingArgs()...)
@@ -156,7 +162,8 @@ func (m *MatchFile) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
 // expression matchers.
 //
 // Example:
-//    expression file({'root': '/srv', 'try_files': [{http.request.uri.path}, '/index.php'], 'try_policy': 'first_exist', 'split_path': ['.php']})
+//
+//	expression file({'root': '/srv', 'try_files': [{http.request.uri.path}, '/index.php'], 'try_policy': 'first_exist', 'split_path': ['.php']})
 func (MatchFile) CELLibrary(ctx caddy.Context) (cel.Library, error) {
 	requestType := cel.ObjectType("http.Request")
 
@@ -249,6 +256,8 @@ func celFileMatcherMacroExpander() parser.MacroExpander {
 
 // Provision sets up m's defaults.
 func (m *MatchFile) Provision(ctx caddy.Context) error {
+	m.logger = ctx.Logger(m)
+
 	// establish the file system to use
 	if len(m.FileSystemRaw) > 0 {
 		mod, err := ctx.LoadModule(m, "FileSystemRaw")
@@ -290,10 +299,10 @@ func (m MatchFile) Validate() error {
 // Match returns true if r matches m. Returns true
 // if a file was matched. If so, four placeholders
 // will be available:
-//    - http.matchers.file.relative
-//    - http.matchers.file.absolute
-//    - http.matchers.file.type
-//    - http.matchers.file.remainder
+//   - http.matchers.file.relative: Path to file relative to site root
+//   - http.matchers.file.absolute: Path to file including site root
+//   - http.matchers.file.type: file or directory
+//   - http.matchers.file.remainder: Portion remaining after splitting file path (if configured)
 func (m MatchFile) Match(r *http.Request) bool {
 	return m.selectFile(r)
 }
@@ -303,23 +312,80 @@ func (m MatchFile) Match(r *http.Request) bool {
 func (m MatchFile) selectFile(r *http.Request) (matched bool) {
 	repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
 
-	root := repl.ReplaceAll(m.Root, ".")
+	root := filepath.Clean(repl.ReplaceAll(m.Root, "."))
+
+	type matchCandidate struct {
+		fullpath, relative, splitRemainder string
+	}
 
-	// common preparation of the file into parts
-	prepareFilePath := func(file string) (suffix, fullpath, remainder string) {
-		suffix, remainder = m.firstSplit(path.Clean(repl.ReplaceAll(file, "")))
+	// makeCandidates evaluates placeholders in file and expands any glob expressions
+	// to build a list of file candidates. Special glob characters are escaped in
+	// placeholder replacements so globs cannot be expanded from placeholders, and
+	// globs are not evaluated on Windows because of its path separator character:
+	// escaping is not supported so we can't safely glob on Windows, or we can't
+	// support placeholders on Windows (pick one). (Actually, evaluating untrusted
+	// globs is not the end of the world since the file server will still hide any
+	// hidden files, it just might lead to unexpected behavior.)
+	makeCandidates := func(file string) []matchCandidate {
+		// first, evaluate placeholders in the file pattern
+		expandedFile, err := repl.ReplaceFunc(file, func(variable string, val any) (any, error) {
+			if runtime.GOOS == "windows" {
+				return val, nil
+			}
+			switch v := val.(type) {
+			case string:
+				return globSafeRepl.Replace(v), nil
+			case fmt.Stringer:
+				return globSafeRepl.Replace(v.String()), nil
+			}
+			return val, nil
+		})
+		if err != nil {
+			m.logger.Error("evaluating placeholders", zap.Error(err))
+			expandedFile = file // "oh well," I guess?
+		}
+
+		// clean the path and split, if configured -- we must split before
+		// globbing so that the file system doesn't include the remainder
+		// ("afterSplit") in the filename; be sure to restore trailing slash
+		beforeSplit, afterSplit := m.firstSplit(path.Clean(expandedFile))
 		if strings.HasSuffix(file, "/") {
-			suffix += "/"
+			beforeSplit += "/"
+		}
+
+		// create the full path to the file by prepending the site root
+		fullPattern := caddyhttp.SanitizedPathJoin(root, beforeSplit)
+
+		// expand glob expressions, but not on Windows because Glob() doesn't
+		// support escaping on Windows due to path separator)
+		var globResults []string
+		if runtime.GOOS == "windows" {
+			globResults = []string{fullPattern} // precious Windows
+		} else {
+			globResults, err = fs.Glob(m.fileSystem, fullPattern)
+			if err != nil {
+				m.logger.Error("expanding glob", zap.Error(err))
+			}
 		}
-		fullpath = caddyhttp.SanitizedPathJoin(root, suffix)
-		return
+
+		// for each glob result, combine all the forms of the path
+		var candidates []matchCandidate
+		for _, result := range globResults {
+			candidates = append(candidates, matchCandidate{
+				fullpath:       result,
+				relative:       strings.TrimPrefix(result, root),
+				splitRemainder: afterSplit,
+			})
+		}
+
+		return candidates
 	}
 
-	// sets up the placeholders for the matched file
-	setPlaceholders := func(info os.FileInfo, rel string, abs string, remainder string) {
-		repl.Set("http.matchers.file.relative", rel)
-		repl.Set("http.matchers.file.absolute", abs)
-		repl.Set("http.matchers.file.remainder", remainder)
+	// setPlaceholders creates the placeholders for the matched file
+	setPlaceholders := func(candidate matchCandidate, info fs.FileInfo) {
+		repl.Set("http.matchers.file.relative", filepath.ToSlash(candidate.relative))
+		repl.Set("http.matchers.file.absolute", filepath.ToSlash(candidate.fullpath))
+		repl.Set("http.matchers.file.remainder", filepath.ToSlash(candidate.splitRemainder))
 
 		fileType := "file"
 		if info.IsDir() {
@@ -328,76 +394,83 @@ func (m MatchFile) selectFile(r *http.Request) (matched bool) {
 		repl.Set("http.matchers.file.type", fileType)
 	}
 
+	// match file according to the configured policy
 	switch m.TryPolicy {
 	case "", tryPolicyFirstExist:
-		for _, f := range m.TryFiles {
-			if err := parseErrorCode(f); err != nil {
+		for _, pattern := range m.TryFiles {
+			if err := parseErrorCode(pattern); err != nil {
 				caddyhttp.SetVar(r.Context(), caddyhttp.MatcherErrorVarKey, err)
 				return
 			}
-			suffix, fullpath, remainder := prepareFilePath(f)
-			if info, exists := m.strictFileExists(fullpath); exists {
-				setPlaceholders(info, suffix, fullpath, remainder)
-				return true
+			candidates := makeCandidates(pattern)
+			for _, c := range candidates {
+				if info, exists := m.strictFileExists(c.fullpath); exists {
+					setPlaceholders(c, info)
+					return true
+				}
 			}
 		}
 
 	case tryPolicyLargestSize:
 		var largestSize int64
-		var largestFilename string
-		var largestSuffix string
-		var remainder string
-		var info os.FileInfo
-		for _, f := range m.TryFiles {
-			suffix, fullpath, splitRemainder := prepareFilePath(f)
-			info, err := m.fileSystem.Stat(fullpath)
-			if err == nil && info.Size() > largestSize {
-				largestSize = info.Size()
-				largestFilename = fullpath
-				largestSuffix = suffix
-				remainder = splitRemainder
+		var largest matchCandidate
+		var largestInfo os.FileInfo
+		for _, pattern := range m.TryFiles {
+			candidates := makeCandidates(pattern)
+			for _, c := range candidates {
+				info, err := m.fileSystem.Stat(c.fullpath)
+				if err == nil && info.Size() > largestSize {
+					largestSize = info.Size()
+					largest = c
+					largestInfo = info
+				}
 			}
 		}
-		setPlaceholders(info, largestSuffix, largestFilename, remainder)
+		if largestInfo == nil {
+			return false
+		}
+		setPlaceholders(largest, largestInfo)
 		return true
 
 	case tryPolicySmallestSize:
 		var smallestSize int64
-		var smallestFilename string
-		var smallestSuffix string
-		var remainder string
-		var info os.FileInfo
-		for _, f := range m.TryFiles {
-			suffix, fullpath, splitRemainder := prepareFilePath(f)
-			info, err := m.fileSystem.Stat(fullpath)
-			if err == nil && (smallestSize == 0 || info.Size() < smallestSize) {
-				smallestSize = info.Size()
-				smallestFilename = fullpath
-				smallestSuffix = suffix
-				remainder = splitRemainder
+		var smallest matchCandidate
+		var smallestInfo os.FileInfo
+		for _, pattern := range m.TryFiles {
+			candidates := makeCandidates(pattern)
+			for _, c := range candidates {
+				info, err := m.fileSystem.Stat(c.fullpath)
+				if err == nil && (smallestSize == 0 || info.Size() < smallestSize) {
+					smallestSize = info.Size()
+					smallest = c
+					smallestInfo = info
+				}
 			}
 		}
-		setPlaceholders(info, smallestSuffix, smallestFilename, remainder)
+		if smallestInfo == nil {
+			return false
+		}
+		setPlaceholders(smallest, smallestInfo)
 		return true
 
 	case tryPolicyMostRecentlyMod:
-		var recentDate time.Time
-		var recentFilename string
-		var recentSuffix string
-		var remainder string
-		var info os.FileInfo
-		for _, f := range m.TryFiles {
-			suffix, fullpath, splitRemainder := prepareFilePath(f)
-			info, err := m.fileSystem.Stat(fullpath)
-			if err == nil &&
-				(recentDate.IsZero() || info.ModTime().After(recentDate)) {
-				recentDate = info.ModTime()
-				recentFilename = fullpath
-				recentSuffix = suffix
-				remainder = splitRemainder
+		var recent matchCandidate
+		var recentInfo os.FileInfo
+		for _, pattern := range m.TryFiles {
+			candidates := makeCandidates(pattern)
+			for _, c := range candidates {
+				info, err := m.fileSystem.Stat(c.fullpath)
+				if err == nil &&
+					(recentInfo == nil || info.ModTime().After(recentInfo.ModTime())) {
+					recent = c
+					recentInfo = info
+				}
 			}
 		}
-		setPlaceholders(info, recentSuffix, recentFilename, remainder)
+		if recentInfo == nil {
+			return false
+		}
+		setPlaceholders(recent, recentInfo)
 		return true
 	}
 
@@ -425,7 +498,7 @@ func parseErrorCode(input string) error {
 // NOT end in a forward slash, the file must NOT
 // be a directory.
 func (m MatchFile) strictFileExists(file string) (os.FileInfo, bool) {
-	stat, err := m.fileSystem.Stat(file)
+	info, err := m.fileSystem.Stat(file)
 	if err != nil {
 		// in reality, this can be any error
 		// such as permission or even obscure
@@ -440,11 +513,11 @@ func (m MatchFile) strictFileExists(file string) (os.FileInfo, bool) {
 	if strings.HasSuffix(file, separator) {
 		// by convention, file paths ending
 		// in a path separator must be a directory
-		return stat, stat.IsDir()
+		return info, info.IsDir()
 	}
 	// by convention, file paths NOT ending
 	// in a path separator must NOT be a directory
-	return stat, !stat.IsDir()
+	return info, !info.IsDir()
 }
 
 // firstSplit returns the first result where the path
@@ -581,6 +654,15 @@ func isCELStringListLiteral(e *exprpb.Expr) bool {
 	return false
 }
 
+// globSafeRepl replaces special glob characters with escaped
+// equivalents. Note that the filepath godoc states that
+// escaping is not done on Windows because of the separator.
+var globSafeRepl = strings.NewReplacer(
+	"*", "\\*",
+	"[", "\\[",
+	"?", "\\?",
+)
+
 const (
 	tryPolicyFirstExist      = "first_exist"
 	tryPolicyLargestSize     = "largest_size"
diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go
index 554f8d30d01..25bcf5a2641 100644
--- a/modules/caddyhttp/fileserver/staticfiles.go
+++ b/modules/caddyhttp/fileserver/staticfiles.go
@@ -618,10 +618,15 @@ func (wr statusOverrideResponseWriter) WriteHeader(int) {
 // rooting or path prefixing without being constrained to a single
 // root folder. The standard os.DirFS implementation is problematic
 // since roots can be dynamic in our application.)
+//
+// osFS also implements fs.GlobFS, fs.ReadDirFS, and fs.ReadFileFS.
 type osFS struct{}
 
-func (osFS) Open(name string) (fs.File, error)     { return os.Open(name) }
-func (osFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }
+func (osFS) Open(name string) (fs.File, error)          { return os.Open(name) }
+func (osFS) Stat(name string) (fs.FileInfo, error)      { return os.Stat(name) }
+func (osFS) Glob(pattern string) ([]string, error)      { return filepath.Glob(pattern) }
+func (osFS) ReadDir(name string) ([]fs.DirEntry, error) { return os.ReadDir(name) }
+func (osFS) ReadFile(name string) ([]byte, error)       { return os.ReadFile(name) }
 
 var defaultIndexNames = []string{"index.html", "index.txt"}
 
@@ -634,4 +639,8 @@ const (
 var (
 	_ caddy.Provisioner           = (*FileServer)(nil)
 	_ caddyhttp.MiddlewareHandler = (*FileServer)(nil)
+
+	_ fs.GlobFS     = (*osFS)(nil)
+	_ fs.ReadDirFS  = (*osFS)(nil)
+	_ fs.ReadFileFS = (*osFS)(nil)
 )
diff --git a/modules/caddyhttp/replacer.go b/modules/caddyhttp/replacer.go
index 17fc02eaf37..e15464997f7 100644
--- a/modules/caddyhttp/replacer.go
+++ b/modules/caddyhttp/replacer.go
@@ -143,6 +143,10 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo
 			case "http.request.uri.path.dir":
 				dir, _ := path.Split(req.URL.Path)
 				return dir, true
+			case "http.request.uri.path.file.base":
+				return strings.TrimSuffix(path.Base(req.URL.Path), path.Ext(req.URL.Path)), true
+			case "http.request.uri.path.file.ext":
+				return path.Ext(req.URL.Path), true
 			case "http.request.uri.query":
 				return req.URL.RawQuery, true
 			case "http.request.duration":


Test patch:
diff --git a/modules/caddyhttp/fileserver/matcher_test.go b/modules/caddyhttp/fileserver/matcher_test.go
index 7734bf1d346..0f8c6bbc542 100644
--- a/modules/caddyhttp/fileserver/matcher_test.go
+++ b/modules/caddyhttp/fileserver/matcher_test.go
@@ -28,7 +28,6 @@ import (
 )
 
 func TestFileMatcher(t *testing.T) {
-
 	// Windows doesn't like colons in files names
 	isWindows := runtime.GOOS == "windows"
 	if !isWindows {
@@ -87,25 +86,25 @@ func TestFileMatcher(t *testing.T) {
 		},
 		{
 			path:         "ملف.txt", // the path file name is not escaped
-			expectedPath: "ملف.txt",
+			expectedPath: "/ملف.txt",
 			expectedType: "file",
 			matched:      true,
 		},
 		{
 			path:         url.PathEscape("ملف.txt"), // singly-escaped path
-			expectedPath: "ملف.txt",
+			expectedPath: "/ملف.txt",
 			expectedType: "file",
 			matched:      true,
 		},
 		{
 			path:         url.PathEscape(url.PathEscape("ملف.txt")), // doubly-escaped path
-			expectedPath: "%D9%85%D9%84%D9%81.txt",
+			expectedPath: "/%D9%85%D9%84%D9%81.txt",
 			expectedType: "file",
 			matched:      true,
 		},
 		{
 			path:         "./with:in-name.txt", // browsers send the request with the path as such
-			expectedPath: "with:in-name.txt",
+			expectedPath: "/with:in-name.txt",
 			expectedType: "file",
 			matched:      !isWindows,
 		},
@@ -118,7 +117,7 @@ func TestFileMatcher(t *testing.T) {
 
 		u, err := url.Parse(tc.path)
 		if err != nil {
-			t.Fatalf("Test %d: parsing path: %v", i, err)
+			t.Errorf("Test %d: parsing path: %v", i, err)
 		}
 
 		req := &http.Request{URL: u}
@@ -126,24 +125,24 @@ func TestFileMatcher(t *testing.T) {
 
 		result := m.Match(req)
 		if result != tc.matched {
-			t.Fatalf("Test %d: expected match=%t, got %t", i, tc.matched, result)
+			t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result)
 		}
 
 		rel, ok := repl.Get("http.matchers.file.relative")
 		if !ok && result {
-			t.Fatalf("Test %d: expected replacer value", i)
+			t.Errorf("Test %d: expected replacer value", i)
 		}
 		if !result {
 			continue
 		}
 
 		if rel != tc.expectedPath {
-			t.Fatalf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
+			t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
 		}
 
 		fileType, _ := repl.Get("http.matchers.file.type")
 		if fileType != tc.expectedType {
-			t.Fatalf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
+			t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
 		}
 	}
 }
@@ -222,7 +221,7 @@ func TestPHPFileMatcher(t *testing.T) {
 
 		u, err := url.Parse(tc.path)
 		if err != nil {
-			t.Fatalf("Test %d: parsing path: %v", i, err)
+			t.Errorf("Test %d: parsing path: %v", i, err)
 		}
 
 		req := &http.Request{URL: u}
@@ -230,24 +229,24 @@ func TestPHPFileMatcher(t *testing.T) {
 
 		result := m.Match(req)
 		if result != tc.matched {
-			t.Fatalf("Test %d: expected match=%t, got %t", i, tc.matched, result)
+			t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result)
 		}
 
 		rel, ok := repl.Get("http.matchers.file.relative")
 		if !ok && result {
-			t.Fatalf("Test %d: expected replacer value", i)
+			t.Errorf("Test %d: expected replacer value", i)
 		}
 		if !result {
 			continue
 		}
 
 		if rel != tc.expectedPath {
-			t.Fatalf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
+			t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
 		}
 
 		fileType, _ := repl.Get("http.matchers.file.type")
 		if fileType != tc.expectedType {
-			t.Fatalf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
+			t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
 		}
 	}
 }
diff --git a/modules/caddyhttp/fileserver/testdata/foodir/bar.txt b/modules/caddyhttp/fileserver/testdata/foodir/bar.txt
new file mode 100644
index 00000000000..df34bd20398
--- /dev/null
+++ b/modules/caddyhttp/fileserver/testdata/foodir/bar.txt
@@ -0,0 +1,1 @@
+foodir/bar.txt
\ No newline at end of file
diff --git a/modules/caddyhttp/replacer_test.go b/modules/caddyhttp/replacer_test.go
index 5026ac84670..18253d3fdaf 100644
--- a/modules/caddyhttp/replacer_test.go
+++ b/modules/caddyhttp/replacer_test.go
@@ -27,7 +27,7 @@ import (
 )
 
 func TestHTTPVarReplacement(t *testing.T) {
-	req, _ := http.NewRequest("GET", "/", nil)
+	req, _ := http.NewRequest(http.MethodGet, "/foo/bar.tar.gz", nil)
 	repl := caddy.NewReplacer()
 	ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
 	req = req.WithContext(ctx)
@@ -72,114 +72,134 @@ eqp31wM9il1n+guTNyxJd+FzVAH+hCZE5K+tCgVDdVFUlDEHHbS/wqb2PSIoouLV
 	addHTTPVarsToReplacer(repl, req, res)
 
 	for i, tc := range []struct {
-		input  string
+		get    string
 		expect string
 	}{
 		{
-			input:  "{http.request.scheme}",
+			get:    "http.request.scheme",
 			expect: "https",
 		},
 		{
-			input:  "{http.request.host}",
+			get:    "http.request.method",
+			expect: http.MethodGet,
+		},
+		{
+			get:    "http.request.host",
 			expect: "example.com",
 		},
 		{
-			input:  "{http.request.port}",
+			get:    "http.request.port",
 			expect: "80",
 		},
 		{
-			input:  "{http.request.hostport}",
+			get:    "http.request.hostport",
 			expect: "example.com:80",
 		},
 		{
-			input:  "{http.request.remote.host}",
+			get:    "http.request.remote.host",
 			expect: "localhost",
 		},
 		{
-			input:  "{http.request.remote.port}",
+			get:    "http.request.remote.port",
 			expect: "1234",
 		},
 		{
-			input:  "{http.request.host.labels.0}",
+			get:    "http.request.host.labels.0",
 			expect: "com",
 		},
 		{
-			input:  "{http.request.host.labels.1}",
+			get:    "http.request.host.labels.1",
 			expect: "example",
 		},
 		{
-			input:  "{http.request.host.labels.2}",
-			expect: "<empty>",
+			get:    "http.request.host.labels.2",
+			expect: "",
 		},
 		{
-			input:  "{http.request.tls.cipher_suite}",
+			get:    "http.request.uri.path.file",
+			expect: "bar.tar.gz",
+		},
+		{
+			get:    "http.request.uri.path.file.base",
+			expect: "bar.tar",
+		},
+		{
+			// not ideal, but also most correct, given that files can have dots (example: index.<SHA>.html) TODO: maybe this isn't right..
+			get:    "http.request.uri.path.file.ext",
+			expect: ".gz",
+		},
+		{
+			get:    "http.request.tls.cipher_suite",
 			expect: "TLS_AES_256_GCM_SHA384",
 		},
 		{
-			input:  "{http.request.tls.proto}",
+			get:    "http.request.tls.proto",
 			expect: "h2",
 		},
 		{
-			input:  "{http.request.tls.proto_mutual}",
+			get:    "http.request.tls.proto_mutual",
 			expect: "true",
 		},
 		{
-			input:  "{http.request.tls.resumed}",
+			get:    "http.request.tls.resumed",
 			expect: "false",
 		},
 		{
-			input:  "{http.request.tls.server_name}",
+			get:    "http.request.tls.server_name",
 			expect: "foo.com",
 		},
 		{
-			input:  "{http.request.tls.version}",
+			get:    "http.request.tls.version",
 			expect: "tls1.3",
 		},
 		{
-			input:  "{http.request.tls.client.fingerprint}",
+			get:    "http.request.tls.client.fingerprint",
 			expect: "9f57b7b497cceacc5459b76ac1c3afedbc12b300e728071f55f84168ff0f7702",
 		},
 		{
-			input:  "{http.request.tls.client.issuer}",
+			get:    "http.request.tls.client.issuer",
 			expect: "CN=Caddy Test CA",
 		},
 		{
-			input:  "{http.request.tls.client.serial}",
+			get:    "http.request.tls.client.serial",
 			expect: "2",
 		},
 		{
-			input:  "{http.request.tls.client.subject}",
+			get:    "http.request.tls.client.subject",
 			expect: "CN=client.localdomain",
 		},
 		{
-			input:  "{http.request.tls.client.san.dns_names}",
+			get:    "http.request.tls.client.san.dns_names",
 			expect: "[localhost]",
 		},
 		{
-			input:  "{http.request.tls.client.san.dns_names.0}",
+			get:    "http.request.tls.client.san.dns_names.0",
 			expect: "localhost",
 		},
 		{
-			input:  "{http.request.tls.client.san.dns_names.1}",
-			expect: "<empty>",
+			get:    "http.request.tls.client.san.dns_names.1",
+			expect: "",
 		},
 		{
-			input:  "{http.request.tls.client.san.ips}",
+			get:    "http.request.tls.client.san.ips",
 			expect: "[127.0.0.1]",
 		},
 		{
-			input:  "{http.request.tls.client.san.ips.0}",
+			get:    "http.request.tls.client.san.ips.0",
 			expect: "127.0.0.1",
 		},
 		{
-			input:  "{http.request.tls.client.certificate_pem}",
+			get:    "http.request.tls.client.certificate_pem",
 			expect: string(clientCert) + "\n", // returned value comes with a newline appended to it
 		},
 	} {
-		actual := repl.ReplaceAll(tc.input, "<empty>")
+		actual, got := repl.GetString(tc.get)
+		if !got {
+			t.Errorf("Test %d: Expected to recognize the placeholder name, but didn't", i)
+		}
 		if actual != tc.expect {
-			t.Errorf("Test %d: Expected placeholder %s to be '%s' but got '%s'",
-				i, tc.input, tc.expect, actual)
+			t.Errorf("Test %d: Expected %s to be '%s' but got '%s'",
+				i, tc.get, tc.expect, actual)
 		}
 	}
 }