diff --git a/.gitignore b/.gitignore index 321751d..29d9708 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ vendor/ *.dylib *.hex *.wasm - +/http-linux # Profiling *.pprof diff --git a/examples/http-linux/conn-linux.go b/examples/http-linux/conn-linux.go new file mode 100644 index 0000000..9da6143 --- /dev/null +++ b/examples/http-linux/conn-linux.go @@ -0,0 +1,96 @@ +//go:build !tinygo && linux + +package main + +import ( + "net/netip" + "syscall" +) + +// Conn wraps an accepted TCP connection from a raw Linux socket file descriptor. +// It implements io.Reader/io.Writer/io.Closer over syscall.Read/Write/Close. +type Conn struct { + fd int + remote netip.AddrPort +} + +// Read reads bytes from the connection into b. +func (c *Conn) Read(b []byte) (int, error) { + if len(b) == 0 { + return 0, nil + } + n, err := syscall.Read(c.fd, b) + if err != nil { + return 0, err + } + if n == 0 { + return 0, syscall.ECONNRESET // Peer closed. + } + return n, nil +} + +// Write writes b to the connection, looping until all bytes are sent. +func (c *Conn) Write(b []byte) (int, error) { + total := 0 + for total < len(b) { + n, err := syscall.Write(c.fd, b[total:]) + if err != nil { + return total, err + } + total += n + } + return total, nil +} + +// Close closes the underlying file descriptor. +func (c *Conn) Close() error { + return syscall.Close(c.fd) +} + +// RemoteAddr returns the peer address of the connection. +func (c *Conn) RemoteAddr() netip.AddrPort { return c.remote } + +// Listener wraps a listening TCP socket bound to a local port. +type Listener struct { + fd int +} + +// Listen creates a listening TCP socket bound to port on all interfaces. +func Listen(port uint16) (*Listener, error) { + fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, syscall.IPPROTO_TCP) + if err != nil { + return nil, err + } + // Allow quick rebind after restart. + if err = syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); err != nil { + syscall.Close(fd) + return nil, err + } + addr := &syscall.SockaddrInet4{Port: int(port)} + if err = syscall.Bind(fd, addr); err != nil { + syscall.Close(fd) + return nil, err + } + if err = syscall.Listen(fd, syscall.SOMAXCONN); err != nil { + syscall.Close(fd) + return nil, err + } + return &Listener{fd: fd}, nil +} + +// Accept blocks until an incoming connection arrives and returns it as a Conn. +func (l *Listener) Accept(conn *Conn) error { + nfd, sa, err := syscall.Accept(l.fd) + if err != nil { + return err + } + + conn.fd = nfd + if sa4, ok := sa.(*syscall.SockaddrInet4); ok { + conn.remote = netip.AddrPortFrom(netip.AddrFrom4(sa4.Addr), uint16(sa4.Port)) + } + return nil +} + +// Close closes the listening socket. +func (l *Listener) Close() error { return syscall.Close(l.fd) } diff --git a/examples/http-linux/main-httplinux.go b/examples/http-linux/main-httplinux.go new file mode 100644 index 0000000..b243fea --- /dev/null +++ b/examples/http-linux/main-httplinux.go @@ -0,0 +1,107 @@ +//go:build !tinygo && linux + +package main + +import ( + "os" + "strconv" + "sync/atomic" + "time" + + "github.com/soypat/lneto/http/httpraw" +) + +const listenPort = 8080 + +func main() { + if err := run(); err != nil { + println("Error: ", err) + os.Exit(1) + } + println("DONE") +} + +func run() error { + ln, err := Listen(listenPort) + if err != nil { + return err + } + defer ln.Close() + println("listening on port", listenPort) + conn := new(Conn) + for { + err := ln.Accept(conn) + if err != nil { + return err + } + visits.Add(1) + if err := handle(conn); err != nil { + println("handle:", conn.RemoteAddr().String(), err.Error()) + } + conn.Close() + } +} + +const ( + htmlHead = `
` + + `` + + `*** WELCOME TO MY HOMEPAGE ***` + + `

