// Package mediaproxy tests the pure decision surface of the relay's media // proxy: HTTP status-line parsing, redirect resolution, EOF/chunked body // reading under a byte limit, and the input-rejection branches of Fetch and // fetchOnce. // // No test dials out. Every fetchOnce case here is rejected at url.Parse or at // the scheme switch, both of which run before net.DialTimeout, and the body // readers are driven from bytes.Reader. The one peer is the scripted loopback // origin in mediaproxy_http_test.mx, which is a local socket, not a dial out. package mediaproxy import ( "bufio" "bytes" "io" "testing" "time" ) // mpFailReader is an io.Reader whose only read fails with a non-EOF error, so // readToEOF's error return is exercised without a network peer. type mpFailReader struct{} func (r *mpFailReader) Read(b []byte) (n int32, err error) { return 0, io.ErrUnexpectedEOF } func mpBytes(n int32, fill byte) (b []byte) { b = []byte{:n} for i := 0; i < n; i++ { b[i] = fill } return } // mpHas is a local substring scan. bytes.Contains/bytes.Index can miss a // needle in a long haystack under this toolchain, and the point of the check // is the error mapping, so the search is done by hand. func mpHas(hay, needle string) (ok bool) { if len(needle) == 0 { return true } for i := 0; i+len(needle) <= len(hay); i++ { match := true for j := 0; j < len(needle); j++ { if hay[i+j] != needle[j] { match = false } } if match { return true } } return } // mpCRLF appends CRLF to every part. The exact escape literals are miscompiled // by `moxie test`, so the bytes are built from explicit values. func mpCRLF(parts [][]byte) (out []byte) { crlf := []byte{13, 10} for i := 0; i < len(parts); i++ { out = out | parts[i] | crlf } return } // mpChunkedReader wraps an assembled chunked body in the same reader kind // fetchOnce builds. func mpChunkedReader(parts [][]byte) (r *bufio.Reader) { return bufio.NewReaderSize(bytes.NewReader(mpCRLF(parts)), 32768) } func TestConstants(t *testing.T) { if DefaultMaxBytes != 32*1024*1024 { t.Fatal("DefaultMaxBytes changed") } if maxRedirects != 5 { t.Fatal("maxRedirects changed") } if DefaultTimeout != 8*time.Second { t.Fatal("DefaultTimeout changed") } } func TestParseStatus(t *testing.T) { var err error var got int32 cases := []struct { line string want int32 }{ {"HTTP/1.1 200 OK", 200}, {"HTTP/1.1 204 No Content", 204}, {"HTTP/1.0 301 Moved Permanently", 301}, {"HTTP/1.1 404 Not Found", 404}, {"HTTP/1.1 500", 500}, {" HTTP/1.1 302 Found ", 302}, } for i := 0; i < len(cases); i++ { got, err = parseStatus(cases[i].line) if err != nil { t.Fatalf("parseStatus(%q): %s", cases[i].line, err.Error()) } if got != cases[i].want { t.Fatalf("parseStatus(%q) = %d, want %d", cases[i].line, got, cases[i].want) } } // A real status line carries CRLF; TrimSpace must absorb it. line := []byte("HTTP/1.1 304 Not Modified") | []byte{13, 10} got, err = parseStatus(string(line)) if err != nil { t.Fatalf("parseStatus(CRLF line): %s", err.Error()) } if got != 304 { t.Fatalf("parseStatus(CRLF line) = %d, want 304", got) } } func TestParseStatusRejects(t *testing.T) { bad := []string{"", "HTTP/1.1", "HTTP/1.1 ", "HTTP/1.1 abc", "not a status"} for i := 0; i < len(bad); i++ { if _, err := parseStatus(bad[i]); err == nil { t.Fatalf("parseStatus(%q) must fail", bad[i]) } } } func TestResolveRedirectAbsolute(t *testing.T) { // Absolute URLs are returned unchanged. got, err := resolveRedirect("http://a/b/c", "http://x/y") if err != nil || got != "http://x/y" { t.Fatalf("absolute http redirect = %q err=%v", got, err != nil) } got2, err2 := resolveRedirect("http://a/b/c", "https://x/y") if err2 != nil || got2 != "https://x/y" { t.Fatalf("absolute https redirect = %q err=%v", got2, err2 != nil) } // A network-path reference inherits the base scheme but not its host/port. got3, err3 := resolveRedirect("https://a:8443/b/c", "//x/y") if err3 != nil || got3 != "https://x/y" { t.Fatalf("network-path redirect = %q err=%v", got3, err3 != nil) } // An absolute path keeps scheme and authority. got4, err4 := resolveRedirect("http://a:8080/b/c", "/z") if err4 != nil || got4 != "http://a:8080/z" { t.Fatalf("absolute-path redirect = %q err=%v", got4, err4 != nil) } } func TestResolveRedirectRelative(t *testing.T) { // Relative references replace the last path segment. got, err := resolveRedirect("http://a/b/c.png", "d.png") if err != nil || got != "http://a/b/d.png" { t.Fatalf("relative redirect = %q err=%v", got, err != nil) } // A trailing-slash base keeps the directory. got2, err2 := resolveRedirect("http://a/b/", "d.png") if err2 != nil || got2 != "http://a/b/d.png" { t.Fatalf("directory redirect = %q err=%v", got2, err2 != nil) } // A base with an empty path resolves from the root. got3, err3 := resolveRedirect("http://a", "d.png") if err3 != nil || got3 != "http://a/d.png" { t.Fatalf("rootless redirect = %q err=%v", got3, err3 != nil) } } func TestResolveRedirectRejects(t *testing.T) { if _, err := resolveRedirect("http://a/b", ""); err == nil { t.Fatal("an empty location must fail") } if _, err := resolveRedirect("://bad", "d.png"); err == nil { t.Fatal("an unparseable base must fail") } } func TestReadToEOF(t *testing.T) { b, err := readToEOF(bytes.NewReader([]byte("abc")), 16) if err != nil { t.Fatalf("readToEOF(abc): %s", err.Error()) } if string(b) != "abc" { t.Fatalf("readToEOF(abc) = %q", b) } empty, eerr := readToEOF(bytes.NewReader([]byte{}), 16) if eerr != nil { t.Fatalf("readToEOF(empty): %s", eerr.Error()) } if len(empty) != 0 { t.Fatalf("readToEOF(empty) length = %d", int32(len(empty))) } // A body exactly at the limit is accepted. exact, xerr := readToEOF(bytes.NewReader([]byte("abcde")), 5) if xerr != nil { t.Fatalf("readToEOF(exact limit): %s", xerr.Error()) } if string(exact) != "abcde" { t.Fatalf("readToEOF(exact limit) = %q", exact) } // One byte over the limit is rejected. if _, oerr := readToEOF(bytes.NewReader([]byte("abcdef")), 5); oerr == nil { t.Fatal("readToEOF must reject a body over the limit") } // A non-EOF read error propagates instead of looking like end of body. if _, ferr := readToEOF(&mpFailReader{}, 16); ferr == nil { t.Fatal("readToEOF must propagate a read error") } } func TestReadChunkedZeroAndTrailers(t *testing.T) { // A zero-size chunk terminates the body. z, err := readChunked(mpChunkedReader([][]byte{[]byte("0"), []byte("")}), 1024) if err != nil { t.Fatalf("readChunked(zero): %s", err.Error()) } if len(z) != 0 { t.Fatalf("zero-chunk body length = %d", int32(len(z))) } // Trailers after the terminating chunk are discarded. tr, terr := readChunked(mpChunkedReader([][]byte{[]byte("0"), []byte("X-Trailer: y"), []byte("")}), 1024) if terr != nil { t.Fatalf("readChunked(trailers): %s", terr.Error()) } if len(tr) != 0 { t.Fatalf("trailer-terminated body length = %d", int32(len(tr))) } } func TestReadChunkedSizeLineHandling(t *testing.T) { // A chunk extension (';') is stripped before the size is parsed. r := mpChunkedReader([][]byte{[]byte("3;a=b"), []byte("abc"), []byte("0"), []byte("")}) ext, err := readChunked(r, 1024) if err != nil { t.Fatalf("readChunked(extension): %s", err.Error()) } if string(ext) != "abc" { t.Fatalf("readChunked(extension) = %q, want %q", ext, "abc") } // A hex size line selects the chunk length. r2 := mpChunkedReader([][]byte{[]byte("5"), []byte("hello"), []byte("0"), []byte("")}) one, err2 := readChunked(r2, 1024) if err2 != nil { t.Fatalf("readChunked(non-zero size): %s", err2.Error()) } if string(one) != "hello" { t.Fatalf("readChunked(non-zero size) = %q, want %q", one, "hello") } // Several chunks concatenate, and a size line wider than one digit still // parses as hex ("a" = 10). r3 := mpChunkedReader([][]byte{[]byte("1"), []byte("a"), []byte("1"), []byte("b"), []byte("a"), []byte("cccccccccc"), []byte("0"), []byte("")}) many, err3 := readChunked(r3, 1024) if err3 != nil { t.Fatalf("readChunked(multi): %s", err3.Error()) } if string(many) != "abcccccccccc" { t.Fatalf("readChunked(multi) = %q, want %q", many, "abcccccccccc") } // A body over the limit is rejected, not truncated. r4 := mpChunkedReader([][]byte{[]byte("5"), []byte("hello"), []byte("0"), []byte("")}) if _, err4 := readChunked(r4, 4); err4 == nil { t.Fatal("readChunked must reject a body over the limit") } // A size line that is not hex is an error, not a terminator. r5 := mpChunkedReader([][]byte{[]byte("zz"), []byte("")}) if _, err5 := readChunked(r5, 1024); err5 == nil { t.Fatal("readChunked must reject a non-hex size line") } // A chunk body shorter than the announced size is an error. r6 := mpChunkedReader([][]byte{[]byte("7"), []byte("abc")}) if _, err6 := readChunked(r6, 1024); err6 == nil { t.Fatal("readChunked must reject a truncated chunk body") } } func TestReadChunkedRejectsMissingSize(t *testing.T) { // EOF before any size line is an I/O error, not a zero-length body. r := bufio.NewReaderSize(bytes.NewReader([]byte{}), 32768) if _, err := readChunked(r, 1024); err == nil { t.Fatal("readChunked must fail on a missing size line") } } func TestFetchOnceRejectsBadInput(t *testing.T) { // A disallowed scheme is rejected before any DNS or dial. _, _, _, err := fetchOnce("ftp://example.com/x", 1024) if err == nil { t.Fatal("fetchOnce must reject a non-http(s) scheme") } if !mpHas(err.Error(), "not allowed") { t.Fatalf("scheme rejection must name the reason: %s", err.Error()) } // url.Parse failures are rejected before the scheme switch. bad := []string{"://bad", "http://host:bad/x", "http://[::1"} for i := 0; i < len(bad); i++ { if _, _, _, err2 := fetchOnce(bad[i], 1024); err2 == nil { t.Fatalf("fetchOnce(%q) must reject an unparseable URL", bad[i]) } } } func TestFetchRejectsBadInput(t *testing.T) { // maxBytes <= 0 falls back to DefaultMaxBytes, then the scheme is checked. if _, _, _, err := Fetch("ftp://example.com/x", 0); err == nil { t.Fatal("Fetch must reject a non-http(s) scheme") } if _, _, _, err2 := Fetch("ftp://example.com/x", -1); err2 == nil { t.Fatal("Fetch must reject a non-http(s) scheme under a negative limit") } // Parse failures also return before any connection attempt. if _, _, _, err3 := Fetch("http://host:bad/x", 1024); err3 == nil { t.Fatal("Fetch must reject an unparseable URL before dialing") } if _, _, _, err4 := Fetch("://bad", 1024); err4 == nil { t.Fatal("Fetch must reject an unparseable URL") } }