@marcel91200 / caddy Mirror · read-only
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 11, 01:00 UTC

master
go 954 lines 25.5 KB
Raw
1 // Copyright 2015 Matthew Holt and The Caddy Authors
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 // http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14
15 package fileserver
16
17 import (
18 "context"
19 "net/http"
20 "net/http/httptest"
21 "net/url"
22 "os"
23 "path/filepath"
24 "runtime"
25 "strings"
26 "testing"
27
28 "github.com/caddyserver/caddy/v2"
29 "github.com/caddyserver/caddy/v2/internal/filesystems"
30 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
31 "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite"
32 )
33
34 type testCase struct {
35 path string
36 expectedPath string
37 expectedType string
38 matched bool
39 }
40
41 func TestFileMatcher(t *testing.T) {
42 // Windows doesn't like colons in files names
43 isWindows := runtime.GOOS == "windows"
44 if !isWindows {
45 filename := "with:in-name.txt"
46 f, err := os.Create("./testdata/" + filename)
47 if err != nil {
48 t.Fail()
49 return
50 }
51 t.Cleanup(func() {
52 os.Remove("./testdata/" + filename)
53 })
54 f.WriteString(filename)
55 f.Close()
56 }
57
58 if err := os.WriteFile("./testdata/.env", []byte("SECRET=1"), 0o644); err != nil {
59 t.Fatal(err)
60 }
61 t.Cleanup(func() {
62 os.Remove("./testdata/.env")
63 })
64
65 for i, tc := range []testCase{
66 {
67 path: "/",
68 expectedPath: "/",
69 expectedType: "directory",
70 matched: true,
71 },
72 {
73 path: "/.env",
74 expectedPath: "/.env",
75 expectedType: "file",
76 matched: true,
77 },
78 {
79 path: "/.env/",
80 matched: false,
81 },
82 {
83 path: "/.env/./",
84 matched: false,
85 },
86 {
87 path: "/foo.txt",
88 expectedPath: "/foo.txt",
89 expectedType: "file",
90 matched: true,
91 },
92 {
93 path: "/foo.txt/",
94 matched: false,
95 },
96 {
97 path: "/foo.txt/./",
98 matched: false,
99 },
100 {
101 path: "/foo.txt//",
102 matched: false,
103 },
104 {
105 path: "/foodir/./",
106 expectedPath: "/foodir/",
107 expectedType: "directory",
108 matched: true,
109 },
110 {
111 path: "/foo.txt?a=b",
112 expectedPath: "/foo.txt",
113 expectedType: "file",
114 matched: true,
115 },
116 {
117 path: "/foodir",
118 expectedPath: "/foodir/",
119 expectedType: "directory",
120 matched: true,
121 },
122 {
123 path: "/foodir/",
124 expectedPath: "/foodir/",
125 expectedType: "directory",
126 matched: true,
127 },
128 {
129 path: "/foodir/foo.txt",
130 expectedPath: "/foodir/foo.txt",
131 expectedType: "file",
132 matched: true,
133 },
134 {
135 path: "/missingfile.php",
136 matched: false,
137 },
138 {
139 path: "ملف.txt", // the path file name is not escaped
140 expectedPath: "/ملف.txt",
141 expectedType: "file",
142 matched: true,
143 },
144 {
145 path: url.PathEscape("ملف.txt"), // singly-escaped path
146 expectedPath: "/ملف.txt",
147 expectedType: "file",
148 matched: true,
149 },
150 {
151 path: url.PathEscape(url.PathEscape("ملف.txt")), // doubly-escaped path
152 expectedPath: "/%D9%85%D9%84%D9%81.txt",
153 expectedType: "file",
154 matched: true,
155 },
156 {
157 path: "./with:in-name.txt", // browsers send the request with the path as such
158 expectedPath: "/with:in-name.txt",
159 expectedType: "file",
160 matched: !isWindows,
161 },
162 } {
163 fileMatcherTest(t, i, tc)
164 }
165 }
166
167 func TestFileMatcherNonWindows(t *testing.T) {
168 if runtime.GOOS == "windows" {
169 return
170 }
171
172 // this is impossible to test on Windows, but tests a security patch for other platforms
173 tc := testCase{
174 path: "/foodir/secr%5Cet.txt",
175 expectedPath: "/foodir/secr\\et.txt",
176 expectedType: "file",
177 matched: true,
178 }
179
180 f, err := os.Create(filepath.Join("testdata", strings.TrimPrefix(tc.expectedPath, "/")))
181 if err != nil {
182 t.Fatalf("could not create test file: %v", err)
183 }
184 defer f.Close()
185 defer os.Remove(f.Name())
186
187 fileMatcherTest(t, 0, tc)
188 }
189
190 func fileMatcherTest(t *testing.T, i int, tc testCase) {
191 m := &MatchFile{
192 fsmap: &filesystems.FileSystemMap{},
193 Root: "./testdata",
194 TryFiles: []string{"{http.request.uri.path}", "{http.request.uri.path}/"},
195 }
196
197 u, err := url.Parse(tc.path)
198 if err != nil {
199 t.Errorf("Test %d: parsing path: %v", i, err)
200 }
201
202 req := &http.Request{URL: u}
203 repl := caddyhttp.NewTestReplacer(req)
204
205 result, err := m.MatchWithError(req)
206 if err != nil {
207 t.Errorf("Test %d: unexpected error: %v", i, err)
208 }
209 if result != tc.matched {
210 t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result)
211 }
212
213 rel, ok := repl.Get("http.matchers.file.relative")
214 if !ok && result {
215 t.Errorf("Test %d: expected replacer value", i)
216 }
217 if !result {
218 return
219 }
220
221 if rel != tc.expectedPath {
222 t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
223 }
224
225 fileType, _ := repl.Get("http.matchers.file.type")
226 if fileType != tc.expectedType {
227 t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
228 }
229 }
230
231 func TestTryFilesRewriteEscapesMatchedPath(t *testing.T) {
232 root := t.TempDir()
233
234 tests := []struct {
235 name string
236 requestTarget string
237 filename string
238 extraFiles []string
239 wantPath string
240 wantRequestURI string
241 skipWindows bool
242 }{
243 {
244 name: "question mark in path",
245 requestTarget: "/%3F.html",
246 filename: "?.html",
247 wantPath: "/?.html",
248 wantRequestURI: "/%3F.html",
249 skipWindows: true,
250 },
251 {
252 name: "percent in path",
253 requestTarget: "/%25.html",
254 filename: "%.html",
255 wantPath: "/%.html",
256 wantRequestURI: "/%25.html",
257 },
258 {
259 name: "encoded question mark remains percent-encoded",
260 requestTarget: "/%253F.html",
261 filename: "%3F.html",
262 wantPath: "/%3F.html",
263 wantRequestURI: "/%253F.html",
264 },
265 {
266 name: "question mark in nested path",
267 requestTarget: "/nested/%3F.html",
268 filename: filepath.Join("nested", "?.html"),
269 wantPath: "/nested/?.html",
270 wantRequestURI: "/nested/%3F.html",
271 skipWindows: true,
272 },
273 {
274 name: "encoded slash in filename does not conflict with nesting",
275 requestTarget: "/nested%252Ffile.html",
276 filename: "nested%2Ffile.html",
277 extraFiles: []string{filepath.Join("nested", "file.html")},
278 wantPath: "/nested%2Ffile.html",
279 wantRequestURI: "/nested%252Ffile.html",
280 },
281 }
282
283 for _, tc := range tests {
284 t.Run(tc.name, func(t *testing.T) {
285 if tc.skipWindows && runtime.GOOS == "windows" {
286 t.Skip("Windows file names cannot contain question marks")
287 }
288
289 for _, name := range append([]string{tc.filename}, tc.extraFiles...) {
290 filename := filepath.Join(root, name)
291 if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
292 t.Fatalf("creating test file parent directory: %v", err)
293 }
294 if err := os.WriteFile(filename, []byte(name), 0o600); err != nil {
295 t.Fatalf("writing test file: %v", err)
296 }
297 }
298
299 m := &MatchFile{
300 fsmap: &filesystems.FileSystemMap{},
301 Root: root,
302 TryFiles: []string{"{http.request.uri.path}"},
303 }
304 req := httptest.NewRequest(http.MethodGet, "http://example.com"+tc.requestTarget, nil)
305 repl := caddyhttp.NewTestReplacer(req)
306
307 matched, err := m.MatchWithError(req)
308 if err != nil {
309 t.Fatalf("matching file: %v", err)
310 }
311 if !matched {
312 t.Fatalf("expected request %s to match %s", tc.requestTarget, tc.filename)
313 }
314
315 rewrite.Rewrite{URI: "{http.matchers.file.relative}"}.Rewrite(req, repl)
316
317 if req.URL.Path != tc.wantPath {
318 t.Errorf("rewritten path = %q, want %q", req.URL.Path, tc.wantPath)
319 }
320 if req.RequestURI != tc.wantRequestURI {
321 t.Errorf("rewritten request URI = %q, want %q", req.RequestURI, tc.wantRequestURI)
322 }
323 if req.URL.RawQuery != "" {
324 t.Errorf("rewritten raw query = %q, want empty", req.URL.RawQuery)
325 }
326 })
327 }
328 }
329
330 func TestPHPFileMatcher(t *testing.T) {
331 for i, tc := range []struct {
332 path string
333 expectedPath string
334 expectedType string
335 matched bool
336 }{
337 {
338 path: "/index.php",
339 expectedPath: "/index.php",
340 expectedType: "file",
341 matched: true,
342 },
343 {
344 path: "/index.php/somewhere",
345 expectedPath: "/index.php",
346 expectedType: "file",
347 matched: true,
348 },
349 {
350 path: "/remote.php",
351 expectedPath: "/remote.php",
352 expectedType: "file",
353 matched: true,
354 },
355 {
356 path: "/remote.php/somewhere",
357 expectedPath: "/remote.php",
358 expectedType: "file",
359 matched: true,
360 },
361 {
362 path: "/missingfile.php",
363 matched: false,
364 },
365 {
366 path: "/notphp.php.txt",
367 expectedPath: "/notphp.php.txt",
368 expectedType: "file",
369 matched: true,
370 },
371 {
372 path: "/notphp.php.txt/",
373 matched: false,
374 },
375 {
376 path: "/index.php/",
377 expectedPath: "/index.php",
378 expectedType: "file",
379 matched: true,
380 },
381 {
382 path: "/index.php/somewhere/",
383 expectedPath: "/index.php",
384 expectedType: "file",
385 matched: true,
386 },
387 {
388 path: "/notphp.php.txt.suffixed",
389 matched: false,
390 },
391 {
392 path: "/foo.php.php/index.php",
393 expectedPath: "/foo.php.php/index.php",
394 expectedType: "file",
395 matched: true,
396 },
397 {
398 path: "/foo.php.php/",
399 expectedPath: "/foo.php.php/index.php",
400 expectedType: "file",
401 matched: true,
402 },
403 {
404 path: "/foo.php.php/./",
405 expectedPath: "/foo.php.php/index.php",
406 expectedType: "file",
407 matched: true,
408 },
409 {
410 // See https://github.com/caddyserver/caddy/issues/3623
411 path: "/%E2%C3",
412 expectedPath: "/%E2%C3",
413 expectedType: "file",
414 matched: false,
415 },
416 {
417 path: "/index.php?path={path}&{query}",
418 expectedPath: "/index.php",
419 expectedType: "file",
420 matched: true,
421 },
422 } {
423 m := &MatchFile{
424 fsmap: &filesystems.FileSystemMap{},
425 Root: "./testdata",
426 TryFiles: []string{"{http.request.uri.path}", "{http.request.uri.path}/index.php"},
427 SplitPath: []string{".php"},
428 }
429
430 u, err := url.Parse(tc.path)
431 if err != nil {
432 t.Errorf("Test %d: parsing path: %v", i, err)
433 }
434
435 req := &http.Request{URL: u}
436 repl := caddyhttp.NewTestReplacer(req)
437
438 result, err := m.MatchWithError(req)
439 if err != nil {
440 t.Errorf("Test %d: unexpected error: %v", i, err)
441 }
442 if result != tc.matched {
443 t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result)
444 }
445
446 rel, ok := repl.Get("http.matchers.file.relative")
447 if !ok && result {
448 t.Errorf("Test %d: expected replacer value", i)
449 }
450 if !result {
451 continue
452 }
453
454 if rel != tc.expectedPath {
455 t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
456 }
457
458 fileType, _ := repl.Get("http.matchers.file.type")
459 if fileType != tc.expectedType {
460 t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType)
461 }
462 }
463 }
464
465 func TestFirstSplit(t *testing.T) {
466 m := MatchFile{
467 SplitPath: []string{".php"},
468 fsmap: &filesystems.FileSystemMap{},
469 }
470 actual, remainder := m.firstSplit("index.PHP/somewhere")
471 expected := "index.PHP"
472 expectedRemainder := "/somewhere"
473 if actual != expected {
474 t.Errorf("Expected split %s but got %s", expected, actual)
475 }
476 if remainder != expectedRemainder {
477 t.Errorf("Expected remainder %s but got %s", expectedRemainder, remainder)
478 }
479 }
480
481 var expressionTests = []struct {
482 name string
483 expression *caddyhttp.MatchExpression
484 urlTarget string
485 httpMethod string
486 httpHeader *http.Header
487 wantErr bool
488 wantResult bool
489 clientCertificate []byte
490 expectedPath string
491 }{
492 {
493 name: "file error no args (MatchFile)",
494 expression: &caddyhttp.MatchExpression{
495 Expr: `file()`,
496 },
497 urlTarget: "https://example.com/foo.txt",
498 wantResult: true,
499 },
500 {
501 name: "file error bad try files (MatchFile)",
502 expression: &caddyhttp.MatchExpression{
503 Expr: `file({"try_file": ["bad_arg"]})`,
504 },
505 urlTarget: "https://example.com/foo",
506 wantErr: true,
507 },
508 {
509 name: "file match short pattern index.php (MatchFile)",
510 expression: &caddyhttp.MatchExpression{
511 Expr: `file("index.php")`,
512 },
513 urlTarget: "https://example.com/foo",
514 wantResult: true,
515 },
516 {
517 name: "file match short pattern foo.txt (MatchFile)",
518 expression: &caddyhttp.MatchExpression{
519 Expr: `file({http.request.uri.path})`,
520 },
521 urlTarget: "https://example.com/foo.txt",
522 wantResult: true,
523 },
524 {
525 name: "file match index.php (MatchFile)",
526 expression: &caddyhttp.MatchExpression{
527 Expr: `file({"root": "./testdata", "try_files": [{http.request.uri.path}, "/index.php"]})`,
528 },
529 urlTarget: "https://example.com/foo",
530 wantResult: true,
531 },
532 {
533 name: "file match long pattern foo.txt (MatchFile)",
534 expression: &caddyhttp.MatchExpression{
535 Expr: `file({"root": "./testdata", "try_files": [{http.request.uri.path}]})`,
536 },
537 urlTarget: "https://example.com/foo.txt",
538 wantResult: true,
539 },
540 {
541 name: "file match long pattern foo.txt with concatenation (MatchFile)",
542 expression: &caddyhttp.MatchExpression{
543 Expr: `file({"root": ".", "try_files": ["./testdata" + {http.request.uri.path}]})`,
544 },
545 urlTarget: "https://example.com/foo.txt",
546 wantResult: true,
547 },
548 {
549 name: "file not match long pattern (MatchFile)",
550 expression: &caddyhttp.MatchExpression{
551 Expr: `file({"root": "./testdata", "try_files": [{http.request.uri.path}]})`,
552 },
553 urlTarget: "https://example.com/nopenope.txt",
554 wantResult: false,
555 },
556 {
557 name: "file match long pattern foo.txt with try_policy (MatchFile)",
558 expression: &caddyhttp.MatchExpression{
559 Expr: `file({"root": "./testdata", "try_policy": "largest_size", "try_files": ["foo.txt", "large.txt"]})`,
560 },
561 urlTarget: "https://example.com/",
562 wantResult: true,
563 expectedPath: "/large.txt",
564 },
565 }
566
567 func TestMatchExpressionMatch(t *testing.T) {
568 for _, tst := range expressionTests {
569 tc := tst
570 t.Run(tc.name, func(t *testing.T) {
571 caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
572 defer cancel()
573 err := tc.expression.Provision(caddyCtx)
574 if err != nil {
575 if !tc.wantErr {
576 t.Errorf("MatchExpression.Provision() error = %v, wantErr %v", err, tc.wantErr)
577 }
578 return
579 }
580
581 req := httptest.NewRequest(tc.httpMethod, tc.urlTarget, nil)
582 if tc.httpHeader != nil {
583 req.Header = *tc.httpHeader
584 }
585 repl := caddyhttp.NewTestReplacer(req)
586 repl.Set("http.vars.root", "./testdata")
587 ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
588 req = req.WithContext(ctx)
589
590 matches, err := tc.expression.MatchWithError(req)
591 if err != nil {
592 t.Errorf("MatchExpression.Match() error = %v", err)
593 return
594 }
595 if matches != tc.wantResult {
596 t.Errorf("MatchExpression.Match() expected to return '%t', for expression : '%s'", tc.wantResult, tc.expression.Expr)
597 }
598
599 if tc.expectedPath != "" {
600 path, ok := repl.Get("http.matchers.file.relative")
601 if !ok {
602 t.Errorf("MatchExpression.Match() expected to return path '%s', but got none", tc.expectedPath)
603 }
604 if path != tc.expectedPath {
605 t.Errorf("MatchExpression.Match() expected to return path '%s', but got '%s'", tc.expectedPath, path)
606 }
607 }
608 })
609 }
610 }
611
612 // try_policy first_exist_fallback is documented to match unconditionally,
613 // falling back to the last try_files entry when no earlier candidate exists.
614 // It must therefore match even when that last entry names a file that is not
615 // on disk. php_fastcgi relies on this: its generated rewrite route is the only
616 // step that normalizes r.URL.Path before the FastCGI transport reads it, so a
617 // matcher that fails here silently removes that normalization.
618 func TestFirstExistFallbackMatchesWhenFinalFileIsAbsent(t *testing.T) {
619 for i, tc := range []struct {
620 name string
621 tryFiles []string
622 policy string
623 path string
624 matched bool
625 expectedPath string
626 }{
627 {
628 name: "fallback matches although the final file is absent",
629 tryFiles: []string{"{http.request.uri.path}", "no-such-index.php"},
630 policy: tryPolicyFirstExistFallback,
631 path: "/does-not-exist.php",
632 matched: true,
633 expectedPath: "/no-such-index.php",
634 },
635 {
636 name: "fallback does not match a regular file with a trailing slash",
637 tryFiles: []string{"{http.request.uri.path}"},
638 policy: tryPolicyFirstExistFallback,
639 path: "/foo.txt/",
640 matched: false,
641 },
642 {
643 name: "fallback does not match a regular file with a trailing dot segment",
644 tryFiles: []string{"{http.request.uri.path}"},
645 policy: tryPolicyFirstExistFallback,
646 path: "/foo.txt/./",
647 matched: false,
648 },
649 {
650 name: "fallback still prefers an earlier candidate that exists",
651 tryFiles: []string{"{http.request.uri.path}", "no-such-index.php"},
652 policy: tryPolicyFirstExistFallback,
653 path: "/foo.txt",
654 matched: true,
655 expectedPath: "/foo.txt",
656 },
657 {
658 name: "fallback uses the final entry when it does exist",
659 tryFiles: []string{"{http.request.uri.path}", "index.php"},
660 policy: tryPolicyFirstExistFallback,
661 path: "/does-not-exist.php",
662 matched: true,
663 expectedPath: "/index.php",
664 },
665 {
666 name: "first_exist does NOT fall back",
667 tryFiles: []string{"{http.request.uri.path}", "no-such-index.php"},
668 policy: tryPolicyFirstExist,
669 path: "/does-not-exist.php",
670 matched: false,
671 },
672 {
673 name: "empty policy behaves as first_exist",
674 tryFiles: []string{"{http.request.uri.path}", "no-such-index.php"},
675 policy: "",
676 path: "/does-not-exist.php",
677 matched: false,
678 },
679 {
680 name: "fallback uses the final entry literally when its glob expands to nothing",
681 tryFiles: []string{"{http.request.uri.path}", "absent[0-9].php"},
682 policy: tryPolicyFirstExistFallback,
683 path: "/does-not-exist.php",
684 matched: true,
685 expectedPath: "/absent[0-9].php",
686 },
687 {
688 name: "first_exist does not use a pattern expanding to nothing literally",
689 tryFiles: []string{"absent[0-9].txt"},
690 policy: tryPolicyFirstExist,
691 path: "/whatever",
692 matched: false,
693 },
694 } {
695 t.Run(tc.name, func(t *testing.T) {
696 m := &MatchFile{
697 fsmap: &filesystems.FileSystemMap{},
698 Root: "./testdata",
699 TryFiles: tc.tryFiles,
700 TryPolicy: tc.policy,
701 SplitPath: []string{".php"},
702 }
703
704 u, err := url.Parse(tc.path)
705 if err != nil {
706 t.Fatalf("Test %d: parsing path: %v", i, err)
707 }
708 req := &http.Request{URL: u}
709 repl := caddyhttp.NewTestReplacer(req)
710
711 result, err := m.MatchWithError(req)
712 if err != nil {
713 t.Fatalf("Test %d: unexpected error: %v", i, err)
714 }
715 if result != tc.matched {
716 t.Fatalf("Test %d: expected match=%t, got %t", i, tc.matched, result)
717 }
718 if !result {
719 return
720 }
721
722 rel, _ := repl.Get("http.matchers.file.relative")
723 if rel != tc.expectedPath {
724 t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
725 }
726 })
727 }
728 }
729
730 func TestFileMatcherRejectsWindowsSeparators(t *testing.T) {
731 if runtime.GOOS != "windows" {
732 t.Skip("backslash is a path separator only on Windows")
733 }
734
735 for _, policy := range []string{tryPolicyFirstExist, tryPolicyFirstExistFallback} {
736 for _, requestTarget := range []string{
737 "/foo.txt%5c",
738 "/foo.txt%5C",
739 `/foo.txt\`,
740 } {
741 t.Run(policy+requestTarget, func(t *testing.T) {
742 m := &MatchFile{
743 fsmap: &filesystems.FileSystemMap{},
744 Root: "./testdata",
745 TryFiles: []string{"{http.request.uri.path}"},
746 TryPolicy: policy,
747 }
748 req := httptest.NewRequest(http.MethodGet, requestTarget, nil)
749 caddyhttp.NewTestReplacer(req)
750
751 matched, err := m.MatchWithError(req)
752 if err != nil {
753 t.Fatal(err)
754 }
755 if matched {
756 t.Fatal("separator-qualified regular file matched")
757 }
758 })
759 }
760 }
761 }
762
763 // Only the final entry of first_exist_fallback may be used without globbing.
764 // Every other policy must keep expanding patterns as globs, so a file whose
765 // literal name contains glob metacharacters is not matched by that name.
766 func TestLiteralGlobNameMatchesOnlyAsFallback(t *testing.T) {
767 if runtime.GOOS == "windows" {
768 t.Skip("globs are not evaluated on Windows")
769 }
770
771 root := t.TempDir()
772 if err := os.WriteFile(filepath.Join(root, "lit[1].txt"), []byte("x"), 0o644); err != nil {
773 t.Fatal(err)
774 }
775
776 for i, tc := range []struct {
777 tryFiles []string
778 policy string
779 path string
780 matched bool
781 expectedPath string
782 expectedRemainder string
783 }{
784 {tryFiles: []string{"lit[1].txt"}, policy: tryPolicyFirstExist, path: "/", matched: false},
785 {tryFiles: []string{"lit[1].txt"}, policy: tryPolicyLargestSize, path: "/", matched: false},
786 {tryFiles: []string{"lit[1].txt"}, policy: tryPolicySmallestSize, path: "/", matched: false},
787 {tryFiles: []string{"lit[1].txt"}, policy: tryPolicyMostRecentlyMod, path: "/", matched: false},
788 {
789 tryFiles: []string{"{http.request.uri.path}", "lit[1].txt"},
790 policy: tryPolicyFirstExistFallback,
791 path: "/absent.txt",
792 matched: true,
793 expectedPath: "/lit[1].txt",
794 },
795 {
796 // only the final entry is used literally; earlier entries are
797 // still globbed, so lit[1].txt is not matched by its literal name
798 tryFiles: []string{"lit[1].txt", "fallback.txt"},
799 policy: tryPolicyFirstExistFallback,
800 path: "/",
801 matched: true,
802 expectedPath: "/fallback.txt",
803 },
804 {
805 tryFiles: []string{"{http.request.uri.path}"},
806 policy: tryPolicyFirstExistFallback,
807 path: "/absent.php/extra",
808 matched: true,
809 expectedPath: "/absent.php",
810 expectedRemainder: "/extra",
811 },
812 {
813 // placeholder values are glob-escaped for expansion, but the
814 // literal fallback must use the unescaped value
815 tryFiles: []string{"{http.request.uri.path}"},
816 policy: tryPolicyFirstExistFallback,
817 path: "/a[b].txt",
818 matched: true,
819 expectedPath: "/a[b].txt",
820 },
821 } {
822 m := &MatchFile{
823 fsmap: &filesystems.FileSystemMap{},
824 Root: root,
825 TryFiles: tc.tryFiles,
826 TryPolicy: tc.policy,
827 SplitPath: []string{".php"},
828 }
829
830 req := &http.Request{URL: &url.URL{Path: tc.path}}
831 repl := caddyhttp.NewTestReplacer(req)
832
833 result, err := m.MatchWithError(req)
834 if err != nil {
835 t.Fatalf("Test %d: unexpected error: %v", i, err)
836 }
837 if result != tc.matched {
838 t.Fatalf("Test %d (%s): expected match=%t, got %t", i, tc.policy, tc.matched, result)
839 }
840 if !result {
841 continue
842 }
843
844 rel, _ := repl.Get("http.matchers.file.relative")
845 if rel != tc.expectedPath {
846 t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath)
847 }
848 rem, _ := repl.Get("http.matchers.file.remainder")
849 if rem != tc.expectedRemainder {
850 t.Errorf("Test %d: actual remainder: %v, expected: %v", i, rem, tc.expectedRemainder)
851 }
852 }
853 }
854
855 // An empty file is a valid candidate for the size policies: smallest_size
856 // must keep it once chosen, and largest_size must be able to choose it.
857 func TestSizePoliciesWithEmptyFiles(t *testing.T) {
858 root := t.TempDir()
859 for name, content := range map[string]string{
860 "empty.txt": "",
861 "small.txt": "x",
862 "big.txt": "xxxxx",
863 } {
864 if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o644); err != nil {
865 t.Fatal(err)
866 }
867 }
868
869 for _, tc := range []struct {
870 name string
871 tryFiles []string
872 policy string
873 expectedPath string
874 }{
875 {
876 name: "smallest_size keeps an empty file listed first",
877 tryFiles: []string{"empty.txt", "small.txt", "big.txt"},
878 policy: tryPolicySmallestSize,
879 expectedPath: "/empty.txt",
880 },
881 {
882 name: "smallest_size chooses an empty file listed last",
883 tryFiles: []string{"big.txt", "small.txt", "empty.txt"},
884 policy: tryPolicySmallestSize,
885 expectedPath: "/empty.txt",
886 },
887 {
888 name: "largest_size chooses the only file even if empty",
889 tryFiles: []string{"missing.txt", "empty.txt"},
890 policy: tryPolicyLargestSize,
891 expectedPath: "/empty.txt",
892 },
893 {
894 name: "largest_size prefers a larger file after an empty one",
895 tryFiles: []string{"empty.txt", "big.txt", "small.txt"},
896 policy: tryPolicyLargestSize,
897 expectedPath: "/big.txt",
898 },
899 } {
900 t.Run(tc.name, func(t *testing.T) {
901 m := &MatchFile{
902 fsmap: &filesystems.FileSystemMap{},
903 Root: root,
904 TryFiles: tc.tryFiles,
905 TryPolicy: tc.policy,
906 }
907
908 req := &http.Request{URL: &url.URL{Path: "/"}}
909 repl := caddyhttp.NewTestReplacer(req)
910
911 result, err := m.MatchWithError(req)
912 if err != nil {
913 t.Fatalf("unexpected error: %v", err)
914 }
915 if !result {
916 t.Fatal("expected a match")
917 }
918
919 rel, _ := repl.Get("http.matchers.file.relative")
920 if rel != tc.expectedPath {
921 t.Errorf("actual path: %v, expected: %v", rel, tc.expectedPath)
922 }
923 })
924 }
925 }
926
927 func BenchmarkFileMatcher(b *testing.B) {
928 for _, bc := range []struct {
929 name string
930 path string
931 }{
932 {name: "existing file", path: "/index.php"},
933 {name: "front controller", path: "/blog/my-post"},
934 } {
935 b.Run(bc.name, func(b *testing.B) {
936 m := &MatchFile{
937 fsmap: &filesystems.FileSystemMap{},
938 Root: "./testdata",
939 TryFiles: []string{"{http.request.uri.path}", "{http.request.uri.path}/index.php", "index.php"},
940 TryPolicy: tryPolicyFirstExist,
941 SplitPath: []string{".php"},
942 }
943 req := &http.Request{URL: &url.URL{Path: bc.path}}
944 caddyhttp.NewTestReplacer(req)
945
946 b.ReportAllocs()
947 for b.Loop() {
948 if ok, err := m.MatchWithError(req); !ok || err != nil {
949 b.Fatalf("expected match, got %t, %v", ok, err)
950 }
951 }
952 })
953 }
954 }