Files
summercms/tide/variables.go
Jakub Zych aa165fe3d0 feat(02-02): capture, scrub, and strictly diff stateful flows
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>
2026-09-17 12:32:49 +02:00

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)
}