Files
summercms/tide/variables.go
Jakub Zych 591a0d4681 fix(02-03): expand expected placeholders after replay capture
Unquoted numeric id placeholders made recorded JSON illegal to parse.
Replay now recaptures, persists vars, expands the expected body, and
diffs against the live response.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 12:51:24 +02:00

604 lines
14 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
}
func expandResponse(resp Response, store *Store) (Response, error) {
out := resp
var err error
if resp.Headers != nil {
out.Headers = make(map[string]string, len(resp.Headers))
for k, v := range resp.Headers {
out.Headers[k], err = store.Expand(v)
if err != nil {
return Response{}, err
}
}
}
body, err := store.Expand(string(resp.Body))
if err != nil {
return Response{}, 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
}
if len(p[0]) >= 8 {
s = strings.ReplaceAll(s, p[0], p[1])
continue
}
s = replaceIsolated(s, p[0], p[1])
}
return s
}
func replaceIsolated(s, old, neu string) string {
if old == "" || s == "" {
return s
}
var b strings.Builder
i := 0
for i < len(s) {
j := strings.Index(s[i:], old)
if j < 0 {
b.WriteString(s[i:])
break
}
j += i
leftOK := j == 0 || !isIdentByte(s[j-1])
right := j + len(old)
rightOK := right == len(s) || !isIdentByte(s[right])
if leftOK && rightOK {
b.WriteString(s[i:j])
b.WriteString(neu)
i = right
continue
}
b.WriteString(s[i : j+len(old)])
i = j + len(old)
}
return b.String()
}
func isIdentByte(c byte) bool {
return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'
}
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)
}