internal/parser/http.go

// Package parser holds the very small amount of HTTP parsing we need.
// We don't want to bring in net/http because we only ever see partial
// buffers, mid-stream, often cut in awkward places.
//
// See mercemay.top/src/httptap/
package parser

import (
	"bytes"
	"strconv"
	"strings"
)

// Request captures the start-line of an HTTP/1.x request.
type Request struct {
	Method  string
	Target  string
	Version string
	Headers map[string]string
}

// Response captures the start-line of an HTTP/1.x response.
type Response struct {
	Version string
	Status  int
	Reason  string
	Headers map[string]string
	Chunked bool
}

// ParseRequest returns a Request if b starts with a plausible HTTP
// request line. It only looks at what it needs.
func ParseRequest(b []byte) (Request, bool) {
	line, rest, ok := splitLine(b)
	if !ok {
		return Request{}, false
	}
	parts := strings.SplitN(line, " ", 3)
	if len(parts) != 3 {
		return Request{}, false
	}
	if !isMethod(parts[0]) {
		return Request{}, false
	}
	if !strings.HasPrefix(parts[2], "HTTP/") {
		return Request{}, false
	}
	return Request{
		Method:  parts[0],
		Target:  parts[1],
		Version: parts[2],
		Headers: parseHeaders(rest),
	}, true
}

// ParseResponse returns a Response if b starts with a plausible HTTP
// status line.
func ParseResponse(b []byte) (Response, bool) {
	line, rest, ok := splitLine(b)
	if !ok {
		return Response{}, false
	}
	parts := strings.SplitN(line, " ", 3)
	if len(parts) < 2 || !strings.HasPrefix(parts[0], "HTTP/") {
		return Response{}, false
	}
	code, err := strconv.Atoi(parts[1])
	if err != nil || code < 100 || code > 599 {
		return Response{}, false
	}
	reason := ""
	if len(parts) == 3 {
		reason = parts[2]
	}
	hdr := parseHeaders(rest)
	return Response{
		Version: parts[0],
		Status:  code,
		Reason:  reason,
		Headers: hdr,
		Chunked: strings.EqualFold(hdr["Transfer-Encoding"], "chunked"),
	}, true
}

func splitLine(b []byte) (line string, rest []byte, ok bool) {
	i := bytes.Index(b, []byte("\r\n"))
	if i < 0 {
		return "", nil, false
	}
	return string(b[:i]), b[i+2:], true
}

func parseHeaders(b []byte) map[string]string {
	h := make(map[string]string, 8)
	for len(b) > 0 {
		line, rest, ok := splitLine(b)
		if !ok || line == "" {
			return h
		}
		b = rest
		idx := strings.IndexByte(line, ':')
		if idx < 0 {
			continue
		}
		name := strings.TrimSpace(line[:idx])
		value := strings.TrimSpace(line[idx+1:])
		// normalise to canonical casing
		h[canonicalise(name)] = value
	}
	return h
}

func canonicalise(s string) string {
	out := make([]byte, len(s))
	upper := true
	for i := 0; i < len(s); i++ {
		c := s[i]
		switch {
		case upper && c >= 'a' && c <= 'z':
			out[i] = c - 32
		case !upper && c >= 'A' && c <= 'Z':
			out[i] = c + 32
		default:
			out[i] = c
		}
		upper = c == '-'
	}
	return string(out)
}

func isMethod(s string) bool {
	switch s {
	case "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT", "TRACE":
		return true
	}
	return false
}