@marcel91200 / caddy Mirror · read-only / commits / b426776c
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 10, 23:45 UTC

filesystems: forward optional fs interfaces (#8388)

wrapperFs embeds fs.FS, which hides the StatFS, ReadDirFS and ReadFileFS methods of the wrapped filesystem. fs.Stat on DefaultFileSystem did open, fstat and close instead of one stat, for every fs.Stat in the file matcher and file_server, including the literal-path case of fs.Glob. GlobFS is not forwarded: OsFS.Glob uses filepath.Glob, which differs from fs.Glob (Lstat for literal patterns, OS path separators). Stat now succeeds on files and directories that cannot be opened, and no longer blocks on FIFOs. The file matcher matches unreadable files instead of skipping them, file_server picks an unreadable index file (403) instead of trying the next one, and browse lists symlinks to unreadable directories as directories. BenchmarkFileMatcher, Apple M5 Pro, macOS, count=10: before after delta existing file 18.90µs 1.570µs -91.70% front controller 20.85µs 3.040µs -85.42% existing file 936 B 728 B 22 -> 18 allocs front controller 1312 B 1520 B 35 -> 33 allocs

Kévin Dunglas committed Nov 8, 2026 at 22:45 UTC b426776cc757a61cea1c25f1de86af1dfb9080c2
3 files changed +113
internal/filesystems/map.go
+13
@@ -18,6 +18,19 @@ type wrapperFs struct {
18 fs.FS
19 }
20
21 +// Embedding fs.FS hides the wrapped FS's optional interfaces, so forward them.
22 +// Not GlobFS: OsFS.Glob uses filepath.Glob, whose semantics differ from fs.Glob.
23 +
24 +func (w *wrapperFs) Stat(name string) (fs.FileInfo, error) { return fs.Stat(w.FS, name) }
25 +func (w *wrapperFs) ReadDir(name string) ([]fs.DirEntry, error) { return fs.ReadDir(w.FS, name) }
26 +func (w *wrapperFs) ReadFile(name string) ([]byte, error) { return fs.ReadFile(w.FS, name) }
27 +
28 +var (
29 + _ fs.StatFS = (*wrapperFs)(nil)
30 + _ fs.ReadDirFS = (*wrapperFs)(nil)
31 + _ fs.ReadFileFS = (*wrapperFs)(nil)
32 +)
33 +
34 // FileSystemMap stores a map of filesystems
35 // the empty key will be overwritten to be the default key
36 // it includes a default filesystem, based off the os fs
internal/filesystems/map_test.go new
+71
@@ -0,0 +1,71 @@
1 +package filesystems
2 +
3 +import (
4 + "io/fs"
5 + "os"
6 + "path/filepath"
7 + "runtime"
8 + "testing"
9 + "testing/fstest"
10 +)
11 +
12 +type openCountingFS struct {
13 + fstest.MapFS
14 + opens int
15 +}
16 +
17 +func (o *openCountingFS) Open(name string) (fs.File, error) {
18 + o.opens++
19 + return o.MapFS.Open(name)
20 +}
21 +
22 +func TestWrapperFsStat(t *testing.T) {
23 + for i, tc := range []struct {
24 + hideStatFS bool
25 + wantOpens int
26 + }{
27 + {hideStatFS: false, wantOpens: 0},
28 + {hideStatFS: true, wantOpens: 1},
29 + } {
30 + inner := &openCountingFS{MapFS: fstest.MapFS{"file": {Data: []byte("data")}}}
31 + var fsys fs.FS = inner
32 + if tc.hideStatFS {
33 + fsys = struct{ fs.FS }{inner}
34 + }
35 + var m FileSystemMap
36 + m.Register("test", fsys)
37 + wrapped, _ := m.Get("test")
38 +
39 + info, err := fs.Stat(wrapped, "file")
40 + if err != nil {
41 + t.Fatalf("Test %d: unexpected error: %v", i, err)
42 + }
43 + if info.Size() != 4 {
44 + t.Errorf("Test %d: expected size 4, got %d", i, info.Size())
45 + }
46 + if inner.opens != tc.wantOpens {
47 + t.Errorf("Test %d: expected %d opens, got %d", i, tc.wantOpens, inner.opens)
48 + }
49 + }
50 +}
51 +
52 +func TestDefaultFileSystemStatDoesNotOpen(t *testing.T) {
53 + if runtime.GOOS == "windows" || os.Geteuid() == 0 {
54 + t.Skip("needs a file that cannot be opened")
55 + }
56 + name := filepath.Join(t.TempDir(), "unreadable")
57 + if err := os.WriteFile(name, []byte("data"), 0o000); err != nil {
58 + t.Fatal(err)
59 + }
60 + if _, err := DefaultFileSystem.Open(name); err == nil {
61 + t.Fatal("expected open to fail")
62 + }
63 +
64 + info, err := fs.Stat(DefaultFileSystem, name)
65 + if err != nil {
66 + t.Fatalf("unexpected error: %v", err)
67 + }
68 + if info.Size() != 4 {
69 + t.Errorf("expected size 4, got %d", info.Size())
70 + }
71 +}
modules/caddyhttp/fileserver/matcher_test.go
+29
@@ -870,3 +870,32 @@ func TestSizePoliciesWithEmptyFiles(t *testing.T) {
870 })
871 }
872 }
873 +
874 +func BenchmarkFileMatcher(b *testing.B) {
875 + for _, bc := range []struct {
876 + name string
877 + path string
878 + }{
879 + {name: "existing file", path: "/index.php"},
880 + {name: "front controller", path: "/blog/my-post"},
881 + } {
882 + b.Run(bc.name, func(b *testing.B) {
883 + m := &MatchFile{
884 + fsmap: &filesystems.FileSystemMap{},
885 + Root: "./testdata",
886 + TryFiles: []string{"{http.request.uri.path}", "{http.request.uri.path}/index.php", "index.php"},
887 + TryPolicy: tryPolicyFirstExist,
888 + SplitPath: []string{".php"},
889 + }
890 + req := &http.Request{URL: &url.URL{Path: bc.path}}
891 + caddyhttp.NewTestReplacer(req)
892 +
893 + b.ReportAllocs()
894 + for b.Loop() {
895 + if ok, err := m.MatchWithError(req); !ok || err != nil {
896 + b.Fatalf("expected match, got %t, %v", ok, err)
897 + }
898 + }
899 + })
900 + }
901 +}