package tide import ( "bytes" "crypto/sha256" "encoding/hex" "fmt" "mime" "mime/multipart" "net/textproto" "os" "path" "path/filepath" "regexp" "strings" ) // MultipartBoundary is the fixed boundary every multipart request body is // encoded with, so a recording against one backend and a replay against // another send byte-identical bodies. const MultipartBoundary = "SummerTideMultipartBoundary7MA4YWxkTrZu0gW" // Part is one field of a multipart/form-data request. A text field sets // Value; a file field sets File, a path relative to the fixture directory // (for example files/cover.png), whose bytes are never inlined into the // YAML, with SHA256 pinning its content. Filename defaults to the base name // of File and ContentType to application/octet-stream. type Part struct { Name string `yaml:"name"` Value string `yaml:"value,omitempty"` File string `yaml:"file,omitempty"` Filename string `yaml:"filename,omitempty"` ContentType string `yaml:"content_type,omitempty"` SHA256 string `yaml:"sha256,omitempty"` } var sha256Hex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) // validateParts checks the shape of a request's parts without reading files. func validateParts(req Request) error { if len(req.Parts) == 0 { return nil } if req.Body != "" { return fmt.Errorf("request has both body and parts") } for i, p := range req.Parts { if strings.TrimSpace(p.Name) == "" { return fmt.Errorf("parts[%d] is missing name", i) } if p.File == "" { if p.Filename != "" || p.ContentType != "" || p.SHA256 != "" { return fmt.Errorf("part %q has file attributes but no file", p.Name) } continue } if p.Value != "" { return fmt.Errorf("part %q has both value and file", p.Name) } if err := validateSidecar(p.File); err != nil { return fmt.Errorf("part %q: %w", p.Name, err) } if !sha256Hex.MatchString(p.SHA256) { return fmt.Errorf("part %q needs the sha256 of its file", p.Name) } } return nil } // verifyPartFiles checks that every part file of flow exists under base and // matches its sha256. func verifyPartFiles(base string, flow Flow) error { for _, step := range flow.Steps { for _, p := range step.Request.Parts { if p.File == "" { continue } if _, err := readPartFile(base, p); err != nil { return fmt.Errorf("tide: step %s: %w", step.ID, err) } } } return nil } func readPartFile(base string, p Part) ([]byte, error) { if err := validateSidecar(p.File); err != nil { return nil, fmt.Errorf("part %q: %w", p.Name, err) } full := p.File if base != "" { full = filepath.Join(base, p.File) } resolved, err := resolvePath(full) if err != nil { return nil, fmt.Errorf("part %q file %s: %w", p.Name, p.File, err) } if base != "" { root, err := resolvePath(base) if err != nil { return nil, fmt.Errorf("fixture dir: %w", err) } if resolved != root && !strings.HasPrefix(resolved, root+string(os.PathSeparator)) { return nil, fmt.Errorf("part %q file %q escapes the fixture directory", p.Name, p.File) } } raw, err := os.ReadFile(resolved) if err != nil { return nil, fmt.Errorf("part %q file %s: %w", p.Name, p.File, err) } sum := sha256.Sum256(raw) if !strings.EqualFold(hex.EncodeToString(sum[:]), p.SHA256) { return nil, fmt.Errorf("part %q file %s sha256 mismatch", p.Name, p.File) } return raw, nil } var quoteEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`) // encodeParts writes parts in declaration order as a multipart/form-data // body with MultipartBoundary and returns the body and its Content-Type. func encodeParts(base string, parts []Part) ([]byte, string, error) { var buf bytes.Buffer w := multipart.NewWriter(&buf) if err := w.SetBoundary(MultipartBoundary); err != nil { return nil, "", err } marker := []byte(MultipartBoundary) for _, p := range parts { h := make(textproto.MIMEHeader) var content []byte if p.File == "" { h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"`, quoteEscaper.Replace(p.Name))) content = []byte(p.Value) } else { raw, err := readPartFile(base, p) if err != nil { return nil, "", err } filename := p.Filename if filename == "" { filename = path.Base(filepath.ToSlash(p.File)) } ct := p.ContentType if ct == "" { ct = "application/octet-stream" } h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, quoteEscaper.Replace(p.Name), quoteEscaper.Replace(filename))) h.Set("Content-Type", ct) content = raw } if bytes.Contains(content, marker) { return nil, "", fmt.Errorf("part %q contains the multipart boundary", p.Name) } pw, err := w.CreatePart(h) if err != nil { return nil, "", err } if _, err := pw.Write(content); err != nil { return nil, "", err } } if err := w.Close(); err != nil { return nil, "", err } return buf.Bytes(), w.FormDataContentType(), nil } // prepareRequest encodes a request's parts into its body. The multipart // Content-Type replaces a recorded multipart Content-Type (whose boundary // would be stale) and is added when none is recorded; a recorded non- // multipart Content-Type is kept as it is. func prepareRequest(req Request, base string) (Request, error) { if len(req.Parts) == 0 { return req, nil } if err := validateParts(req); err != nil { return Request{}, err } body, ct, err := encodeParts(base, req.Parts) if err != nil { return Request{}, err } out := req out.Body = Body(body) out.Headers = make(map[string]string, len(req.Headers)+1) set := false for k, v := range req.Headers { if strings.EqualFold(k, "Content-Type") { set = true if media, _, err := mime.ParseMediaType(v); err != nil || strings.HasPrefix(media, "multipart/") { v = ct } } out.Headers[k] = v } if !set { out.Headers["Content-Type"] = ct } return out, nil }