package hw3test import ( "bufio" "bytes" "compress/gzip" "crypto/sha256" "fmt" "io" "math/rand" "net" "net/http" "net/url" "os" "path/filepath" "reflect" "strings" "time" "github.com/stretchr/testify/require" ) // Contract cases use a small separate tree, so snapshots never scan G6/G7 files. func RunContractTests(t *TC, runner Runner, tmpRoot, group string) { t.RunByName("contract", func(t *TC) { workdir, err := os.MkdirTemp(tmpRoot, "contract-") require.NoError(t, err) defer os.RemoveAll(workdir) require.NoError(t, os.Chmod(workdir, 0755)) require.NoError(t, os.MkdirAll(filepath.Join(workdir, "dir", "nested"), 0755)) ascii := []byte("hello\r\nworld\n") binary := []byte{0, 255, 13, 10, 13, 10, 128, 1} if group == "G1" || group == "G3" { binary = ascii } for name, body := range map[string][]byte{ "alpha.txt": ascii, "binary": binary, "empty": {}, ".hidden": []byte("hidden"), "dir/nested/child": []byte("child"), } { require.NoError(t, os.WriteFile(filepath.Join(workdir, filepath.FromSlash(name)), body, 0644)) } port, err := GetFreePort() require.NoError(t, err) opts := RunOpts{Port: port, WorkingDirectory: workdir, ListenAddr: "0.0.0.0", ServerDomain: "localhost"} opts.GenerateRunConfig(t, rand.New(rand.NewSource(9103)), &EnvGen{AllowEnv: true}) stop, err := runner.Run(t, opts) require.NoError(t, err) defer stop() require.NoError(t, WaitForServer(t, opts)) extra := group == "G5" || group == "G7" request := func(method, path, headers string, body []byte) string { return fmt.Sprintf("%s %s HTTP/1.1\r\nhOsT: LOCALHOST\r\ncOnTeNt-LeNgTh: %d\r\n%s\r\n%s", method, path, len(body), headers, body) } run := func(name, raw string, split bool, codes []int, expected []byte, unchanged bool) { t.RunByName(name, func(t *TC) { before, err := snapshotTree(workdir) require.NoError(t, err) parts := [][]byte{[]byte(raw)} if split { // Split inside a header and inside CRLFCRLF; coalesce its end with body bytes. boundary := strings.Index(raw, "\r\n\r\n") parts = [][]byte{[]byte(raw[:9]), []byte(raw[9 : boundary+3]), []byte(raw[boundary+3:])} } resp, body := contractExchange(t, opts, parts) require.Contains(t, codes, resp.StatusCode) if resp.StatusCode >= 400 { require.NotEmpty(t, bytes.TrimSpace(body), "error explanation is empty") } if extra { require.NotEmpty(t, resp.Header.Get("Server")) if len(body) > 0 { mediaType, err := parseContentType(resp.Header.Get("Content-Type")) require.NoError(t, err) if methodIsDirectoryGet(raw) && resp.StatusCode == 200 { require.Contains(t, []string{"text/plain", "text/html"}, mediaType) } } } if expected != nil { require.Empty(t, resp.Header.Get("Content-Encoding")) require.Equal(t, expected, body) } if unchanged { after, err := snapshotTree(workdir) require.NoError(t, err) require.NoError(t, compareSnapshots(before, after)) } }) } if group == "G4" || group == "G5" || group == "G6" || group == "G7" { run("post-root-file", request("POST", "/", "", binary), false, []int{409}, nil, true) run("post-root-dir", request("POST", "/", "Create-Directory: True\r\n", nil), false, []int{409}, nil, true) run("put-root", request("PUT", "/", "", binary), false, []int{409}, nil, true) for _, value := range []string{"absent", "False", "True"} { header := "" if value != "absent" { header = "Remove-Directory: " + value + "\r\n" } run("delete-root-"+value, request("DELETE", "/", header, nil), false, []int{403}, nil, true) } if extra { raw := strings.Replace(request("DELETE", "/", "Remove-Directory: True\r\n", nil), "LOCALHOST", "wrong.example", 1) run("wrong-host-delete-root", raw, false, []int{400}, nil, true) } } switch group { case "G1": run("split-headers", request("GET", "/alpha.txt", "", nil), true, []int{200}, ascii, true) // No Content-Length is also valid for an empty request. run("no-request-body", "GET /alpha.txt HTTP/1.1\r\nHost: localhost\r\n\r\n", false, []int{200}, ascii, true) run("empty-file", request("GET", "/empty", "", nil), false, []int{200}, []byte{}, true) case "G2": run("binary-body", request("GET", "/binary", "", nil), true, []int{200}, binary, true) case "G3": run("missing", request("GET", "/missing", "", nil), false, []int{404}, nil, true) t.RunByName("root-listing", func(t *TC) { resp, body := contractExchange(t, opts, [][]byte{[]byte(request("GET", "/", "", nil))}) require.Equal(t, 200, resp.StatusCode) require.Empty(t, resp.Header.Get("Content-Encoding")) for _, name := range []string{"alpha.txt", "binary", "empty", ".hidden", "dir"} { require.Contains(t, string(body), name) } }) case "G4", "G5": for _, tc := range []struct { name, method, path, headers string code int }{ {"post-existing-file", "POST", "/alpha.txt", "", 409}, {"post-existing-dir", "POST", "/dir", "Create-Directory: True\r\n", 409}, {"post-missing-parent", "POST", "/missing/new", "", 404}, {"post-dir-missing-parent", "POST", "/missing/new", "Create-Directory: True\r\n", 404}, {"post-file-as-parent", "POST", "/alpha.txt/child", "", 404}, {"put-missing", "PUT", "/missing", "", 404}, {"put-dir", "PUT", "/dir", "", 409}, {"delete-missing", "DELETE", "/missing", "", 404}, {"delete-dir-absent", "DELETE", "/dir", "", 406}, {"delete-dir-false", "DELETE", "/dir", "rEmOvE-dIrEcToRy: False\r\n", 406}, } { body := []byte(nil) if tc.method == "PUT" || (tc.method == "POST" && tc.headers == "") { body = binary } run(tc.name, request(tc.method, tc.path, tc.headers, body), false, []int{tc.code}, nil, true) } run("create-file-false", request("POST", "/new", "cReAtE-dIrEcToRy: False\r\n", binary), true, []int{200, 201}, nil, false) run("read-created", request("GET", "/new", "", nil), false, []int{200}, binary, true) run("create-empty", request("POST", "/new-empty", "", nil), false, []int{200, 201}, nil, false) run("read-empty", request("GET", "/new-empty", "", nil), false, []int{200}, []byte{}, true) run("replace-shorter", request("PUT", "/alpha.txt", "", []byte("x")), true, []int{200, 204}, nil, false) run("read-shorter", request("GET", "/alpha.txt", "", nil), false, []int{200}, []byte("x"), true) run("replace-empty", request("PUT", "/alpha.txt", "", nil), false, []int{200, 204}, nil, false) run("read-replaced-empty", request("GET", "/alpha.txt", "", nil), false, []int{200}, []byte{}, true) run("create-dir", request("POST", "/created-dir", "cReAtE-dIrEcToRy: True\r\n", nil), false, []int{200, 201}, nil, false) t.RunByName("created-dir-on-disk", func(t *TC) { entries, err := os.ReadDir(filepath.Join(workdir, "created-dir")) require.NoError(t, err) require.Empty(t, entries) }) run("recursive-delete", request("DELETE", "/dir", "rEmOvE-dIrEcToRy: True\r\n", nil), false, []int{200}, nil, false) t.RunByName("deleted-tree-on-disk", func(t *TC) { _, err := os.Stat(filepath.Join(workdir, "dir")) require.True(t, os.IsNotExist(err), "directory must be removed") }) if extra { for _, method := range []string{"GET", "POST", "PUT", "DELETE"} { raw := strings.Replace(request(method, "/alpha.txt", "", []byte("changed")), "LOCALHOST", "wrong.example", 1) run("wrong-host-"+method, raw, false, []int{400}, nil, true) } } case "G7": for _, path := range []string{"/binary", "/dir", "/empty"} { t.RunByName("gzip-"+strings.TrimPrefix(path, "/"), func(t *TC) { before, err := snapshotTree(workdir) require.NoError(t, err) resp, body := contractExchange(t, opts, [][]byte{[]byte(request("GET", path, "aCcEpT-eNcOdInG: gzip\r\n", nil))}) require.Equal(t, 200, resp.StatusCode) require.Equal(t, "gzip", resp.Header.Get("Content-Encoding")) require.NotEmpty(t, resp.Header.Get("Server")) mediaType, err := parseContentType(resp.Header.Get("Content-Type")) require.NoError(t, err) gz, err := gzip.NewReader(bytes.NewReader(body)) require.NoError(t, err) decoded, err := io.ReadAll(gz) require.NoError(t, err) require.NoError(t, gz.Close()) if path == "/binary" { require.Equal(t, binary, decoded) } if path == "/empty" { require.Empty(t, decoded) } if path == "/dir" { require.Contains(t, string(decoded), "nested") require.Contains(t, []string{"text/plain", "text/html"}, mediaType) } after, err := snapshotTree(workdir) require.NoError(t, err) require.NoError(t, compareSnapshots(before, after)) }) } } }) } func methodIsDirectoryGet(raw string) bool { return strings.HasPrefix(raw, "GET / HTTP/") } // The client leaves its sending side open: a server reading to EOF must time out. func contractExchange(t *TC, opts RunOpts, parts [][]byte) (*http.Response, []byte) { req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(bytes.Join(parts, nil)))) require.NoError(t, err) defer req.Body.Close() address, err := url.Parse(opts.Address()) require.NoError(t, err) conn, err := net.DialTimeout("tcp", address.Host, 10*time.Second) require.NoError(t, err) defer conn.Close() require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) for i, part := range parts { _, err := io.Copy(conn, bytes.NewReader(part)) require.NoError(t, err) if i+1 < len(parts) { time.Sleep(20 * time.Millisecond) } } resp, body, err := readContractResponse(bufio.NewReader(conn), req.ContentLength > 0) require.NoError(t, err) return resp, body } func readContractResponse(reader *bufio.Reader, requestHasBody bool) (*http.Response, []byte, error) { resp, err := http.ReadResponse(reader, nil) if err != nil { return nil, nil, err } defer resp.Body.Close() if err := validateResponseFraming(resp); err != nil { return resp, nil, err } body, err := io.ReadAll(resp.Body) if err != nil { return resp, nil, err } if resp.StatusCode != 204 && int64(len(body)) != resp.ContentLength { return resp, nil, fmt.Errorf("response length mismatch") } if err := checkResponseEnd(reader, requestHasBody); err != nil { return resp, nil, err } return resp, body, nil } type treeEntry struct { Mode os.FileMode Size int64 Digest [32]byte } func snapshotTree(root string) (map[string]treeEntry, error) { result := make(map[string]treeEntry) err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error { if err != nil { return err } rel, err := filepath.Rel(root, p) if err != nil { return err } entry := treeEntry{Mode: info.Mode()} if info.Mode().IsRegular() { entry.Size = info.Size() f, err := os.Open(p) if err != nil { return err } h := sha256.New() _, copyErr := io.Copy(h, f) closeErr := f.Close() if copyErr != nil { return copyErr } if closeErr != nil { return closeErr } copy(entry.Digest[:], h.Sum(nil)) } result[rel] = entry return nil }) return result, err } func compareSnapshots(before, after map[string]treeEntry) error { if !reflect.DeepEqual(before, after) { return fmt.Errorf("request changed the file system unexpectedly") } return nil }