Resolve named placeholders from a private variable store, mask dates and ids after shape checks, and keep comparing independent steps. Co-authored-by: Cursor <cursoragent@cursor.com>
548 lines
13 KiB
Go
548 lines
13 KiB
Go
package tide
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
)
|
|
|
|
var (
|
|
placeholderRe = regexp.MustCompile(`\{\{([^{}]+)\}\}`)
|
|
jwtShapeRe = regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`)
|
|
invShapeRe = regexp.MustCompile(`inv_[A-Za-z0-9]{8,}`)
|
|
cookieRe = regexp.MustCompile(`(?i)auth_token=([^;]+)`)
|
|
secretFormRe = regexp.MustCompile(`(?i)client_secret=([^&\s]+)`)
|
|
pkceFormRe = regexp.MustCompile(`(?i)code_verifier=([^&\s]+)`)
|
|
)
|
|
|
|
// Store holds named capture values. When Path is set it is a mode-0600 private file.
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
path string
|
|
vals map[string]string
|
|
}
|
|
|
|
// OpenStore loads or creates a private variable map. Empty path is memory-only.
|
|
func OpenStore(path string) (*Store, error) {
|
|
s := &Store{path: path, vals: make(map[string]string)}
|
|
if strings.TrimSpace(path) == "" {
|
|
return s, nil
|
|
}
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tide: vars path: %w", err)
|
|
}
|
|
s.path = abs
|
|
st, err := os.Stat(abs)
|
|
if err == nil {
|
|
if st.IsDir() {
|
|
return nil, fmt.Errorf("tide: vars %q is a directory", path)
|
|
}
|
|
raw, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tide: read vars: %w", err)
|
|
}
|
|
if len(strings.TrimSpace(string(raw))) > 0 {
|
|
if err := yaml.Unmarshal(raw, &s.vals); err != nil {
|
|
return nil, fmt.Errorf("tide: parse vars: %w", err)
|
|
}
|
|
if s.vals == nil {
|
|
s.vals = make(map[string]string)
|
|
}
|
|
}
|
|
if err := os.Chmod(abs, 0o600); err != nil {
|
|
return nil, fmt.Errorf("tide: chmod vars: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
if !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("tide: stat vars: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
return nil, fmt.Errorf("tide: create vars dir: %w", err)
|
|
}
|
|
if err := os.WriteFile(abs, []byte("{}\n"), 0o600); err != nil {
|
|
return nil, fmt.Errorf("tide: create vars: %w", err)
|
|
}
|
|
_ = os.Chmod(abs, 0o600)
|
|
return s, nil
|
|
}
|
|
|
|
// Save writes the map as YAML with mode 0600. Memory-only stores are a no-op.
|
|
func (s *Store) Save() error {
|
|
if s == nil || s.path == "" {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
keys := make([]string, 0, len(s.vals))
|
|
for k := range s.vals {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
var b strings.Builder
|
|
if len(keys) == 0 {
|
|
b.WriteString("{}\n")
|
|
}
|
|
for _, k := range keys {
|
|
fmt.Fprintf(&b, "%s: %s\n", strconv.Quote(k), strconv.Quote(s.vals[k]))
|
|
}
|
|
raw := []byte(b.String())
|
|
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".vars-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("tide: vars temp: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
if _, err := tmp.Write(raw); err != nil {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
_ = tmp.Chmod(0o600)
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpName, s.path); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return err
|
|
}
|
|
return os.Chmod(s.path, 0o600)
|
|
}
|
|
|
|
// Get returns a stored value.
|
|
func (s *Store) Get(name string) (string, bool) {
|
|
if s == nil {
|
|
return "", false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
v, ok := s.vals[name]
|
|
return v, ok
|
|
}
|
|
|
|
// Set stores a named value.
|
|
func (s *Store) Set(name, value string) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.vals[name] = value
|
|
}
|
|
|
|
// Expand replaces {{name}} placeholders. Unresolved names fail before HTTP send.
|
|
func (s *Store) Expand(text string) (string, error) {
|
|
if !strings.Contains(text, "{{") {
|
|
return text, nil
|
|
}
|
|
if s == nil {
|
|
m := placeholderRe.FindStringSubmatch(text)
|
|
if len(m) > 1 {
|
|
return "", fmt.Errorf("tide: unresolved placeholder %q", m[1])
|
|
}
|
|
return "", fmt.Errorf("tide: unresolved placeholder")
|
|
}
|
|
var missing []string
|
|
s.mu.Lock()
|
|
out := placeholderRe.ReplaceAllStringFunc(text, func(m string) string {
|
|
name := m[2 : len(m)-2]
|
|
v, ok := s.vals[name]
|
|
if !ok {
|
|
missing = append(missing, name)
|
|
return m
|
|
}
|
|
return v
|
|
})
|
|
s.mu.Unlock()
|
|
if len(missing) > 0 {
|
|
return "", fmt.Errorf("tide: unresolved placeholder %q", missing[0])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func expandRequest(req Request, store *Store) (Request, error) {
|
|
out := req
|
|
var err error
|
|
out.Path, err = store.Expand(req.Path)
|
|
if err != nil {
|
|
return Request{}, err
|
|
}
|
|
out.Query, err = store.Expand(req.Query)
|
|
if err != nil {
|
|
return Request{}, err
|
|
}
|
|
if req.Headers != nil {
|
|
out.Headers = make(map[string]string, len(req.Headers))
|
|
for k, v := range req.Headers {
|
|
out.Headers[k], err = store.Expand(v)
|
|
if err != nil {
|
|
return Request{}, err
|
|
}
|
|
}
|
|
}
|
|
body, err := store.Expand(string(req.Body))
|
|
if err != nil {
|
|
return Request{}, err
|
|
}
|
|
out.Body = Body(body)
|
|
return out, nil
|
|
}
|
|
|
|
// CaptureStep writes named values from the step into the store.
|
|
func CaptureStep(store *Store, step *Step) error {
|
|
if store == nil || step == nil || len(step.Capture) == 0 {
|
|
return nil
|
|
}
|
|
for _, rule := range step.Capture {
|
|
val, err := extractCapture(rule, step.Request, step.Response)
|
|
if err != nil {
|
|
return fmt.Errorf("tide: capture %q: %w", rule.As, err)
|
|
}
|
|
if strings.TrimSpace(val) == "" {
|
|
return fmt.Errorf("tide: capture %q was empty", rule.As)
|
|
}
|
|
store.Set(rule.As, val)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func extractCapture(rule CaptureRule, req Request, resp Response) (string, error) {
|
|
from := strings.TrimSpace(rule.From)
|
|
if from == "" {
|
|
from = "response.json"
|
|
}
|
|
switch from {
|
|
case "response.json":
|
|
v, err := jsonPathValue([]byte(resp.Body), rule.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return scalarString(v), nil
|
|
case "response.header":
|
|
v := headerValue(resp.Headers, rule.Name)
|
|
if v == "" {
|
|
return "", fmt.Errorf("missing response header %s", rule.Name)
|
|
}
|
|
return v, nil
|
|
case "response.query":
|
|
loc := headerValue(resp.Headers, "Location")
|
|
if loc == "" {
|
|
return "", fmt.Errorf("missing Location header")
|
|
}
|
|
u, err := url.Parse(loc)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
v := u.Query().Get(rule.Name)
|
|
if v == "" {
|
|
return "", fmt.Errorf("missing response query %s", rule.Name)
|
|
}
|
|
return v, nil
|
|
case "response.location.query":
|
|
loc := headerValue(resp.Headers, "Location")
|
|
if loc == "" {
|
|
return "", fmt.Errorf("missing Location header")
|
|
}
|
|
u, err := url.Parse(loc)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
v := u.Query().Get(rule.Name)
|
|
if v == "" {
|
|
return "", fmt.Errorf("missing Location query %s", rule.Name)
|
|
}
|
|
return v, nil
|
|
case "request.form":
|
|
v, err := formValue(string(req.Body), rule.Name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if v == "" {
|
|
return "", fmt.Errorf("missing form field %s", rule.Name)
|
|
}
|
|
return v, nil
|
|
case "request.header":
|
|
v := headerValue(req.Headers, rule.Name)
|
|
if v == "" {
|
|
return "", fmt.Errorf("missing request header %s", rule.Name)
|
|
}
|
|
return v, nil
|
|
case "request.query":
|
|
q, err := url.ParseQuery(req.Query)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
v := q.Get(rule.Name)
|
|
if v == "" {
|
|
return "", fmt.Errorf("missing request query %s", rule.Name)
|
|
}
|
|
return v, nil
|
|
default:
|
|
return "", fmt.Errorf("unknown capture from %q", from)
|
|
}
|
|
}
|
|
|
|
func formValue(body, name string) (string, error) {
|
|
vals, err := url.ParseQuery(body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return vals.Get(name), nil
|
|
}
|
|
|
|
func jsonPathValue(raw []byte, path string) (any, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return nil, fmt.Errorf("json path is required")
|
|
}
|
|
root, err := decodeJSON(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v, err := walkJSONPath(root, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func walkJSONPath(root any, path string) (any, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "$" || path == "" {
|
|
return root, nil
|
|
}
|
|
if !strings.HasPrefix(path, "$") {
|
|
path = "$." + path
|
|
}
|
|
cur := root
|
|
rest := strings.TrimPrefix(path, "$")
|
|
for rest != "" {
|
|
switch {
|
|
case strings.HasPrefix(rest, "."):
|
|
rest = rest[1:]
|
|
name, next := splitPathSeg(rest)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("invalid json path %s", path)
|
|
}
|
|
obj, ok := cur.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s is not an object", path)
|
|
}
|
|
v, ok := obj[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing %s", "$."+name)
|
|
}
|
|
cur = v
|
|
rest = next
|
|
case strings.HasPrefix(rest, "["):
|
|
end := strings.IndexByte(rest, ']')
|
|
if end < 0 {
|
|
return nil, fmt.Errorf("invalid json path %s", path)
|
|
}
|
|
idx, err := atoi(rest[1:end])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
arr, ok := cur.([]any)
|
|
if !ok || idx < 0 || idx >= len(arr) {
|
|
return nil, fmt.Errorf("missing %s[%d]", path, idx)
|
|
}
|
|
cur = arr[idx]
|
|
rest = rest[end+1:]
|
|
default:
|
|
return nil, fmt.Errorf("invalid json path %s", path)
|
|
}
|
|
}
|
|
return cur, nil
|
|
}
|
|
|
|
func splitPathSeg(s string) (name, rest string) {
|
|
i := 0
|
|
for i < len(s) && s[i] != '.' && s[i] != '[' {
|
|
i++
|
|
}
|
|
return s[:i], s[i:]
|
|
}
|
|
|
|
func atoi(s string) (int, error) {
|
|
n := 0
|
|
if s == "" {
|
|
return 0, fmt.Errorf("empty index")
|
|
}
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return 0, fmt.Errorf("invalid index %q", s)
|
|
}
|
|
n = n*10 + int(c-'0')
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func scalarString(v any) string {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return t
|
|
case json.Number:
|
|
return string(t)
|
|
case bool:
|
|
return fmt.Sprintf("%v", t)
|
|
default:
|
|
return fmt.Sprintf("%v", t)
|
|
}
|
|
}
|
|
|
|
// ScrubStep replaces stored capture values with {{name}} in kept fields.
|
|
func ScrubStep(store *Store, step *Step) error {
|
|
if store == nil || step == nil {
|
|
return nil
|
|
}
|
|
pairs := store.replacements()
|
|
step.Request.Path = replaceAll(step.Request.Path, pairs)
|
|
step.Request.Query = replaceAll(step.Request.Query, pairs)
|
|
step.Request.Headers = scrubMap(step.Request.Headers, pairs)
|
|
step.Request.Body = Body(replaceAll(string(step.Request.Body), pairs))
|
|
step.Response.Headers = scrubMap(step.Response.Headers, pairs)
|
|
step.Response.Body = Body(replaceAll(string(step.Response.Body), pairs))
|
|
return rejectUnclassifiedCredentials(*step)
|
|
}
|
|
|
|
func (s *Store) replacements() [][2]string {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
keys := make([]string, 0, len(s.vals))
|
|
for k, v := range s.vals {
|
|
if v != "" {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool {
|
|
return len(s.vals[keys[i]]) > len(s.vals[keys[j]])
|
|
})
|
|
out := make([][2]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
out = append(out, [2]string{s.vals[k], "{{" + k + "}}"})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func scrubMap(in map[string]string, pairs [][2]string) map[string]string {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for k, v := range in {
|
|
out[k] = replaceAll(v, pairs)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func replaceAll(s string, pairs [][2]string) string {
|
|
for _, p := range pairs {
|
|
if p[0] == "" {
|
|
continue
|
|
}
|
|
s = strings.ReplaceAll(s, p[0], p[1])
|
|
}
|
|
return s
|
|
}
|
|
|
|
func rejectUnclassifiedCredentials(step Step) error {
|
|
check := func(label, s string) error {
|
|
if hit := remainingCredential(s); hit != "" {
|
|
return fmt.Errorf("unclassified credential-shaped value (%s) in %s step %s", hit, label, step.ID)
|
|
}
|
|
return nil
|
|
}
|
|
for k, v := range step.Request.Headers {
|
|
if err := check("request header "+k, v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := check("request query", step.Request.Query); err != nil {
|
|
return err
|
|
}
|
|
if err := check("request body", string(step.Request.Body)); err != nil {
|
|
return err
|
|
}
|
|
for k, v := range step.Response.Headers {
|
|
if err := check("response header "+k, v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return check("response body", string(step.Response.Body))
|
|
}
|
|
|
|
func remainingCredential(s string) string {
|
|
s = placeholderRe.ReplaceAllString(s, "")
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if jwtShapeRe.MatchString(s) {
|
|
return "jwt"
|
|
}
|
|
if invShapeRe.MatchString(s) {
|
|
return "token"
|
|
}
|
|
if cookieRe.MatchString(s) {
|
|
return "cookie"
|
|
}
|
|
if secretFormRe.MatchString(s) {
|
|
return "oauth_secret"
|
|
}
|
|
if pkceFormRe.MatchString(s) {
|
|
return "pkce"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func varsOutsideFixtures(varsPath, fixtures string) error {
|
|
if varsPath == "" || fixtures == "" {
|
|
return nil
|
|
}
|
|
absVars, err := filepath.Abs(varsPath)
|
|
if err != nil {
|
|
return fmt.Errorf("tide: vars path: %w", err)
|
|
}
|
|
absFix, err := filepath.Abs(fixtures)
|
|
if err != nil {
|
|
return fmt.Errorf("tide: fixtures path: %w", err)
|
|
}
|
|
if absVars == absFix || strings.HasPrefix(absVars, absFix+string(os.PathSeparator)) {
|
|
return fmt.Errorf("tide: vars file %q must be outside fixtures %q", varsPath, fixtures)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mergeRouteCaptures(step *Step, rules Rules) {
|
|
if step == nil || len(step.Capture) > 0 {
|
|
return
|
|
}
|
|
route := rules.Match(step.Request.Method, step.Request.Path)
|
|
if route != nil && len(route.Capture) > 0 {
|
|
step.Capture = append([]CaptureRule(nil), route.Capture...)
|
|
}
|
|
}
|
|
|
|
func recordedResponseHeaders(h http.Header, rules Rules, method, path string) map[string]string {
|
|
route := rules.Match(method, path)
|
|
if keep := rules.responseHeaders(route); len(keep) > 0 {
|
|
return filterHeaders(h, keep)
|
|
}
|
|
return keepResponseHeaders(h)
|
|
}
|