diff --git a/internal/filesystems/map.go b/internal/filesystems/map.go index 3ecb34e40..88e61b010 100644 --- a/internal/filesystems/map.go +++ b/internal/filesystems/map.go @@ -18,6 +18,19 @@ type wrapperFs struct { fs.FS } +// Embedding fs.FS hides the wrapped FS's optional interfaces, so forward them. +// Not GlobFS: OsFS.Glob uses filepath.Glob, whose semantics differ from fs.Glob. + +func (w *wrapperFs) Stat(name string) (fs.FileInfo, error) { return fs.Stat(w.FS, name) } +func (w *wrapperFs) ReadDir(name string) ([]fs.DirEntry, error) { return fs.ReadDir(w.FS, name) } +func (w *wrapperFs) ReadFile(name string) ([]byte, error) { return fs.ReadFile(w.FS, name) } + +var ( + _ fs.StatFS = (*wrapperFs)(nil) + _ fs.ReadDirFS = (*wrapperFs)(nil) + _ fs.ReadFileFS = (*wrapperFs)(nil) +) + // FileSystemMap stores a map of filesystems // the empty key will be overwritten to be the default key // it includes a default filesystem, based off the os fs diff --git a/internal/filesystems/map_test.go b/internal/filesystems/map_test.go new file mode 100644 index 000000000..cfcd5ec93 --- /dev/null +++ b/internal/filesystems/map_test.go @@ -0,0 +1,71 @@ +package filesystems + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + "testing/fstest" +) + +type openCountingFS struct { + fstest.MapFS + opens int +} + +func (o *openCountingFS) Open(name string) (fs.File, error) { + o.opens++ + return o.MapFS.Open(name) +} + +func TestWrapperFsStat(t *testing.T) { + for i, tc := range []struct { + hideStatFS bool + wantOpens int + }{ + {hideStatFS: false, wantOpens: 0}, + {hideStatFS: true, wantOpens: 1}, + } { + inner := &openCountingFS{MapFS: fstest.MapFS{"file": {Data: []byte("data")}}} + var fsys fs.FS = inner + if tc.hideStatFS { + fsys = struct{ fs.FS }{inner} + } + var m FileSystemMap + m.Register("test", fsys) + wrapped, _ := m.Get("test") + + info, err := fs.Stat(wrapped, "file") + if err != nil { + t.Fatalf("Test %d: unexpected error: %v", i, err) + } + if info.Size() != 4 { + t.Errorf("Test %d: expected size 4, got %d", i, info.Size()) + } + if inner.opens != tc.wantOpens { + t.Errorf("Test %d: expected %d opens, got %d", i, tc.wantOpens, inner.opens) + } + } +} + +func TestDefaultFileSystemStatDoesNotOpen(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("needs a file that cannot be opened") + } + name := filepath.Join(t.TempDir(), "unreadable") + if err := os.WriteFile(name, []byte("data"), 0o000); err != nil { + t.Fatal(err) + } + if _, err := DefaultFileSystem.Open(name); err == nil { + t.Fatal("expected open to fail") + } + + info, err := fs.Stat(DefaultFileSystem, name) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.Size() != 4 { + t.Errorf("expected size 4, got %d", info.Size()) + } +} diff --git a/modules/caddyhttp/fileserver/matcher_test.go b/modules/caddyhttp/fileserver/matcher_test.go index 1a00924f2..9c42f91fb 100644 --- a/modules/caddyhttp/fileserver/matcher_test.go +++ b/modules/caddyhttp/fileserver/matcher_test.go @@ -870,3 +870,32 @@ func TestSizePoliciesWithEmptyFiles(t *testing.T) { }) } } + +func BenchmarkFileMatcher(b *testing.B) { + for _, bc := range []struct { + name string + path string + }{ + {name: "existing file", path: "/index.php"}, + {name: "front controller", path: "/blog/my-post"}, + } { + b.Run(bc.name, func(b *testing.B) { + m := &MatchFile{ + fsmap: &filesystems.FileSystemMap{}, + Root: "./testdata", + TryFiles: []string{"{http.request.uri.path}", "{http.request.uri.path}/index.php", "index.php"}, + TryPolicy: tryPolicyFirstExist, + SplitPath: []string{".php"}, + } + req := &http.Request{URL: &url.URL{Path: bc.path}} + caddyhttp.NewTestReplacer(req) + + b.ReportAllocs() + for b.Loop() { + if ok, err := m.MatchWithError(req); !ok || err != nil { + b.Fatalf("expected match, got %t, %v", ok, err) + } + } + }) + } +}