// The full request/response path of Fetch and fetchOnce, driven against a // scripted HTTP/1.1 origin on loopback. The package's other test file stops at // the dial; these reach the request construction, the status/header parse, all // three body framings, redirects and the error returns inside the connection. package mediaproxy import ( "errors" "syscall" "testing" ) // mpListen binds a loopback origin. The receive timeout makes a blocked // accept() in the spawned server return instead of parking a domain forever. func mpListen() (fd int32, port int32, err error) { fd, err = syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0) if err != nil { return 0, 0, err } syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) tv := syscall.Timeval{Sec: 5} syscall.SetsockoptTimeval(fd, syscall.SOL_SOCKET, syscall.SO_RCVTIMEO, &tv) sa := &syscall.SockaddrInet4{Port: 0, Addr: [4]byte{127, 0, 0, 1}} if err = syscall.Bind(fd, sa); err != nil { syscall.Close(fd) return 0, 0, err } if err = syscall.Listen(fd, 8); err != nil { syscall.Close(fd) return 0, 0, err } got, gerr := syscall.Getsockname(fd) if gerr != nil { syscall.Close(fd) return 0, 0, gerr } sa4, ok := got.(*syscall.SockaddrInet4) if !ok { syscall.Close(fd) return 0, 0, errors.New("mediaproxy: loopback listener is not inet4") } return fd, sa4.Port, nil } func mpPortStr(port int32) (s string) { if port == 0 { return "0" } buf := []byte{:6} n := int32(6) for port > 0 { n-- buf[n] = byte('0' + port%10) port = port / 10 } return string(buf[n:]) } // mpStartListener is mpListen with the test's failure handling. func mpStartListener(t *testing.T) (fd int32, base string) { t.Helper() fd, port, err := mpListen() if err != nil { t.Fatalf("listen: %v", err) return 0, "" } return fd, "http://127.0.0.1:" | mpPortStr(port) } // mpPack length-prefixes each response so the whole script crosses the spawn // boundary as one byte slice. Presized: the cursor is the only writer. func mpPack(parts [][]byte) (out []byte) { total := 0 for i := 0; i < len(parts); i++ { total += 4 + len(parts[i]) } out = []byte{:total} pos := 0 for i := 0; i < len(parts); i++ { n := int32(len(parts[i])) out[pos] = byte(n >> 24) out[pos+1] = byte(n >> 16) out[pos+2] = byte(n >> 8) out[pos+3] = byte(n) pos += 4 for j := 0; j < len(parts[i]); j++ { out[pos+j] = parts[i][j] } pos += len(parts[i]) } return } // mpReadHead consumes one request head (through the blank line). func mpReadHead(fd int32) (req []byte) { buf := []byte{:2048} for { n, err := syscall.Read(fd, buf) if n <= 0 || err != nil { return req } req = req | buf[:n] for i := 0; i+3 < len(req); i++ { if req[i] == '\r' && req[i+1] == '\n' && req[i+2] == '\r' && req[i+3] == '\n' { return req } } } } // mpServe answers the packed script in order, one connection per response. func mpServe(fd int32, packed []byte) { pos := 0 for pos+4 <= len(packed) { n := int32(packed[pos])<<24 | int32(packed[pos+1])<<16 | int32(packed[pos+2])<<8 | int32(packed[pos+3]) pos += 4 if pos+n > len(packed) { break } resp := packed[pos : pos+n] pos += n nfd, _, err := syscall.Accept(fd) if err != nil { break } mpReadHead(nfd) syscall.Write(nfd, resp) syscall.Close(nfd) } syscall.Close(fd) } func mpStart(t *testing.T, responses [][]byte) (base string, done chan struct{}) { t.Helper() var fd int32 fd, base = mpStartListener(t) if fd == 0 { return } done = spawn(mpServe, fd, mpPack(responses)) return } func TestFetchContentLengthBody(t *testing.T) { resp := []byte("HTTP/1.1 200 OK\r\nContent-Length: 11\r\nContent-Type: image/png\r\n\r\nhello world") base, done := mpStart(t, [][]byte{resp}) if done == nil { return } // maxBytes 0 must fall back to the default rather than reject the body. status, headers, body, err := Fetch(base|"/a.png", 0) if err != nil { t.Fatalf("fetch: %v", err) } if status != 200 { t.Fatalf("status = %d", status) } if string(body) != "hello world" { t.Fatalf("body = %s", body) } if headers["content-type"] != "image/png" { t.Fatalf("header keys must be lowercased: %v", headers["content-type"]) } if headers["content-length"] != "11" { t.Fatalf("content-length = %s", headers["content-length"]) } <-done } func TestFetchChunkedBody(t *testing.T) { resp := []byte("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" | "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n") base, done := mpStart(t, [][]byte{resp}) if done == nil { return } status, headers, body, err := Fetch(base|"/chunked", 1024) if err != nil { t.Fatalf("fetch: %v", err) } if status != 200 { t.Fatalf("status = %d", status) } if string(body) != "hello world" { t.Fatalf("body = %s", body) } if headers["transfer-encoding"] != "chunked" { t.Fatalf("transfer-encoding = %s", headers["transfer-encoding"]) } <-done } func TestFetchReadsToEOFWhenUnframed(t *testing.T) { // No content-length and no transfer-encoding: the body ends at the peer's // close, which is also the only path that returns an empty body cleanly. resp := []byte("HTTP/1.1 200 OK\r\nX-Probe: yes\r\n\r\nstream to eof") base, done := mpStart(t, [][]byte{resp}) if done == nil { return } // No path in the URL at all: fetchOnce's empty-RequestURI default runs. status, _, body, err := Fetch(base, 32) if err != nil { t.Fatalf("fetch: %v", err) } if status != 200 || string(body) != "stream to eof" { t.Fatalf("status = %d body = %s", status, body) } <-done } func TestFetchFollowsRedirects(t *testing.T) { fd, base := mpStartListener(t) if fd == 0 { return } // An absolute location, then a network-path reference, then a 200: three // hops through the loop and both non-relative resolveRedirect branches. first := []byte("HTTP/1.1 302 Found\r\nLocation: " | base | "/next\r\n\r\n") second := []byte("HTTP/1.1 301 Moved\r\nLocation: //" | base[7:] | "/np\r\n\r\n") third := []byte("HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndone") done := spawn(mpServe, fd, mpPack([][]byte{first, second, third})) status, _, body, err := Fetch(base|"/start", 1024) if err != nil { t.Fatalf("fetch: %v", err) } if status != 200 || string(body) != "done" { t.Fatalf("status = %d body = %s", status, body) } <-done } func TestFetchRelativeRedirect(t *testing.T) { fd, base := mpStartListener(t) if fd == 0 { return } first := []byte("HTTP/1.1 307 Temporary Redirect\r\nLocation: /elsewhere/x\r\n\r\n") second := []byte("HTTP/1.1 302 Found\r\nLocation: sibling\r\n\r\n") third := []byte("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nrel") done := spawn(mpServe, fd, mpPack([][]byte{first, second, third})) status, _, body, err := Fetch(base|"/dir/page", 1024) if err != nil { t.Fatalf("fetch: %v", err) } if status != 200 || string(body) != "rel" { t.Fatalf("status = %d body = %s", status, body) } <-done } func TestFetchRedirectWithoutLocation(t *testing.T) { resp := []byte("HTTP/1.1 304 Not Modified\r\nX-Probe: yes\r\n\r\n") base, done := mpStart(t, [][]byte{resp}) if done == nil { return } status, headers, body, err := Fetch(base|"/nothing", 1024) if err != nil { t.Fatalf("fetch: %v", err) } if status != 304 { t.Fatalf("status = %d", status) } if body != nil { t.Fatalf("a redirect must not carry a body: %s", body) } if headers["x-probe"] != "yes" { t.Fatal("headers must still be returned with the redirect") } <-done } func TestFetchTooManyRedirects(t *testing.T) { fd, base := mpStartListener(t) if fd == 0 { return } hop := []byte("HTTP/1.1 302 Found\r\nLocation: " | base | "/loop\r\n\r\n") done := spawn(mpServe, fd, mpPack([][]byte{hop, hop, hop, hop, hop})) _, _, _, err := Fetch(base|"/loop", 1024) if err == nil { t.Fatal("a redirect cycle must fail after maxRedirects") } if !mpHas(err.Error(), "too many redirects") { t.Fatalf("err = %s", err.Error()) } <-done } func TestFetchBodyErrorBranches(t *testing.T) { cases := []struct { name string resp []byte lim int64 want string }{ {"bad-status-code", []byte("HTTP/1.1 teapot\r\n\r\n"), 1024, "bad status code"}, {"bad-content-length", []byte("HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n"), 1024, "bad content-length"}, {"negative-content-length", []byte("HTTP/1.1 200 OK\r\nContent-Length: -5\r\n\r\n"), 1024, "bad content-length"}, {"length-over-limit", []byte("HTTP/1.1 200 OK\r\nContent-Length: 9999\r\n\r\n"), 10, "response too large"}, {"short-body", []byte("HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabc"), 1024, "read body"}, {"eof-over-limit", []byte("HTTP/1.1 200 OK\r\ny\r\n\r\n0123456789"), 4, "response too large"}, {"chunk-over-limit", []byte("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nff\r\n0123456789\r\n0\r\n\r\n"), 4, "response too large"}, {"chunk-bad-size", []byte("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\n\r\n"), 1024, "chunked size parse"}, {"chunk-short-body", []byte("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nff\r\nabc"), 1024, "chunked body"}, } for i := 0; i < len(cases); i++ { tc := cases[i] base, done := mpStart(t, [][]byte{tc.resp}) if done == nil { return } _, _, _, err := Fetch(base|"/probe", tc.lim) if err == nil { t.Fatalf("%s: expected an error", tc.name) } if !mpHas(err.Error(), tc.want) { t.Fatalf("%s: err = %s, want %s", tc.name, err.Error(), tc.want) } <-done } } func TestFetchHeadersWithoutColonAreSkipped(t *testing.T) { resp := []byte("HTTP/1.1 200 OK\r\nGarbage Header Line\r\nContent-Length: 2\r\n\r\nok") base, done := mpStart(t, [][]byte{resp}) if done == nil { return } status, _, body, err := Fetch(base|"/odd", 1024) if err != nil { t.Fatalf("fetch: %v", err) } if status != 200 || string(body) != "ok" { t.Fatalf("status = %d body = %s", status, body) } <-done } func TestFetchEmptyReplyFails(t *testing.T) { // The peer closes before the status line: the read error is reported. base, done := mpStart(t, [][]byte{[]byte(nil)}) if done == nil { return } _, _, _, err := Fetch(base|"/gone", 1024) if err == nil { t.Fatal("an empty reply must fail") } if !mpHas(err.Error(), "read status") { t.Fatalf("err = %s", err.Error()) } <-done }