302 lines
11 KiB
Go
302 lines
11 KiB
Go
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
|
|
}
|