diff options
Diffstat (limited to 'middleware/file')
-rw-r--r-- | middleware/file/file.go | 89 | ||||
-rw-r--r-- | middleware/file/file_test.go | 325 |
2 files changed, 414 insertions, 0 deletions
diff --git a/middleware/file/file.go b/middleware/file/file.go new file mode 100644 index 000000000..5bc5a3a3a --- /dev/null +++ b/middleware/file/file.go @@ -0,0 +1,89 @@ +package file + +// TODO(miek): the zone's implementation is basically non-existent +// we return a list and when searching for an answer we iterate +// over the list. This must be moved to a tree-like structure and +// have some fluff for DNSSEC (and be memory efficient). + +import ( + "strings" + + "github.com/miekg/coredns/middleware" + "github.com/miekg/dns" +) + +type ( + File struct { + Next middleware.Handler + Zones Zones + // Maybe a list of all zones as well, as a []string? + } + + Zone []dns.RR + Zones struct { + Z map[string]Zone // utterly braindead impl. TODO(miek): fix + Names []string + } +) + +func (f File) ServeDNS(w dns.ResponseWriter, r *dns.Msg) (int, error) { + context := middleware.Context{W: w, Req: r} + qname := context.Name() + zone := middleware.Zones(f.Zones.Names).Matches(qname) + if zone == "" { + return f.Next.ServeDNS(w, r) + } + + names, nodata := f.Zones.Z[zone].lookup(qname, context.QType()) + var answer *dns.Msg + switch { + case nodata: + answer = context.AnswerMessage() + answer.Ns = names + case len(names) == 0: + answer = context.AnswerMessage() + answer.Ns = names + answer.Rcode = dns.RcodeNameError + case len(names) > 0: + answer = context.AnswerMessage() + answer.Answer = names + default: + answer = context.ErrorMessage(dns.RcodeServerFailure) + } + // Check return size, etc. TODO(miek) + w.WriteMsg(answer) + return 0, nil +} + +// Lookup will try to find qname and qtype in z. It returns the +// records found *or* a boolean saying NODATA. If the answer +// is NODATA then the RR returned is the SOA record. +// +// TODO(miek): EXTREMELY STUPID IMPLEMENTATION. +// Doesn't do much, no delegation, no cname, nothing really, etc. +// TODO(miek): even NODATA looks broken +func (z Zone) lookup(qname string, qtype uint16) ([]dns.RR, bool) { + var ( + nodata bool + rep []dns.RR + soa dns.RR + ) + + for _, rr := range z { + if rr.Header().Rrtype == dns.TypeSOA { + soa = rr + } + // Match function in Go DNS? + if strings.ToLower(rr.Header().Name) == qname { + if rr.Header().Rrtype == qtype { + rep = append(rep, rr) + nodata = false + } + + } + } + if nodata { + return []dns.RR{soa}, true + } + return rep, false +} diff --git a/middleware/file/file_test.go b/middleware/file/file_test.go new file mode 100644 index 000000000..54584b5cc --- /dev/null +++ b/middleware/file/file_test.go @@ -0,0 +1,325 @@ +package file + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +var testDir = filepath.Join(os.TempDir(), "caddy_testdir") +var ErrCustom = errors.New("Custom Error") + +// testFiles is a map with relative paths to test files as keys and file content as values. +// The map represents the following structure: +// - $TEMP/caddy_testdir/ +// '-- file1.html +// '-- dirwithindex/ +// '---- index.html +// '-- dir/ +// '---- file2.html +// '---- hidden.html +var testFiles = map[string]string{ + "file1.html": "<h1>file1.html</h1>", + filepath.Join("dirwithindex", "index.html"): "<h1>dirwithindex/index.html</h1>", + filepath.Join("dir", "file2.html"): "<h1>dir/file2.html</h1>", + filepath.Join("dir", "hidden.html"): "<h1>dir/hidden.html</h1>", +} + +// TestServeHTTP covers positive scenarios when serving files. +func TestServeHTTP(t *testing.T) { + + beforeServeHTTPTest(t) + defer afterServeHTTPTest(t) + + fileserver := FileServer(http.Dir(testDir), []string{"hidden.html"}) + + movedPermanently := "Moved Permanently" + + tests := []struct { + url string + + expectedStatus int + expectedBodyContent string + }{ + // Test 0 - access without any path + { + url: "https://foo", + expectedStatus: http.StatusNotFound, + }, + // Test 1 - access root (without index.html) + { + url: "https://foo/", + expectedStatus: http.StatusNotFound, + }, + // Test 2 - access existing file + { + url: "https://foo/file1.html", + expectedStatus: http.StatusOK, + expectedBodyContent: testFiles["file1.html"], + }, + // Test 3 - access folder with index file with trailing slash + { + url: "https://foo/dirwithindex/", + expectedStatus: http.StatusOK, + expectedBodyContent: testFiles[filepath.Join("dirwithindex", "index.html")], + }, + // Test 4 - access folder with index file without trailing slash + { + url: "https://foo/dirwithindex", + expectedStatus: http.StatusMovedPermanently, + expectedBodyContent: movedPermanently, + }, + // Test 5 - access folder without index file + { + url: "https://foo/dir/", + expectedStatus: http.StatusNotFound, + }, + // Test 6 - access folder without trailing slash + { + url: "https://foo/dir", + expectedStatus: http.StatusMovedPermanently, + expectedBodyContent: movedPermanently, + }, + // Test 6 - access file with trailing slash + { + url: "https://foo/file1.html/", + expectedStatus: http.StatusMovedPermanently, + expectedBodyContent: movedPermanently, + }, + // Test 7 - access not existing path + { + url: "https://foo/not_existing", + expectedStatus: http.StatusNotFound, + }, + // Test 8 - access a file, marked as hidden + { + url: "https://foo/dir/hidden.html", + expectedStatus: http.StatusNotFound, + }, + // Test 9 - access a index file directly + { + url: "https://foo/dirwithindex/index.html", + expectedStatus: http.StatusOK, + expectedBodyContent: testFiles[filepath.Join("dirwithindex", "index.html")], + }, + // Test 10 - send a request with query params + { + url: "https://foo/dir?param1=val", + expectedStatus: http.StatusMovedPermanently, + expectedBodyContent: movedPermanently, + }, + } + + for i, test := range tests { + responseRecorder := httptest.NewRecorder() + request, err := http.NewRequest("GET", test.url, strings.NewReader("")) + status, err := fileserver.ServeHTTP(responseRecorder, request) + + // check if error matches expectations + if err != nil { + t.Errorf(getTestPrefix(i)+"Serving file at %s failed. Error was: %v", test.url, err) + } + + // check status code + if test.expectedStatus != status { + t.Errorf(getTestPrefix(i)+"Expected status %d, found %d", test.expectedStatus, status) + } + + // check body content + if !strings.Contains(responseRecorder.Body.String(), test.expectedBodyContent) { + t.Errorf(getTestPrefix(i)+"Expected body to contain %q, found %q", test.expectedBodyContent, responseRecorder.Body.String()) + } + } + +} + +// beforeServeHTTPTest creates a test directory with the structure, defined in the variable testFiles +func beforeServeHTTPTest(t *testing.T) { + // make the root test dir + err := os.Mkdir(testDir, os.ModePerm) + if err != nil { + if !os.IsExist(err) { + t.Fatalf("Failed to create test dir. Error was: %v", err) + return + } + } + + for relFile, fileContent := range testFiles { + absFile := filepath.Join(testDir, relFile) + + // make sure the parent directories exist + parentDir := filepath.Dir(absFile) + _, err = os.Stat(parentDir) + if err != nil { + os.MkdirAll(parentDir, os.ModePerm) + } + + // now create the test files + f, err := os.Create(absFile) + if err != nil { + t.Fatalf("Failed to create test file %s. Error was: %v", absFile, err) + return + } + + // and fill them with content + _, err = f.WriteString(fileContent) + if err != nil { + t.Fatalf("Failed to write to %s. Error was: %v", absFile, err) + return + } + f.Close() + } + +} + +// afterServeHTTPTest removes the test dir and all its content +func afterServeHTTPTest(t *testing.T) { + // cleans up everything under the test dir. No need to clean the individual files. + err := os.RemoveAll(testDir) + if err != nil { + t.Fatalf("Failed to clean up test dir %s. Error was: %v", testDir, err) + } +} + +// failingFS implements the http.FileSystem interface. The Open method always returns the error, assigned to err +type failingFS struct { + err error // the error to return when Open is called + fileImpl http.File // inject the file implementation +} + +// Open returns the assigned failingFile and error +func (f failingFS) Open(path string) (http.File, error) { + return f.fileImpl, f.err +} + +// failingFile implements http.File but returns a predefined error on every Stat() method call. +type failingFile struct { + http.File + err error +} + +// Stat returns nil FileInfo and the provided error on every call +func (ff failingFile) Stat() (os.FileInfo, error) { + return nil, ff.err +} + +// Close is noop and returns no error +func (ff failingFile) Close() error { + return nil +} + +// TestServeHTTPFailingFS tests error cases where the Open function fails with various errors. +func TestServeHTTPFailingFS(t *testing.T) { + + tests := []struct { + fsErr error + expectedStatus int + expectedErr error + expectedHeaders map[string]string + }{ + { + fsErr: os.ErrNotExist, + expectedStatus: http.StatusNotFound, + expectedErr: nil, + }, + { + fsErr: os.ErrPermission, + expectedStatus: http.StatusForbidden, + expectedErr: os.ErrPermission, + }, + { + fsErr: ErrCustom, + expectedStatus: http.StatusServiceUnavailable, + expectedErr: ErrCustom, + expectedHeaders: map[string]string{"Retry-After": "5"}, + }, + } + + for i, test := range tests { + // initialize a file server with the failing FileSystem + fileserver := FileServer(failingFS{err: test.fsErr}, nil) + + // prepare the request and response + request, err := http.NewRequest("GET", "https://foo/", nil) + if err != nil { + t.Fatalf("Failed to build request. Error was: %v", err) + } + responseRecorder := httptest.NewRecorder() + + status, actualErr := fileserver.ServeHTTP(responseRecorder, request) + + // check the status + if status != test.expectedStatus { + t.Errorf(getTestPrefix(i)+"Expected status %d, found %d", test.expectedStatus, status) + } + + // check the error + if actualErr != test.expectedErr { + t.Errorf(getTestPrefix(i)+"Expected err %v, found %v", test.expectedErr, actualErr) + } + + // check the headers - a special case for server under load + if test.expectedHeaders != nil && len(test.expectedHeaders) > 0 { + for expectedKey, expectedVal := range test.expectedHeaders { + actualVal := responseRecorder.Header().Get(expectedKey) + if expectedVal != actualVal { + t.Errorf(getTestPrefix(i)+"Expected header %s: %s, found %s", expectedKey, expectedVal, actualVal) + } + } + } + } +} + +// TestServeHTTPFailingStat tests error cases where the initial Open function succeeds, but the Stat method on the opened file fails. +func TestServeHTTPFailingStat(t *testing.T) { + + tests := []struct { + statErr error + expectedStatus int + expectedErr error + }{ + { + statErr: os.ErrNotExist, + expectedStatus: http.StatusNotFound, + expectedErr: nil, + }, + { + statErr: os.ErrPermission, + expectedStatus: http.StatusForbidden, + expectedErr: os.ErrPermission, + }, + { + statErr: ErrCustom, + expectedStatus: http.StatusInternalServerError, + expectedErr: ErrCustom, + }, + } + + for i, test := range tests { + // initialize a file server. The FileSystem will not fail, but calls to the Stat method of the returned File object will + fileserver := FileServer(failingFS{err: nil, fileImpl: failingFile{err: test.statErr}}, nil) + + // prepare the request and response + request, err := http.NewRequest("GET", "https://foo/", nil) + if err != nil { + t.Fatalf("Failed to build request. Error was: %v", err) + } + responseRecorder := httptest.NewRecorder() + + status, actualErr := fileserver.ServeHTTP(responseRecorder, request) + + // check the status + if status != test.expectedStatus { + t.Errorf(getTestPrefix(i)+"Expected status %d, found %d", test.expectedStatus, status) + } + + // check the error + if actualErr != test.expectedErr { + t.Errorf(getTestPrefix(i)+"Expected err %v, found %v", test.expectedErr, actualErr) + } + } +} |