feat(02-02): capture named sessions through a loopback proxy
Record Nuxt/MCP traffic as ordered flows via parity:proxy, pin loopback upstream, and refuse oversized or credential-shaped fixtures. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
147
tide/rules.go
Normal file
147
tide/rules.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package tide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
const (
|
||||
ClientNuxt = "nuxt"
|
||||
ClientMCP = "mcp"
|
||||
|
||||
SessionHeader = "X-Parity-Session"
|
||||
)
|
||||
|
||||
// Rules is the committed, non-secret capture policy for a proxy session.
|
||||
type Rules struct {
|
||||
Client string `yaml:"client"`
|
||||
KeepRequestHeaders []string `yaml:"keep_request_headers"`
|
||||
KeepResponseHeaders []string `yaml:"keep_response_headers"`
|
||||
Routes []RouteRule `yaml:"routes"`
|
||||
}
|
||||
|
||||
// RouteRule matches a method and path pattern and names capture sources.
|
||||
type RouteRule struct {
|
||||
Method string `yaml:"method"`
|
||||
Path string `yaml:"path"`
|
||||
KeepRequestHeaders []string `yaml:"keep_request_headers,omitempty"`
|
||||
KeepResponseHeaders []string `yaml:"keep_response_headers,omitempty"`
|
||||
Capture []CaptureRule `yaml:"capture,omitempty"`
|
||||
}
|
||||
|
||||
// LoadRules reads a strict YAML rule file, rejecting unknown fields.
|
||||
func LoadRules(path string) (Rules, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Rules{}, fmt.Errorf("tide: read rules %s: %w", path, err)
|
||||
}
|
||||
return ParseRules(raw)
|
||||
}
|
||||
|
||||
// ParseRules decodes rules YAML with unknown-field rejection.
|
||||
func ParseRules(raw []byte) (Rules, error) {
|
||||
var rules Rules
|
||||
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
||||
if err := dec.Decode(&rules); err != nil {
|
||||
return Rules{}, fmt.Errorf("tide: parse rules: %w", err)
|
||||
}
|
||||
if err := validateRules(rules); err != nil {
|
||||
return Rules{}, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func validateRules(rules Rules) error {
|
||||
switch rules.Client {
|
||||
case ClientNuxt, ClientMCP:
|
||||
default:
|
||||
return fmt.Errorf("tide: rules client must be %q or %q", ClientNuxt, ClientMCP)
|
||||
}
|
||||
for i, route := range rules.Routes {
|
||||
if strings.TrimSpace(route.Method) == "" {
|
||||
return fmt.Errorf("tide: rules routes[%d] is missing method", i)
|
||||
}
|
||||
if strings.TrimSpace(route.Path) == "" {
|
||||
return fmt.Errorf("tide: rules routes[%d] is missing path", i)
|
||||
}
|
||||
for j, cap := range route.Capture {
|
||||
if strings.TrimSpace(cap.As) == "" {
|
||||
return fmt.Errorf("tide: rules routes[%d].capture[%d] is missing as", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match returns the first route rule for method and path, or nil.
|
||||
func (r Rules) Match(method, requestPath string) *RouteRule {
|
||||
for i := range r.Routes {
|
||||
route := &r.Routes[i]
|
||||
if !matchMethod(route.Method, method) {
|
||||
continue
|
||||
}
|
||||
if !matchPath(route.Path, requestPath) {
|
||||
continue
|
||||
}
|
||||
return route
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r Rules) requestHeaders(route *RouteRule) []string {
|
||||
if route != nil && len(route.KeepRequestHeaders) > 0 {
|
||||
return route.KeepRequestHeaders
|
||||
}
|
||||
return r.KeepRequestHeaders
|
||||
}
|
||||
|
||||
func (r Rules) responseHeaders(route *RouteRule) []string {
|
||||
if route != nil && len(route.KeepResponseHeaders) > 0 {
|
||||
return route.KeepResponseHeaders
|
||||
}
|
||||
return r.KeepResponseHeaders
|
||||
}
|
||||
|
||||
func matchMethod(pattern, method string) bool {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" || pattern == "*" {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(pattern, method)
|
||||
}
|
||||
|
||||
func matchPath(pattern, requestPath string) bool {
|
||||
if pattern == requestPath {
|
||||
return true
|
||||
}
|
||||
ok, err := path.Match(pattern, requestPath)
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
func filterHeaders(h http.Header, keep []string) map[string]string {
|
||||
if len(keep) == 0 || h == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string)
|
||||
for _, name := range keep {
|
||||
vals := h.Values(name)
|
||||
if len(vals) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(vals) == 1 {
|
||||
out[http.CanonicalHeaderKey(name)] = vals[0]
|
||||
continue
|
||||
}
|
||||
out[http.CanonicalHeaderKey(name)] = strings.Join(vals, "\n")
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user