@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 647 lines 18.7 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 "bytes"
19 "compress/gzip"
20 "context"
21 "encoding/json"
22 "errors"
23 "fmt"
24 "net/http"
25 "net/http/httptest"
26 "os"
27 "path/filepath"
28 "runtime"
29 "strings"
30 "testing"
31 "time"
32
33 "github.com/caddyserver/caddy/v2"
34 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
35 "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
36 )
37
38 func TestFileHidden(t *testing.T) {
39 for i, tc := range []struct {
40 inputHide []string
41 inputPath string
42 expect bool
43 }{
44 {
45 inputHide: nil,
46 inputPath: "",
47 expect: false,
48 },
49 {
50 inputHide: []string{".gitignore"},
51 inputPath: "/.gitignore",
52 expect: true,
53 },
54 {
55 inputHide: []string{".git"},
56 inputPath: "/.gitignore",
57 expect: false,
58 },
59 {
60 inputHide: []string{"/.git"},
61 inputPath: "/.gitignore",
62 expect: false,
63 },
64 {
65 inputHide: []string{".git"},
66 inputPath: "/.git",
67 expect: true,
68 },
69 {
70 inputHide: []string{".git"},
71 inputPath: "/.git/foo",
72 expect: true,
73 },
74 {
75 inputHide: []string{".git"},
76 inputPath: "/foo/.git/bar",
77 expect: true,
78 },
79 {
80 inputHide: []string{"/prefix"},
81 inputPath: "/prefix/foo",
82 expect: true,
83 },
84 {
85 inputHide: []string{"/foo/*/bar"},
86 inputPath: "/foo/asdf/bar",
87 expect: true,
88 },
89 {
90 inputHide: []string{"*.txt"},
91 inputPath: "/foo/bar.txt",
92 expect: true,
93 },
94 {
95 inputHide: []string{"/foo/bar/*.txt"},
96 inputPath: "/foo/bar/baz.txt",
97 expect: true,
98 },
99 {
100 inputHide: []string{"/foo/bar/*.txt"},
101 inputPath: "/foo/bar.txt",
102 expect: false,
103 },
104 {
105 inputHide: []string{"/foo/bar/*.txt"},
106 inputPath: "/foo/bar/index.html",
107 expect: false,
108 },
109 {
110 inputHide: []string{"/foo"},
111 inputPath: "/foo",
112 expect: true,
113 },
114 {
115 inputHide: []string{"/foo"},
116 inputPath: "/foobar",
117 expect: false,
118 },
119 {
120 inputHide: []string{"first", "second"},
121 inputPath: "/second",
122 expect: true,
123 },
124 {
125 inputHide: []string{"/srv/Caddyfile"},
126 inputPath: "/srv/caddyfile",
127 expect: true,
128 },
129 {
130 inputHide: []string{"/srv/Caddyfile"},
131 inputPath: "/SRV/CADDYFILE",
132 expect: true,
133 },
134 {
135 inputHide: []string{"secretdir"},
136 inputPath: "/SecretDir/p.txt",
137 expect: true,
138 },
139 {
140 inputHide: []string{"/srv/secretdir"},
141 inputPath: "/srv/SECRETDIR/p.txt",
142 expect: true,
143 },
144 {
145 inputHide: []string{"/srv/public.txt.gz"},
146 inputPath: "/srv/PUBLIC.TXT.gz",
147 expect: true,
148 },
149 {
150 inputHide: []string{"secret"},
151 inputPath: "/\u017Fecret",
152 expect: true,
153 },
154 {
155 inputHide: []string{"kelvin"},
156 inputPath: "/\u212Aelvin",
157 expect: true,
158 },
159 {
160 inputHide: []string{"Secret"},
161 inputPath: "/secrets",
162 expect: false,
163 },
164 {
165 inputHide: []string{"/foo"},
166 inputPath: "/FOOBAR",
167 expect: false,
168 },
169 {
170 inputHide: []string{"*.secret"},
171 inputPath: "/X.SECRET",
172 expect: false,
173 },
174 } {
175 if runtime.GOOS == "windows" {
176 if strings.HasPrefix(tc.inputPath, "/") {
177 tc.inputPath, _ = filepath.Abs(tc.inputPath)
178 }
179 tc.inputPath = filepath.FromSlash(tc.inputPath)
180 for i := range tc.inputHide {
181 if strings.HasPrefix(tc.inputHide[i], "/") {
182 tc.inputHide[i], _ = filepath.Abs(tc.inputHide[i])
183 }
184 tc.inputHide[i] = filepath.FromSlash(tc.inputHide[i])
185 }
186 }
187
188 actual := fileHidden(tc.inputPath, tc.inputHide)
189 if actual != tc.expect {
190 t.Errorf("Test %d: Does %v hide %s? Got %t but expected %t",
191 i, tc.inputHide, tc.inputPath, actual, tc.expect)
192 }
193 }
194 }
195
196 // Win32 path normalization strips trailing dots and spaces, so Windows opens
197 // hidden files under these aliases; fileHidden relies on FastAbs normalizing alike.
198 func TestHideWindowsTrailingDotSpace(t *testing.T) {
199 if runtime.GOOS != "windows" {
200 t.Skip("Win32 path normalization only")
201 }
202
203 root := t.TempDir()
204 for _, dir := range []string{"publicdir", "secretdir"} {
205 if err := os.Mkdir(filepath.Join(root, dir), 0o700); err != nil {
206 t.Fatal(err)
207 }
208 }
209 for _, name := range []string{"Caddyfile", "public.txt", filepath.Join("publicdir", "p.txt"), filepath.Join("secretdir", "p.txt")} {
210 if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0o600); err != nil {
211 t.Fatal(err)
212 }
213 }
214
215 canonicalURIs := false
216 fsrv := FileServer{
217 Root: root,
218 Hide: []string{"Caddyfile", filepath.Join(root, "secretdir")},
219 CanonicalURIs: &canonicalURIs,
220 }
221 ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
222 defer cancel()
223 if err := fsrv.Provision(ctx); err != nil {
224 t.Fatal(err)
225 }
226
227 for i, tc := range []struct {
228 path string
229 wantStatus int
230 }{
231 {path: "/public.txt.", wantStatus: http.StatusOK},
232 {path: "/public.txt%20", wantStatus: http.StatusOK},
233 {path: "/publicdir./p.txt", wantStatus: http.StatusOK},
234 {path: "/Caddyfile.", wantStatus: http.StatusNotFound},
235 {path: "/Caddyfile%20", wantStatus: http.StatusNotFound},
236 {path: "/Caddyfile.%20..", wantStatus: http.StatusNotFound},
237 {path: "/secretdir./p.txt", wantStatus: http.StatusNotFound},
238 } {
239 r := httptest.NewRequest(http.MethodGet, tc.path, nil)
240 r = r.WithContext(context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer()))
241 w := httptest.NewRecorder()
242
243 status := http.StatusOK
244 if err := fsrv.ServeHTTP(w, r, nil); err != nil {
245 he, ok := errors.AsType[caddyhttp.HandlerError](err)
246 if !ok {
247 t.Fatalf("Test %d (%s): unexpected error: %v", i, tc.path, err)
248 }
249 status = he.StatusCode
250 }
251 if status != tc.wantStatus {
252 t.Errorf("Test %d (%s): expected status %d, got %d (body %q)", i, tc.path, tc.wantStatus, status, w.Body.String())
253 }
254 }
255 }
256
257 // Check to make sure that we don't serve ETag and Last-Modified headers
258 // for files with invalid modification times
259 func TestModTimeHeaders(t *testing.T) {
260 check_validator_headers(time.Now(), true, t)
261 check_validator_headers(time.Unix(0, 0), false, t)
262 check_validator_headers(time.Unix(1, 0), false, t)
263 check_validator_headers(time.Unix(2, 0), true, t)
264 }
265
266 func check_validator_headers(modTime time.Time, expect_headers bool, t *testing.T) {
267 f := false
268 fsrv := FileServer{
269 Root: "./testdata",
270 CanonicalURIs: &f,
271 }
272 w := httptest.NewRecorder()
273 r, err := http.NewRequest("GET", "/modtime.txt", nil)
274 if err != nil {
275 t.Fatal(err)
276 }
277 repl := caddy.NewReplacer()
278 ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
279 r = r.WithContext(ctx)
280
281 ctx2, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) // module will be nil by default
282 fsrv.Provision(ctx2)
283
284 path := "testdata/modtime.txt"
285 os.Chtimes(path, modTime, modTime)
286
287 fsrv.ServeHTTP(w, r, nil)
288
289 if expect_headers {
290 if w.Header().Get("ETag") == "" {
291 t.Errorf("Didn't get ETag header for file with valid mod time %s", modTime)
292 }
293 if w.Header().Get("Last-Modified") == "" {
294 t.Errorf("Didn't get Last-Modified header for file with valid mod time %s", modTime)
295 }
296 } else {
297 if w.Header().Get("ETag") != "" {
298 t.Errorf("Got ETag header for file with invalid mod time %s", modTime)
299 }
300 if w.Header().Get("Last-Modified") != "" {
301 t.Errorf("Got Last-Modified header for file with invalid mod time %s", modTime)
302 }
303 }
304 }
305
306 // calculateEtag concatenates the base-36 mtime and size with no separator,
307 // so distinct (mtime, size) pairs can produce the same digit string and
308 // therefore the same ETag.
309 func TestCalculateEtagCollision(t *testing.T) {
310 fileA := fakeFileInfo{size: 75, modTime: time.Unix(2, 0)}
311 fileB := fakeFileInfo{size: 3, modTime: time.Unix(72, 2)}
312
313 etagA := calculateEtag(fileA)
314 etagB := calculateEtag(fileB)
315
316 if etagA == etagB {
317 t.Fatalf("etag collision: distinct files (size=%d mtime=%s) and (size=%d mtime=%s) both produced ETag %s",
318 fileA.size, fileA.modTime, fileB.size, fileB.modTime, etagA)
319 }
320 }
321
322 type fakeFileInfo struct {
323 size int64
324 modTime time.Time
325 }
326
327 func (f fakeFileInfo) Name() string { return "fake" }
328 func (f fakeFileInfo) Size() int64 { return f.size }
329 func (f fakeFileInfo) Mode() os.FileMode { return 0 }
330 func (f fakeFileInfo) ModTime() time.Time { return f.modTime }
331 func (f fakeFileInfo) IsDir() bool { return false }
332 func (f fakeFileInfo) Sys() any { return nil }
333
334 func TestPrecompressedRangeResponse(t *testing.T) {
335 root := t.TempDir()
336 if err := os.WriteFile(filepath.Join(root, "range.txt"), []byte("original response body"), 0o600); err != nil {
337 t.Fatal(err)
338 }
339
340 sidecar := gzipBytes(t, []byte("original response body"))
341 if err := os.WriteFile(filepath.Join(root, "range.txt.gz"), sidecar, 0o600); err != nil {
342 t.Fatal(err)
343 }
344
345 fsrv := FileServer{
346 Root: root,
347 CanonicalURIs: new(bool),
348 PrecompressedOrder: []string{"gzip"},
349 }
350
351 ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()})
352 if err := fsrv.Provision(ctx); err != nil {
353 t.Fatal(err)
354 }
355 fsrv.precompressors = map[string]encode.Precompressed{
356 "gzip": testPrecompressed{encoding: "gzip", suffix: ".gz"},
357 }
358
359 t.Run("full response", func(t *testing.T) {
360 w := httptest.NewRecorder()
361 r := newPrecompressedRequest(t, "/range.txt")
362 r.Header.Set("Accept-Encoding", "gzip")
363
364 if err := fsrv.ServeHTTP(w, r, nil); err != nil {
365 t.Fatal(err)
366 }
367
368 if got := w.Code; got != http.StatusOK {
369 t.Fatalf("status = %d, want %d", got, http.StatusOK)
370 }
371 if got := w.Header().Get("Content-Encoding"); got != "gzip" {
372 t.Fatalf("Content-Encoding = %q, want gzip", got)
373 }
374 if got := w.Header().Get("Content-Length"); got != fmt.Sprintf("%d", len(sidecar)) {
375 t.Fatalf("Content-Length = %q, want %d", got, len(sidecar))
376 }
377 if got := w.Header().Get("Vary"); got != "Accept-Encoding" {
378 t.Fatalf("Vary = %q, want Accept-Encoding", got)
379 }
380 if got := w.Body.Bytes(); !bytes.Equal(got, sidecar) {
381 t.Fatalf("body len = %d, want len = %d", len(got), len(sidecar))
382 }
383 })
384
385 t.Run("range response", func(t *testing.T) {
386 w := httptest.NewRecorder()
387 r := newPrecompressedRequest(t, "/range.txt")
388 r.Header.Set("Accept-Encoding", "gzip")
389 r.Header.Set("Range", "bytes=2-5")
390
391 if err := fsrv.ServeHTTP(w, r, nil); err != nil {
392 t.Fatal(err)
393 }
394
395 if got := w.Code; got != http.StatusPartialContent {
396 t.Fatalf("status = %d, want %d", got, http.StatusPartialContent)
397 }
398 if got := w.Header().Get("Content-Encoding"); got != "gzip" {
399 t.Fatalf("Content-Encoding = %q, want gzip", got)
400 }
401 wantContentRange := fmt.Sprintf("bytes 2-5/%d", len(sidecar))
402 if got := w.Header().Get("Content-Range"); got != wantContentRange {
403 t.Fatalf("Content-Range = %q, want %q", got, wantContentRange)
404 }
405 if got := w.Header().Get("Content-Length"); got != "4" {
406 t.Fatalf("Content-Length = %q, want 4", got)
407 }
408 if got := w.Header().Get("Vary"); got != "Accept-Encoding" {
409 t.Fatalf("Vary = %q, want Accept-Encoding", got)
410 }
411 if got, want := w.Body.Bytes(), sidecar[2:6]; !bytes.Equal(got, want) {
412 t.Fatalf("body = %x, want %x", got, want)
413 }
414 })
415 }
416
417 func gzipBytes(t *testing.T, data []byte) []byte {
418 t.Helper()
419
420 var buf bytes.Buffer
421 zw := gzip.NewWriter(&buf)
422 if _, err := zw.Write(data); err != nil {
423 t.Fatal(err)
424 }
425 if err := zw.Close(); err != nil {
426 t.Fatal(err)
427 }
428 return buf.Bytes()
429 }
430
431 func newPrecompressedRequest(t *testing.T, target string) *http.Request {
432 t.Helper()
433
434 r := httptest.NewRequest(http.MethodGet, target, nil)
435 repl := caddy.NewReplacer()
436 ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
437 return r.WithContext(ctx)
438 }
439
440 type testPrecompressed struct {
441 encoding string
442 suffix string
443 }
444
445 func (p testPrecompressed) AcceptEncoding() string {
446 return p.encoding
447 }
448
449 func (p testPrecompressed) Suffix() string {
450 return p.suffix
451 }
452
453 // A path below a regular file does not exist, so it is a 404 that pass_thru
454 // falls through, while a path containing a NUL byte is malformed and is a 400.
455 func TestServeHTTPStatErrors(t *testing.T) {
456 root := t.TempDir()
457 if err := os.WriteFile(filepath.Join(root, "file.txt"), nil, 0o600); err != nil {
458 t.Fatal(err)
459 }
460
461 fsrv := FileServer{Root: root}
462 ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
463 defer cancel()
464 if err := fsrv.Provision(ctx); err != nil {
465 t.Fatal(err)
466 }
467
468 for i, tc := range []struct {
469 path string
470 passThru bool
471 wantStatus int
472 wantNext bool
473 }{
474 {path: "/file.txt/child", wantStatus: http.StatusNotFound},
475 {path: "/file.txt/child", passThru: true, wantNext: true},
476 {path: "/file.txt%00", wantStatus: http.StatusBadRequest},
477 } {
478 fsrv.PassThru = tc.passThru
479 r := httptest.NewRequest(http.MethodGet, tc.path, nil)
480 r = r.WithContext(context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer()))
481 var calledNext bool
482 next := caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
483 calledNext = true
484 return nil
485 })
486
487 err := fsrv.ServeHTTP(httptest.NewRecorder(), r, next)
488 if tc.wantNext {
489 if err != nil || !calledNext {
490 t.Errorf("Test %d: expected the next handler, got called=%t err=%v", i, calledNext, err)
491 }
492 continue
493 }
494 if he, ok := errors.AsType[caddyhttp.HandlerError](err); !ok || he.StatusCode != tc.wantStatus {
495 t.Errorf("Test %d: expected status %d, got %v", i, tc.wantStatus, err)
496 }
497 }
498 }
499
500 // A trailing slash denotes a directory, so path matchers don't treat
501 // "/secret.env/" as "*.env"; the file server must not serve the file either.
502 func TestServeHTTPTrailingSlashOnFile(t *testing.T) {
503 root := t.TempDir()
504 if err := os.WriteFile(filepath.Join(root, "secret.env"), []byte("secret"), 0o600); err != nil {
505 t.Fatal(err)
506 }
507 if err := os.WriteFile(filepath.Join(root, "secret.env.gz"), gzipBytes(t, []byte("secret")), 0o600); err != nil {
508 t.Fatal(err)
509 }
510 if err := os.Mkdir(filepath.Join(root, "dir"), 0o700); err != nil {
511 t.Fatal(err)
512 }
513 if err := os.WriteFile(filepath.Join(root, "dir", "index.html"), []byte("index"), 0o600); err != nil {
514 t.Fatal(err)
515 }
516
517 for i, tc := range []struct {
518 path string
519 canonical bool
520 acceptEncoding string
521 passThru bool
522 wantStatus int
523 wantLocation string
524 wantNext bool
525 }{
526 {path: "/secret.env", wantStatus: http.StatusOK},
527 {path: "/secret.env/", wantStatus: http.StatusNotFound},
528 {path: "/secret.env/./", wantStatus: http.StatusNotFound},
529 {path: "/secret.env//", wantStatus: http.StatusNotFound},
530 {path: "/secret.env%2F", wantStatus: http.StatusNotFound},
531 {path: "/secret.env%5c", wantStatus: http.StatusNotFound},
532 {path: "/secret.env%5C", wantStatus: http.StatusNotFound},
533 {path: `/secret.env\`, wantStatus: http.StatusNotFound},
534 {path: "/secret.env%5c", canonical: true, wantStatus: http.StatusNotFound},
535 {path: "/secret.env%5C", canonical: true, wantStatus: http.StatusNotFound},
536 {path: `/secret.env\`, canonical: true, wantStatus: http.StatusNotFound},
537 {path: "/secret.env/", acceptEncoding: "gzip", wantStatus: http.StatusNotFound},
538 {path: "/secret.env/", passThru: true, wantNext: true},
539 {path: "/secret.env/", canonical: true, wantStatus: http.StatusPermanentRedirect, wantLocation: "/secret.env"},
540 {path: "/dir/", wantStatus: http.StatusOK},
541 {path: "/dir", wantStatus: http.StatusOK},
542 {path: "/dir/index.html/", wantStatus: http.StatusNotFound},
543 } {
544 fsrv := FileServer{
545 Root: root,
546 CanonicalURIs: &tc.canonical,
547 PassThru: tc.passThru,
548 PrecompressedOrder: []string{"gzip"},
549 }
550 ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
551 if err := fsrv.Provision(ctx); err != nil {
552 t.Fatal(err)
553 }
554 fsrv.precompressors = map[string]encode.Precompressed{
555 "gzip": testPrecompressed{encoding: "gzip", suffix: ".gz"},
556 }
557
558 r := httptest.NewRequest(http.MethodGet, tc.path, nil)
559 r.Header.Set("Accept-Encoding", tc.acceptEncoding)
560 reqCtx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
561 reqCtx = context.WithValue(reqCtx, caddyhttp.OriginalRequestCtxKey, *r)
562 r = r.WithContext(reqCtx)
563
564 var calledNext bool
565 next := caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
566 calledNext = true
567 return nil
568 })
569
570 w := httptest.NewRecorder()
571 err := fsrv.ServeHTTP(w, r, next)
572 cancel()
573
574 if tc.wantNext {
575 if err != nil || !calledNext {
576 t.Errorf("Test %d (%s): expected the next handler, got called=%t err=%v", i, tc.path, calledNext, err)
577 }
578 continue
579 }
580 status := w.Code
581 if he, ok := errors.AsType[caddyhttp.HandlerError](err); ok {
582 status = he.StatusCode
583 } else if err != nil {
584 t.Errorf("Test %d (%s): unexpected error: %v", i, tc.path, err)
585 continue
586 }
587 if status != tc.wantStatus {
588 t.Errorf("Test %d (%s): expected status %d, got %d", i, tc.path, tc.wantStatus, status)
589 }
590 if got := w.Header().Get("Location"); got != tc.wantLocation {
591 t.Errorf("Test %d (%s): expected Location %q, got %q", i, tc.path, tc.wantLocation, got)
592 }
593 }
594 }
595
596 // The trailing separator of a filesystem root such as "/" or `C:\` must be
597 // kept, else `C:` resolves to the current directory of the drive.
598 func TestServeHTTPFilesystemRoot(t *testing.T) {
599 wd, err := os.Getwd()
600 if err != nil {
601 t.Fatal(err)
602 }
603 volume := filepath.VolumeName(wd)
604 // top-level directory leading to wd, listed only if the root is served
605 topDir := strings.Split(strings.TrimPrefix(wd[len(volume):], separator), separator)[0]
606
607 roots := []string{volume + separator}
608 if runtime.GOOS == "windows" {
609 roots = append(roots, separator)
610 }
611 for _, root := range roots {
612 t.Run(root, func(t *testing.T) {
613 fsrv := FileServer{Root: root, Browse: &Browse{}}
614 ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
615 defer cancel()
616 if err := fsrv.Provision(ctx); err != nil {
617 t.Fatal(err)
618 }
619
620 r := httptest.NewRequest(http.MethodGet, "/", nil)
621 reqCtx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
622 reqCtx = context.WithValue(reqCtx, caddyhttp.OriginalRequestCtxKey, *r)
623 r = r.WithContext(reqCtx)
624 r.Header.Set("Accept", "application/json")
625 w := httptest.NewRecorder()
626 if err := fsrv.ServeHTTP(w, r, nil); err != nil {
627 t.Fatal(err)
628 }
629 if w.Code != http.StatusOK {
630 t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
631 }
632
633 var items []struct {
634 Name string `json:"name"`
635 }
636 if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
637 t.Fatal(err)
638 }
639 for _, item := range items {
640 if strings.TrimSuffix(item.Name, "/") == topDir {
641 return
642 }
643 }
644 t.Errorf("listing of %q doesn't contain %q: %s", root, topDir, w.Body.String())
645 })
646 }
647 }