package tide import ( "crypto/sha256" "encoding/hex" "fmt" "net/http" "os" "path/filepath" "strings" ) const CurrentVersion = 1 // DefaultMaxBody is the default cap on recorded or replayed HTTP bodies. const DefaultMaxBody = 8 << 20 // Flow is a versioned ordered list of HTTP steps. type Flow struct { Version int `yaml:"version"` Name string `yaml:"name"` Description string `yaml:"description,omitempty"` SeedHook string `yaml:"seed_hook,omitempty"` Steps []Step `yaml:"steps"` } // Step is one request/response pair in a flow. type Step struct { ID string `yaml:"id"` RouteID string `yaml:"route_id,omitempty"` Request Request `yaml:"request"` Response Response `yaml:"response"` Capture []CaptureRule `yaml:"capture,omitempty"` Normalize []NormalizeRule `yaml:"normalize,omitempty"` Headers map[string]string `yaml:"headers,omitempty"` } // Request is the outbound HTTP call for a step. type Request struct { Method string `yaml:"method"` Path string `yaml:"path"` Query string `yaml:"query,omitempty"` Headers map[string]string `yaml:"headers,omitempty"` Body Body `yaml:"body,omitempty"` // Parts is a multipart/form-data body, encoded with MultipartBoundary // when the step runs. A request has Body or Parts, never both. Parts []Part `yaml:"parts,omitempty"` } // Response is the recorded or expected HTTP reply. type Response struct { Status int `yaml:"status,omitempty"` Headers map[string]string `yaml:"headers,omitempty"` Body Body `yaml:"body,omitempty"` BodyFile string `yaml:"body_file,omitempty"` SHA256 string `yaml:"sha256,omitempty"` } // CaptureRule maps a named source onto a variable name. type CaptureRule struct { From string `yaml:"from,omitempty"` Path string `yaml:"path,omitempty"` Name string `yaml:"name,omitempty"` As string `yaml:"as"` Identity string `yaml:"identity,omitempty"` Category string `yaml:"category,omitempty"` } // NormalizeRule names a per-step normalizer override. type NormalizeRule struct { Path string `yaml:"path,omitempty"` Disable bool `yaml:"disable,omitempty"` } // Body is verbatim request or response bytes stored as a YAML literal scalar. type Body string // RecordConfig injects the HTTP target, client and body bound for recording. // BaseDir is the fixture directory that request part files are read from. type RecordConfig struct { Target string Client *http.Client MaxBody int64 Store *Store Rules Rules BaseDir string } // ReplayConfig injects the HTTP target, client and body bound for replay. // BaseDir is the fixture directory that body_file sidecars and request part // files are read from. // UploadPrefix is the public URL prefix of uploaded files whose url and // thumb_url values are compared by shape; empty means DefaultUploadPrefix. type ReplayConfig struct { Target string Client *http.Client MaxBody int64 Store *Store BaseDir string UploadPrefix string } // Result is the outcome of replaying a flow. type Result struct { OK bool Steps []StepResult } // StepResult is the outcome of one replayed step. type StepResult struct { ID string OK bool Skipped bool Diffs []Diff } // Diff is one structural JSON or raw-byte mismatch. type Diff struct { Path string Expected string Actual string Offset int Byte bool } // MismatchError is returned when replay finds one or more differences. type MismatchError struct { Result Result } func (e *MismatchError) Error() string { if e == nil { return "tide: mismatch" } var b strings.Builder for _, step := range e.Result.Steps { for _, d := range step.Diffs { if b.Len() > 0 { b.WriteByte('\n') } if d.Byte { fmt.Fprintf(&b, "step %s: body mismatch at byte %d: expected %s actual %s", step.ID, d.Offset, redactSecrets(d.Expected), redactSecrets(d.Actual)) continue } fmt.Fprintf(&b, "step %s: %s: expected %s actual %s", step.ID, d.Path, redactSecrets(d.Expected), redactSecrets(d.Actual)) } } if b.Len() == 0 { return "tide: mismatch" } return b.String() } func validateFlow(flow Flow) error { if flow.Version != CurrentVersion { return fmt.Errorf("tide: unsupported version %d (want %d)", flow.Version, CurrentVersion) } if strings.TrimSpace(flow.Name) == "" { return fmt.Errorf("tide: flow name is required") } if len(flow.Steps) == 0 { return fmt.Errorf("tide: flow %q has no steps", flow.Name) } seen := make(map[string]struct{}, len(flow.Steps)) for i, step := range flow.Steps { if strings.TrimSpace(step.ID) == "" { return fmt.Errorf("tide: steps[%d] is missing id", i) } if _, dup := seen[step.ID]; dup { return fmt.Errorf("tide: duplicate step id %q", step.ID) } seen[step.ID] = struct{}{} if strings.TrimSpace(step.Request.Method) == "" { return fmt.Errorf("tide: step %s is missing request method", step.ID) } if strings.TrimSpace(step.Request.Path) == "" { return fmt.Errorf("tide: step %s is missing request path", step.ID) } if err := validateSidecar(step.Response.BodyFile); err != nil { return fmt.Errorf("tide: step %s: %w", step.ID, err) } if err := validateParts(step.Request); err != nil { return fmt.Errorf("tide: step %s: %w", step.ID, err) } } return nil } func materializeSidecar(base string, resp *Response) error { if resp == nil || resp.BodyFile == "" { return nil } if err := validateSidecar(resp.BodyFile); err != nil { return err } path := resp.BodyFile if base != "" { path = filepath.Join(base, resp.BodyFile) } resolved, err := resolvePath(path) if err != nil { return fmt.Errorf("tide: read body_file %s: %w", resp.BodyFile, err) } if base != "" { root, err := resolvePath(base) if err != nil { return fmt.Errorf("tide: fixture dir: %w", err) } if resolved != root && !strings.HasPrefix(resolved, root+string(os.PathSeparator)) { return fmt.Errorf("tide: body_file %q escapes the fixture directory", resp.BodyFile) } } raw, err := os.ReadFile(resolved) if err != nil { return fmt.Errorf("tide: read body_file %s: %w", resp.BodyFile, err) } sum := sha256.Sum256(raw) got := hex.EncodeToString(sum[:]) if resp.SHA256 != "" && !strings.EqualFold(got, resp.SHA256) { return fmt.Errorf("tide: body_file %s digest mismatch", resp.BodyFile) } resp.Body = Body(raw) return nil } func validateSidecar(path string) error { if path == "" { return nil } if filepath.IsAbs(path) { return fmt.Errorf("body_file %q must be a relative path", path) } clean := filepath.ToSlash(filepath.Clean(path)) if clean == ".." || strings.HasPrefix(clean, "../") { return fmt.Errorf("body_file %q escapes the fixture directory", path) } if _, err := resolvePath(path); err != nil { return fmt.Errorf("body_file %q: %w", path, err) } return nil } func maxBody(n int64) int64 { if n <= 0 { return DefaultMaxBody } return n }