Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions internal/httputil/body.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Package httputil provides shared HTTP utility functions.
package httputil

import (
"bytes"
"io"
)

// ReconstitutedBody returns a new ReadCloser that first replays the captured
// bytes, then continues reading from the remaining original body. Close
// delegates to the original body's Close method.
func ReconstitutedBody(captured []byte, orig io.ReadCloser) io.ReadCloser {
return struct {
io.Reader
io.Closer
}{
Reader: io.MultiReader(bytes.NewReader(captured), orig),
Closer: orig,
}
}
42 changes: 42 additions & 0 deletions internal/httputil/body_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package httputil_test

import (
"io"
"strings"
"testing"

"github.com/yesdevnull/trenchcoat/internal/httputil"
)

func TestReconstitutedBody(t *testing.T) {
original := "hello world"
captured := []byte("hello ")
remaining := io.NopCloser(strings.NewReader("world"))

body := httputil.ReconstitutedBody(captured, remaining)

got, err := io.ReadAll(body)
if err != nil {
t.Fatal(err)
}
if string(got) != original {
t.Errorf("got %q, want %q", string(got), original)
}

if err := body.Close(); err != nil {
t.Errorf("unexpected close error: %v", err)
}
}

func TestReconstitutedBodyEmpty(t *testing.T) {
remaining := io.NopCloser(strings.NewReader("full body"))
body := httputil.ReconstitutedBody(nil, remaining)

got, err := io.ReadAll(body)
if err != nil {
t.Fatal(err)
}
if string(got) != "full body" {
t.Errorf("got %q, want %q", string(got), "full body")
}
}
73 changes: 43 additions & 30 deletions internal/matcher/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
package matcher