YOU ARE VISITOR #` + htmlTail = `!

` + + `Sign my guestbook!` + + `

Best viewed in Netscape Navigator
` +) + +const maxHTTPHeader = 1024 + +var ( + hdr httpraw.Header + httpbuf [maxHTTPHeader]byte + htmlbuf [512]byte + visits atomic.Uint64 +) + +func handle(conn *Conn) error { + hdr.Reset(httpbuf[:0]) + hdr.EnableBufferGrowth(false) // Limit memory to buffer capacity. + const incomingIsResponse = false // We get HTTP requests from clients. + deadline := time.Now().Add(50 * time.Millisecond) + for time.Until(deadline) > 0 { + if _, err := hdr.ReadFromLimited(conn, maxHTTPHeader); err != nil { + return err + } + needmoredata, err := hdr.TryParse(incomingIsResponse) + if err != nil { + return err + } + if needmoredata { + continue + } + break + } + println("\n\n================\n\n", hdr.String()) + if time.Since(deadline) > 0 { + print("DEADLINE EXCEED: ", hdr.BufferParsed(), "/", hdr.BufferReceived(), " bytes parsed/read\n") + return nil + } + // Prepare tacky HTML response. + n := copy(htmlbuf[:], htmlHead) + n += len(strconv.AppendUint(htmlbuf[n:n], visits.Load(), 10)) + n += copy(htmlbuf[n:], htmlTail) + contentLen := n + hdr.Reset(httpbuf[:0]) + hdr.SetProtocol("HTTP/1.1") + hdr.SetStatus("200", "OK") + hdr.Set("Content-Type", "text/html") + hdr.SetInt("Content-Length", int64(contentLen), 10) + // Here we do some buffer juggling. We use remaining space + // of HTTP Header buffer to write the response that will be written over the wire. + respbuf := httpbuf[hdr.BufferUsed():] + header, err := hdr.AppendResponse(respbuf[:0]) + if err != nil { + return err + } + conn.Write(header) + _, err = conn.Write(htmlbuf[:contentLen]) + return err +} diff --git a/http/httpraw/header.go b/http/httpraw/header.go index ada9046..d92ba68 100644 --- a/http/httpraw/header.go +++ b/http/httpraw/header.go @@ -164,6 +164,7 @@ func (h *Header) ReadFromBytes(b []byte) (int, error) { } // BufferReceived returns the amoung of bytes read during calls to Read* methods. +// Returns 0 if buffer is invalid/mangled. func (h *Header) BufferReceived() int { if h.flags.hasAny(flagMangledBuffer | flagOOMReached) { return 0 @@ -181,12 +182,23 @@ func (h *Header) BufferParsed() int { return h.hbuf.off } +// BufferUsed returns the raw memory used. +// +// BufferUsed + BufferFree == BufferCapacity +func (h *Header) BufferUsed() int { + return len(h.hbuf.buf) +} + // BufferFree returns amount of bytes free in underlying buffer. +// +// BufferUsed + BufferFree == BufferCapacity func (h *Header) BufferFree() int { return h.hbuf.free() } // BufferCapacity returns the total capacity of the underlying buffer. +// +// BufferUsed + BufferFree == BufferCapacity func (h *Header) BufferCapacity() int { return cap(h.hbuf.buf) } @@ -222,7 +234,7 @@ func (hb *headerBuf) forEach(cb func(key, value []byte) error) error { // h.Reset(httpHeader); h.Parse() // Parse bytes in place with no copying. // h.Reset(nil) // Reuse buffer previously set in a call to Reset. func (h *Header) Reset(buf []byte) { - if h.flags.hasAny(flagNoBufferGrow) && len(buf) < 32 { + if h.flags.hasAny(flagNoBufferGrow) && cap(buf) < 32 { panic("small buffer and flagNoBufferGrow set") } const persistentFlags = flagNoBufferGrow @@ -252,9 +264,36 @@ func (h *Header) SetBytes(key string, value []byte) { h.Set(key, b2s(value)) } +// SetInt is equivalent to [Header.Set] but with an integer value i.e: Content-Length header key. +// base must be in the range 2..36 (as accepted by [strconv.AppendInt]); other bases are dropped. +// SetInt formats the value directly into the header buffer without heap allocation. +func (h *Header) SetInt(key string, value int64, base int) { + if base < 2 || base > 36 { + return // strconv.AppendInt only supports base 2..36. + } + useKv := h.takeReusableSlot(key) + if useKv == nil { + h.appendHeaderInt(key, value, base) + } else { + useKv.value = h.reuseOrAppendInt(useKv.value, value, base) + } +} + // Set sets a key-value pair in the HTTP header. // Calling Set mangles the buffer. func (h *Header) Set(key, value string) { + useKv := h.takeReusableSlot(key) + if useKv == nil { + h.appendHeader(key, value) + } else { + useKv.value = h.reuseOrAppend(useKv.value, value) + } +} + +// takeReusableSlot returns the valid key-value entry for key with the largest +// value buffer (best candidate for in-place reuse) and invalidates any other +// entries sharing the key. Returns nil if the key is not present. +func (h *Header) takeReusableSlot(key string) *argsKV { hb := &h.hbuf var useKv *argsKV for i := 0; i < len(hb.headers); i++ { @@ -271,11 +310,7 @@ func (h *Header) Set(key, value string) { } } } - if useKv == nil { - h.appendHeader(key, value) - } else { - useKv.value = h.reuseOrAppend(useKv.value, value) - } + return useKv } // Get gets the first value of a key found in the headers. Use [Header.ForEach] to find multiple values corresponding to same key. diff --git a/http/httpraw/header_test.go b/http/httpraw/header_test.go index 4228225..2d94955 100644 --- a/http/httpraw/header_test.go +++ b/http/httpraw/header_test.go @@ -240,3 +240,186 @@ func TestHeaderSetBytesEmptyValue(t *testing.T) { t.Errorf("want empty value, got %q", got) } } + +// buffer/offset past uint16 range silently corrupts or panics. +// A header block > 64KiB pushes a field's offset past 65535. tokint truncation +// makes Get return the wrong window (silent corruption) or panic on slice bounds. +func TestHeader_LargeBufferOverflow(t *testing.T) { + const wantVal = "the-canary-value" + // Pad with a big header so the canary field lands past offset 65535. + pad := strings.Repeat("x", 70000) + raw := "GET / HTTP/1.1\r\n" + + "X-Pad: " + pad + "\r\n" + + "X-Canary: " + wantVal + "\r\n" + + "\r\n" + + var h Header + err := h.ParseBytes(false, []byte(raw)) + if err != nil { + // Clean rejection of the oversized header is the intended behavior: + // no panic, no silent corruption. + return + } + // If it did parse, the value must be correct (never a truncated-offset window). + got := string(h.Get("X-Canary")) + if got != wantVal { + t.Fatalf("overflow corruption: want X-Canary %q, got %q", wantVal, got) + } +} + +// a complete but malformed header line with no colon must be a hard error, +// not errNeedMore (which makes a streaming parser wait forever). +func TestHeader_ColonlessLineIsHardError(t *testing.T) { + raw := "GET / HTTP/1.1\r\nBadHeaderNoColon\r\n\r\n" + var h Header + err := h.ParseBytes(false, []byte(raw)) + if err == nil { + t.Fatal("want error on colonless header line, got nil") + } + if err == errNeedMore { + t.Fatalf("colonless line reported as errNeedMore (parser would hang); want a hard error like errInvalidName") + } +} + +// Regression for a TCP split landing BEFORE the colon (no newline yet) must +// stay "need more data" and parse once the rest arrives. This is the case the +// Colonless fix must NOT turn into a hard error. +func TestHeader_SplitBeforeColonStillParses(t *testing.T) { + const part1 = "GET / HTTP/1.1\r\nHost" // split mid-key, before colon+newline + const part2 = ": example.com\r\n\r\n" + + var h Header + h.Reset(nil) + if _, err := h.ReadFromBytes([]byte(part1)); err != nil { + t.Fatal(err) + } + needMore, err := h.TryParse(false) + if err != nil && err != errNeedMore { + t.Fatalf("split before colon: want errNeedMore/nil, got %v", err) + } + if !needMore { + t.Fatal("want needMoreData=true after partial input") + } + + if _, err := h.ReadFromBytes([]byte(part2)); err != nil { + t.Fatal(err) + } + needMore, err = h.TryParse(false) + if err != nil { + t.Fatalf("after full input: %v", err) + } + if needMore { + t.Fatal("want needMoreData=false after full input") + } + if got := string(h.Get("Host")); got != "example.com" { + t.Fatalf("want Host %q, got %q", "example.com", got) + } +} + +// appendHeader must reserve the +1 byte that mustAppendSlice consumes on an +// empty buffer. Force cap == len(key)+len(value) to defeat allocator rounding. +func TestHeader_AppendHeaderExactCapNoPanic(t *testing.T) { + const key, value = "K", "V" + buf := make([]byte, 0, len(key)+len(value)) // exact cap, no slack. + var h Header + h.Reset(buf) + defer func() { + if r := recover(); r != nil { + t.Fatalf("appendHeader panicked on exact-cap buffer: %v", r) + } + }() + h.Add(key, value) + if got := string(h.Get(key)); got != value { + t.Fatalf("want %q, got %q", value, got) + } +} + +// Add/Set on a full buffer with growth disabled must drop gracefully (flag OOM), +// never panic. Panicking is unacceptable for this package. +func TestHeader_AddFullBufferNoPanic(t *testing.T) { + buf := make([]byte, 0, 40) // Small cap; enough for Reset (len 0) but not the field below. + var h Header + h.Reset(buf) + h.EnableBufferGrowth(false) + h.SetMethod("GET") + h.SetRequestURI("/") + h.SetProtocol("HTTP/1.1") + + defer func() { + if r := recover(); r != nil { + t.Fatalf("Add on full no-grow buffer panicked: %v", r) + } + }() + h.Add("X-Very-Long-Header-Key", "a-value-that-cannot-possibly-fit-in-the-buffer") + + // Graceful drop: request marshalling reports OOM instead of emitting bad data. + if _, err := h.AppendRequest(nil); err == nil { + t.Fatal("want OOM error after dropped Add, got nil") + } +} + +func TestHeader_SetInt(t *testing.T) { + for _, tc := range []struct { + name string + value int64 + base int + want string + }{ + {"decimal", 1234, 10, "1234"}, + {"zero", 0, 10, "0"}, + {"negative", -42, 10, "-42"}, + {"maxint64", 9223372036854775807, 10, "9223372036854775807"}, + {"minint64", -9223372036854775808, 10, "-9223372036854775808"}, + {"hex", 255, 16, "ff"}, + } { + t.Run(tc.name, func(t *testing.T) { + var h Header + h.Reset(nil) + h.SetInt("Content-Length", tc.value, tc.base) + if got := string(h.Get("Content-Length")); got != tc.want { + t.Fatalf("want %q, got %q", tc.want, got) + } + }) + } +} + +// SetInt on an existing key must reuse the slot in place (single field, latest value). +func TestHeader_SetIntOverwrite(t *testing.T) { + var h Header + h.Reset(nil) + h.SetMethod("GET") + h.SetRequestURI("/") + h.SetProtocol("HTTP/1.1") + + h.SetInt("Content-Length", 100, 10) + h.SetInt("Content-Length", 5, 10) // shorter, must fit in old slot. + + if got := string(h.Get("Content-Length")); got != "5" { + t.Fatalf("want Content-Length %q, got %q", "5", got) + } + req, err := h.AppendRequest(nil) + if err != nil { + t.Fatal(err) + } + if n := strings.Count(string(req), "Content-Length:"); n != 1 { + t.Errorf("want 1 Content-Length field, got %d:\n%s", n, req) + } +} + +// SetInt must not heap-allocate: it must format directly into the header buffer. +func TestHeader_SetIntNoAlloc(t *testing.T) { + buf := make([]byte, 0, 256) + var h Header + h.Reset(buf) + h.EnableBufferGrowth(false) + h.Add("Content-Length", "0000000000000000000000") // pre-size a reusable slot. + allocs := testing.AllocsPerRun(100, func() { + h.SetInt("Content-Length", 1234567890, 10) + }) + if allocs != 0 { + t.Fatalf("SetInt allocated %v times, want 0", allocs) + } + if got := string(h.Get("Content-Length")); got != "1234567890" { + t.Fatalf("want %q, got %q", "1234567890", got) + } +} diff --git a/http/httpraw/parse.go b/http/httpraw/parse.go index 0b0bd26..82f4027 100644 --- a/http/httpraw/parse.go +++ b/http/httpraw/parse.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "slices" + "strconv" "unsafe" "github.com/soypat/lneto/internal" @@ -27,8 +28,14 @@ var ( errNeedMethodURI = errors.New("need method/request URI to create request header") errBadStatusCodeTxt = errors.New("invalid status code or text") errCookiesParsed = errors.New("cookies already parsed, reset before parsing again") + errBufferTooLarge = errors.New("httpraw: buffer exceeds max size (offsets are uint16)") ) +// maxBufLen bounds the header buffer. Offsets/lengths are stored as uint16 +// (tokint); a buffer past this would truncate/overflow those, silently +// returning the wrong bytes or panicking on a wrapped slice bound. +const maxBufLen = 0xffff + type headerBuf struct { // buf[:len] holds entire HTTP header data, which may be normalized by [flags]. buf[off:len] holds data not yet processed during parsing. buf []byte @@ -92,6 +99,9 @@ func (h *Header) parse(asResponse bool) (err error) { } func (h *Header) parseFirstLine(asResponse bool) (err error) { + if len(h.hbuf.buf) > maxBufLen { + return errBufferTooLarge // Offsets would overflow uint16 tokint. + } if asResponse { h.statusCode, h.statusText, h.flags, err = h.hbuf.parseFirstLineResponse(h.flags) } else { @@ -302,32 +312,22 @@ func (h *Header) reuseOrAppend(tok headerSlice, value string) headerSlice { func (h *Header) appendSlice(value string) headerSlice { debuglog("http:appendslice:start") - free := h.hbuf.free() - if len(value) > free { - if h.flags.hasAny(flagNoBufferGrow) { - h.flags |= flagOOMReached - return headerSlice{} - } - debuglog("http:appendslice:grow-buf") - h.hbuf.buf = slices.Grow(h.hbuf.buf, len(value)+1) // Grow 1 beyond due to slice validity. + if !h.reserve(len(value)) { + return headerSlice{} } h.flags |= flagMangledBuffer return h.hbuf.mustAppendSlice(value) } func (h *Header) appendHeader(key, value string) { - hb := &h.hbuf - free := hb.free() - buf := h.hbuf.buf - - if len(key)+len(value) > free { - if h.flags.hasAny(flagNoBufferGrow) { - panic(errSmallBuffer) - } - debuglog("http:appendhdr:grow-buf") - hb.buf = slices.Grow(buf, len(key)+len(value)) + // reserve accounts for the byte-0 reservation mustAppendSlice makes on an + // empty buffer, and drops (flagging OOM) rather than panicking when growth + // is disabled and space runs out. + if !h.reserve(len(key) + len(value)) { + return } h.flags |= flagMangledBuffer + hb := &h.hbuf k := hb.mustAppendSlice(key) v := hb.mustAppendSlice(value) debuglog("http:appendhdr:grow-hdrs") @@ -337,6 +337,100 @@ func (h *Header) appendHeader(key, value string) { }) } +// appendHeaderInt is appendHeader's integer counterpart: it appends key and the +// formatted integer value as a new header field. +func (h *Header) appendHeaderInt(key string, value int64, base int) { + n := intLen(value, base) + if !h.reserve(len(key) + n) { + return // Drop and flag OOM; never panic. + } + h.flags |= flagMangledBuffer + hb := &h.hbuf + k := hb.mustAppendSlice(key) + v := hb.mustAppendInt(value, base) + hb.headers = append(hb.headers, argsKV{ + key: k, + value: v, + }) +} + +// reserve ensures need free bytes are available in the buffer, growing it when +// permitted. It accounts for the byte-0 reservation on an empty buffer (see +// mustAppendSlice). It returns false and sets flagOOMReached when the space +// cannot be guaranteed: a tokint offset overflow, or a full buffer with +// flagNoBufferGrow set. +func (h *Header) reserve(need int) bool { + hb := &h.hbuf + if len(hb.buf) == 0 { + need++ // mustAppend* reserves byte 0 on an empty buffer. + } + if len(hb.buf)+need > maxBufLen { + h.flags |= flagOOMReached // Offsets would overflow uint16 tokint. + return false + } + if need > hb.free() { + if h.flags.hasAny(flagNoBufferGrow) { + h.flags |= flagOOMReached + return false + } + hb.buf = slices.Grow(hb.buf, need) + } + return true +} + +// reuseOrAppendInt writes value into tok's slot in place when it fits, avoiding +// any buffer growth; otherwise it appends a fresh slot. +func (h *Header) reuseOrAppendInt(tok headerSlice, value int64, base int) headerSlice { + n := intLen(value, base) + if int(tok.len) >= n { + // Reuse: format directly over the existing slot. No free space needed + // since n <= tok.len and the slot already lives inside buf. + v := strconv.AppendInt(h.hbuf.buf[tok.start:tok.start], value, base) + tok.len = tokint(len(v)) + h.flags |= flagMangledBuffer + return tok + } + return h.appendInt(value, base, n) +} + +// appendInt reserves space (growing or flagging OOM) and appends value as a new slot. +func (h *Header) appendInt(value int64, base, n int) headerSlice { + if !h.reserve(n) { + return headerSlice{} // Drop and flag OOM; never panic. + } + h.flags |= flagMangledBuffer + return h.hbuf.mustAppendInt(value, base) +} + +// mustAppendInt formats value into the buffer's free region and commits it. +// The caller must have reserved at least intLen(value, base) free bytes. +func (hb *headerBuf) mustAppendInt(value int64, base int) headerSlice { + L := len(hb.buf) + if L == 0 { + L++ // Valid key-values start after byte 0. + } + v := strconv.AppendInt(hb.buf[L:L], value, base) + hb.buf = hb.buf[:L+len(v)] + return hb.slice(hb.buf[L : L+len(v)]) +} + +// intLen returns the number of bytes strconv.AppendInt would emit for value in +// the given base (including a leading minus sign for negatives). Used to size +// the buffer and to test whether a value fits an existing slot without writing. +func intLen(value int64, base int) int { + n := 1 + u := uint64(value) + if value < 0 { + n++ // Leading minus sign. + u = -u // Two's-complement magnitude; correct even for math.MinInt64. + } + for u >= uint64(base) { + u /= uint64(base) + n++ + } + return n +} + func (hb *headerBuf) noKV() argsKV { return argsKV{} } func (hb *headerBuf) next(ss *scannerState) argsKV { @@ -373,8 +467,11 @@ func (hb *headerBuf) next(ss *scannerState) argsKV { ss.err = errInvalidName return hb.noKV() } else if n < 0 { - // No colon found, probably missing data. - ss.err = errNeedMore + // A newline is present (x>=0 reached here) but the line has no + // colon: malformed, not incomplete. A split arriving before the + // colon has no newline yet and is caught by the x<0 branch above, + // so it still returns errNeedMore. + ss.err = errInvalidName return hb.noKV() } }