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 }