import (
"bytes"
"fmt"
"io"
"net/http"
Expand All @@ -16,6 +15,7 @@ import (

"github.com/bmatcuk/doublestar/v4"
"github.com/yesdevnull/trenchcoat/internal/coat"
"github.com/yesdevnull/trenchcoat/internal/httputil"
)

// maxBodyMatchSize is the maximum request body size (in bytes) that the matcher
Expand All @@ -36,7 +36,8 @@ const (
// entry is a compiled coat with pre-computed matching metadata.
type entry struct {
coat coat.Coat
index int // original definition order
index int // original definition order
filePath string // source file path, empty for programmatic coats
uriType uriMatchType
regex *regexp.Regexp // only for regex URIs
bodyRegex *regexp.Regexp // only for body_match: regex
Expand All @@ -54,8 +55,9 @@ type entry struct {
type MatchResult struct {
Name string
Coat coat.Coat
ResponseIdx int // index into Responses for sequence coats, -1 for singular
Exhausted bool // true if sequence is exhausted (once mode)
FilePath string // source file path (empty for programmatic coats)
ResponseIdx int // index into Responses for sequence coats, -1 for singular
Exhausted bool // true if sequence is exhausted (once mode)
}

// Matcher matches HTTP requests to coat definitions.
Expand Down Expand Up @@ -142,17 +144,35 @@ func New(coats []coat.Coat) *Matcher {
return &Matcher{entries: entries}
}

// NewWithPaths creates a Matcher from coats with associated file paths.
// The paths slice must be the same length as coats (use "" for programmatic coats).
// Panics if len(paths) != len(coats).
func NewWithPaths(coats []coat.Coat, paths []string) *Matcher {
if len(paths) != len(coats) {
panic(fmt.Sprintf("matcher.NewWithPaths: len(paths)=%d != len(coats)=%d", len(paths), len(coats)))
}
m := New(coats)
for i := range m.entries {
m.entries[i].filePath = paths[i]
}
return m
}

// errBodyTooLarge is returned by the body reader when the request body exceeds
// maxBodyMatchSize.
var errBodyTooLarge = fmt.Errorf("request body exceeds %d bytes", maxBodyMatchSize)

// lazyBodyReader creates a function that lazily reads the request body on first
// call, bounded to maxBodyMatchSize. The request body is reconstituted so
// downstream handlers still see the full body.
func lazyBodyReader(req *http.Request) func() (string, bool) {
func lazyBodyReader(req *http.Request) func() (string, error) {
var reqBodyStr string
var bodyRead bool
var bodyReadErr bool
var readErr error

return func() (string, bool) {
return func() (string, error) {
if bodyRead {
return reqBodyStr, bodyReadErr
return reqBodyStr, readErr
}
bodyRead = true
if req.Body != nil {
Expand All @@ -162,34 +182,26 @@ func lazyBodyReader(req *http.Request) func() (string, bool) {
limited := io.LimitReader(origBody, maxBodyMatchSize+1)
allRead, err := io.ReadAll(limited)
if err != nil {
bodyReadErr = true
readErr = fmt.Errorf("reading request body: %w", err)
req.Body = httputil.ReconstitutedBody(allRead, origBody)
return reqBodyStr, readErr
}

// If we read more than maxBodyMatchSize bytes, treat it as too large
// for body matching, but still restore the full body for downstream use.
var reqBody []byte
if len(allRead) > maxBodyMatchSize {
bodyReadErr = true
reqBody = allRead[:maxBodyMatchSize]
readErr = errBodyTooLarge
reqBodyStr = string(allRead[:maxBodyMatchSize])
} else {
reqBody = allRead
reqBodyStr = string(allRead)
}

// Convert to string once to avoid repeated allocations in matchesBody.
reqBodyStr = string(reqBody)

// Reconstitute req.Body as the bytes already read plus the remaining
// unread original body so downstream handlers see the full body, and
// ensure Close() still delegates to the original body's Close().
req.Body = struct {
io.Reader
io.Closer
}{
Reader: io.MultiReader(bytes.NewReader(allRead), origBody),
Closer: origBody,
}
req.Body = httputil.ReconstitutedBody(allRead, origBody)
}
return reqBodyStr, bodyReadErr
return reqBodyStr, readErr
}
}

Expand Down Expand Up @@ -227,7 +239,7 @@ type candidate struct {
}

// findCandidates evaluates all entries against the request and returns matching candidates.
func (m *Matcher) findCandidates(req *http.Request, getBody func() (string, bool)) []candidate {
func (m *Matcher) findCandidates(req *http.Request, getBody func() (string, error)) []candidate {
var candidates []candidate
for _, e := range m.entries {
if !matchesMethod(e, req.Method) {
Expand Down Expand Up @@ -262,8 +274,9 @@ func selectBest(candidates []candidate) *MatchResult {

best := candidates[0].entry
result := &MatchResult{
Name: best.resolvedName(),
Coat: best.coat,
Name: best.resolvedName(),
Coat: best.coat,
FilePath: best.filePath,
}

idx, exhausted := resolveSequence(best)
Expand Down Expand Up @@ -540,12 +553,12 @@ func matchesQuery(e *entry, rawQuery string, queryValues map[string][]string) bo
return true
}

func matchesBody(e *entry, getBody func() (string, bool)) bool {
func matchesBody(e *entry, getBody func() (string, error)) bool {
if e.coat.Request.Body == nil {
return true // No body constraint — matches anything.
}
body, readErr := getBody()
if readErr {
body, err := getBody()
if err != nil {
return false // Treat read errors as non-match.
}
switch e.coat.Request.BodyMatch {
Expand Down
57 changes: 57 additions & 0 deletions internal/matcher/matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package matcher_test
import (
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -1044,6 +1046,37 @@ func TestMatch_BodyMatch_ExplicitExact(t *testing.T) {
}
}

// --- Body I/O error handling ---

type errorReader struct {
err error
}

func (r *errorReader) Read(p []byte) (int, error) {
return 0, r.err
}

func (r *errorReader) Close() error {
return nil
}

func TestMatchBodyIOError(t *testing.T) {
coats := []coat.Coat{
{
Name: "body-match",
Request: coat.Request{URI: "/test", Body: coat.StringPtr("expected")},
Response: &coat.Response{Code: 200},
},
}
m := matcher.New(coats)

req := httptest.NewRequest("GET", "/test", &errorReader{err: fmt.Errorf("disk failure")})
result := m.Match(req)
if result != nil {
t.Error("expected no match when body read fails, but got a match")
}
}

// --- Body size limit ---

func TestMatch_BodyExceedsMaxSize_NoMatch(t *testing.T) {
Expand Down Expand Up @@ -1277,6 +1310,30 @@ func TestMatchVerbose_MatchReturnsNoMismatches(t *testing.T) {
}
}

// --- FilePath propagation ---

func TestMatchResultFilePath(t *testing.T) {
coats := []coat.Coat{
{
Name: "test",
Request: coat.Request{URI: "/test"},
Response: &coat.Response{Code: 200},
},
}
wantPath := filepath.Join(t.TempDir(), "test.yaml")
paths := []string{wantPath}
m := matcher.NewWithPaths(coats, paths)

req := httptest.NewRequest("GET", "/test", nil)
result := m.Match(req)
if result == nil {
t.Fatal("expected match")
}
if result.FilePath != wantPath {
t.Errorf("got FilePath %q, want %q", result.FilePath, wantPath)
}
}

// --- Helpers ---

func newRequest(t *testing.T, method, uri string, headers map[string]string) *http.Request {
Expand Down
Loading
Loading