diff --git a/.golangci.yaml b/.golangci.yaml index 030740a..aafe3e0 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -29,7 +29,6 @@ linters: - errorlint - exptostd - fatcontext - - forcetypeassert - funcorder - gocheckcompilerdirectives - gocritic diff --git a/docs/examples.md b/docs/examples.md index 717ad5a..060adf5 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -9,6 +9,7 @@ This guide provides practical examples for common Sarin use cases. - [Headers, Cookies, and Parameters](#headers-cookies-and-parameters) - [Dynamic Requests with Templating](#dynamic-requests-with-templating) - [Request Bodies](#request-bodies) +- [File Uploads](#file-uploads) - [Using Proxies](#using-proxies) - [Output Formats](#output-formats) - [Docker Usage](#docker-usage) @@ -456,7 +457,7 @@ body: | ```sh sarin -U http://example.com/api/upload -r 1000 -c 10 \ -M POST \ - -B '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}' + -B '{{ body_FormData "username" "john" "email" "john@example.com" }}' ```
@@ -467,7 +468,7 @@ url: http://example.com/api/upload requests: 1000 concurrency: 10 method: POST -body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}' +body: '{{ body_FormData "username" "john" "email" "john@example.com" }}' ```
@@ -477,7 +478,7 @@ body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") ```sh sarin -U http://example.com/api/users -r 1000 -c 10 \ -M POST \ - -B '{{ body_FormData (dict_Str "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone)) }}' + -B '{{ body_FormData "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone) }}' ```
@@ -488,13 +489,160 @@ url: http://example.com/api/users requests: 1000 concurrency: 10 method: POST -body: '{{ body_FormData (dict_Str "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone)) }}' +body: '{{ body_FormData "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone) }}' ```
> **Note:** `body_FormData` automatically sets the `Content-Type` header to `multipart/form-data` with the appropriate boundary. +## File Uploads + +**File upload with multipart form data:** + +Upload a local file: + +```sh +sarin -U http://example.com/api/upload -r 100 -c 10 \ + -M POST \ + -B '{{ body_FormData "title" "My Document" "document" "@/path/to/file.pdf" }}' +``` + +
+YAML equivalent + +```yaml +url: http://example.com/api/upload +requests: 100 +concurrency: 10 +method: POST +body: '{{ body_FormData "title" "My Document" "document" "@/path/to/file.pdf" }}' +``` + +
+ +**Multiple file uploads (same field name):** + +```sh +sarin -U http://example.com/api/upload -r 100 -c 10 \ + -M POST \ + -B '{{ body_FormData "files" "@/path/to/file1.pdf" "files" "@/path/to/file2.pdf" }}' +``` + +
+YAML equivalent + +```yaml +url: http://example.com/api/upload +requests: 100 +concurrency: 10 +method: POST +body: | + {{ body_FormData + "files" "@/path/to/file1.pdf" + "files" "@/path/to/file2.pdf" + }} +``` + +
+ +**Multiple file uploads (different field names):** + +```sh +sarin -U http://example.com/api/upload -r 100 -c 10 \ + -M POST \ + -B '{{ body_FormData "avatar" "@/path/to/photo.jpg" "resume" "@/path/to/cv.pdf" "cover_letter" "@/path/to/letter.docx" }}' +``` + +
+YAML equivalent + +```yaml +url: http://example.com/api/upload +requests: 100 +concurrency: 10 +method: POST +body: | + {{ body_FormData + "avatar" "@/path/to/photo.jpg" + "resume" "@/path/to/cv.pdf" + "cover_letter" "@/path/to/letter.docx" + }} +``` + +
+ +**File from URL:** + +```sh +sarin -U http://example.com/api/upload -r 100 -c 10 \ + -M POST \ + -B '{{ body_FormData "image" "@https://example.com/photo.jpg" }}' +``` + +
+YAML equivalent + +```yaml +url: http://example.com/api/upload +requests: 100 +concurrency: 10 +method: POST +body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}' +``` + +
+ +> **Note:** Files (local and remote) are cached in memory after the first read, so they are not re-read for every request. + +**Base64 encoded file in JSON body (local file):** + +```sh +sarin -U http://example.com/api/upload -r 100 -c 10 \ + -M POST \ + -H "Content-Type: application/json" \ + -B '{"file": "{{ file_Base64 "/path/to/file.pdf" }}", "filename": "document.pdf"}' +``` + +
+YAML equivalent + +```yaml +url: http://example.com/api/upload +requests: 100 +concurrency: 10 +method: POST +headers: + Content-Type: application/json +body: '{"file": "{{ file_Base64 "/path/to/file.pdf" }}", "filename": "document.pdf"}' +``` + +
+ +**Base64 encoded file in JSON body (remote URL):** + +```sh +sarin -U http://example.com/api/upload -r 100 -c 10 \ + -M POST \ + -H "Content-Type: application/json" \ + -B '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}", "filename": "photo.jpg"}' +``` + +
+YAML equivalent + +```yaml +url: http://example.com/api/upload +requests: 100 +concurrency: 10 +method: POST +headers: + Content-Type: application/json +body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}", "filename": "photo.jpg"}' +``` + +
+ ## Using Proxies **Single HTTP proxy:** diff --git a/docs/templating.md b/docs/templating.md index 6a35faf..4ff3e49 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -11,6 +11,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook - [String Functions](#string-functions) - [Collection Functions](#collection-functions) - [Body Functions](#body-functions) + - [File Functions](#file-functions) - [Fake Data Functions](#fake-data-functions) - [File](#file) - [ID](#id) @@ -110,9 +111,63 @@ sarin -U http://example.com/users \ ### Body Functions -| Function | Description | Example | -| ----------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------- | -| `body_FormData(fields map[string]string)` | Create multipart form data. Automatically sets the `Content-Type` header | `{{ body_FormData (dict_Str "field1" "value1") }}` | +| Function | Description | Example | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `body_FormData(pairs ...string)` | Create multipart form data from key-value pairs. Automatically sets the `Content-Type` header. Values starting with `@` are treated as file references (local path or URL). Use `@@` to escape literal `@`. | `{{ body_FormData "field1" "value1" "file" "@/path/to/file.pdf" }}` | + +**`body_FormData` Details:** + +```yaml +# Text fields only +body: '{{ body_FormData "username" "john" "email" "john@example.com" }}' + +# Single file upload +body: '{{ body_FormData "document" "@/path/to/file.pdf" }}' + +# File from URL +body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}' + +# Mixed text fields and files +body: | + {{ body_FormData + "title" "My Report" + "author" "John Doe" + "cover" "@/path/to/cover.jpg" + "document" "@/path/to/report.pdf" + }} + +# Multiple files with same field name +body: | + {{ body_FormData + "files" "@/path/to/file1.pdf" + "files" "@/path/to/file2.pdf" + }} + +# Escape @ for literal value (sends "@username") +body: '{{ body_FormData "twitter" "@@username" }}' +``` + +> **Note:** Files are cached in memory after the first read. Subsequent requests reuse the cached content, avoiding repeated disk/network I/O. + +### File Functions + +| Function | Description | Example | +| ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| `file_Base64(source string)` | Read a file (local path or URL) and return its Base64 encoded content. Files are cached after first read. | `{{ file_Base64 "/path/to/file.pdf" }}` | + +**`file_Base64` Details:** + +```yaml +# Local file as Base64 in JSON body +body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "document.pdf"}' + +# Remote file as Base64 +body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}' + +# Combined with values for reuse +values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}" +body: '{"data": "{{ .Values.FILE_DATA }}"}' +``` ## Fake Data Functions diff --git a/internal/config/template_validator.go b/internal/config/template_validator.go index 6618209..054ba29 100644 --- a/internal/config/template_validator.go +++ b/internal/config/template_validator.go @@ -191,11 +191,12 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F func ValidateTemplates(config *Config) []types.FieldValidationError { // Create template function map using the same functions as sarin package + // Use nil for fileCache during validation - templates are only parsed, not executed randSource := sarin.NewDefaultRandSource() - funcMap := sarin.NewDefaultTemplateFuncMap(randSource) + funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil) bodyFuncMapData := &sarin.BodyTemplateFuncMapData{} - bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData) + bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData, nil) var allErrors []types.FieldValidationError diff --git a/internal/sarin/filecache.go b/internal/sarin/filecache.go new file mode 100644 index 0000000..b3a24fd --- /dev/null +++ b/internal/sarin/filecache.go @@ -0,0 +1,102 @@ +package sarin + +import ( + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "sync" + "time" +) + +// CachedFile holds the cached content and metadata of a file. +type CachedFile struct { + Content []byte + Filename string +} + +type FileCache struct { + cache sync.Map // map[string]*CachedFile + requestTimeout time.Duration +} + +func NewFileCache(requestTimeout time.Duration) *FileCache { + return &FileCache{ + requestTimeout: requestTimeout, + } +} + +// GetOrLoad retrieves a file from cache or loads it using the provided source. +// The source can be a local file path or an HTTP/HTTPS URL. +func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) { + if val, ok := fc.cache.Load(source); ok { + return val.(*CachedFile), nil + } + + var ( + content []byte + filename string + err error + ) + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + content, filename, err = fc.fetchURL(source) + } else { + content, filename, err = fc.readLocalFile(source) + } + + if err != nil { + return nil, err + } + + file := &CachedFile{Content: content, Filename: filename} + + // LoadOrStore handles race condition - if another goroutine + // cached it first, we get theirs (no duplicate storage) + actual, _ := fc.cache.LoadOrStore(source, file) + return actual.(*CachedFile), nil +} + +func (fc *FileCache) readLocalFile(filePath string) ([]byte, string, error) { + content, err := os.ReadFile(filePath) //nolint:gosec + if err != nil { + return nil, "", fmt.Errorf("failed to read file %s: %w", filePath, err) + } + return content, filepath.Base(filePath), nil +} + +func (fc *FileCache) fetchURL(url string) ([]byte, string, error) { + client := &http.Client{ + Timeout: fc.requestTimeout, + } + + resp, err := client.Get(url) + if err != nil { + return nil, "", fmt.Errorf("failed to fetch URL %s: %w", url, err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("failed to fetch URL %s: HTTP %d", url, resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("failed to read response body from %s: %w", url, err) + } + + // Extract filename from URL path + filename := path.Base(url) + if filename == "" || filename == "/" || filename == "." { + filename = "downloaded_file" + } + + // Remove query string from filename if present + if idx := strings.Index(filename, "?"); idx != -1 { + filename = filename[:idx] + } + + return content, filename, nil +} diff --git a/internal/sarin/request.go b/internal/sarin/request.go index 9b4604e..aeddcb4 100644 --- a/internal/sarin/request.go +++ b/internal/sarin/request.go @@ -34,11 +34,12 @@ func NewRequestGenerator( cookies types.Cookies, bodies []string, values []string, + fileCache *FileCache, ) (RequestGenerator, bool) { randSource := NewDefaultRandSource() //nolint:gosec // G404: Using non-cryptographic rand for load testing, not security localRand := rand.New(randSource) - templateFuncMap := NewDefaultTemplateFuncMap(randSource) + templateFuncMap := NewDefaultTemplateFuncMap(randSource, fileCache) pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap) methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap) @@ -47,7 +48,7 @@ func NewRequestGenerator( cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap) bodyTemplateFuncMapData := &BodyTemplateFuncMapData{} - bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData) + bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache) bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap) valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap) diff --git a/internal/sarin/sarin.go b/internal/sarin/sarin.go index 6c3a9bb..664e9b7 100644 --- a/internal/sarin/sarin.go +++ b/internal/sarin/sarin.go @@ -51,6 +51,7 @@ type sarin struct { hostClients []*fasthttp.HostClient responses *SarinResponseData + fileCache *FileCache } // NewSarin creates a new sarin instance for load testing. @@ -101,6 +102,7 @@ func NewSarin( collectStats: collectStats, dryRun: dryRun, hostClients: hostClients, + fileCache: NewFileCache(time.Second * 10), } if collectStats { @@ -191,7 +193,7 @@ func (q sarin) Worker( defer fasthttp.ReleaseRequest(req) defer fasthttp.ReleaseResponse(resp) - requestGenerator, isDynamic := NewRequestGenerator(q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values) + requestGenerator, isDynamic := NewRequestGenerator(q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache) if q.dryRun { switch { diff --git a/internal/sarin/template.go b/internal/sarin/template.go index 1e42b8d..bd4f611 100644 --- a/internal/sarin/template.go +++ b/internal/sarin/template.go @@ -2,6 +2,8 @@ package sarin import ( "bytes" + "encoding/base64" + "errors" "math/rand/v2" "mime/multipart" "strings" @@ -12,7 +14,7 @@ import ( "github.com/brianvoe/gofakeit/v7" ) -func NewDefaultTemplateFuncMap(randSource rand.Source) template.FuncMap { +func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) template.FuncMap { fakeit := gofakeit.NewFaker(randSource, false) return template.FuncMap{ @@ -82,6 +84,21 @@ func NewDefaultTemplateFuncMap(randSource rand.Source) template.FuncMap { "slice_Int": func(values ...int) []int { return values }, "slice_Uint": func(values ...uint) []uint { return values }, + // File + // file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content. + // Usage: {{ file_Base64 "/path/to/file.pdf" }} + // {{ file_Base64 "https://example.com/image.png" }} + "file_Base64": func(source string) (string, error) { + if fileCache == nil { + return "", errors.New("file cache is not initialized") + } + cached, err := fileCache.GetOrLoad(source) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(cached.Content), nil + }, + // Fakeit / File // "fakeit_CSV": fakeit.CSV(nil), // "fakeit_JSON": fakeit.JSON(nil), @@ -542,21 +559,75 @@ func (data *BodyTemplateFuncMapData) ClearFormDataContenType() { data.formDataContenType = "" } -func NewDefaultBodyTemplateFuncMap(randSource rand.Source, data *BodyTemplateFuncMapData) template.FuncMap { - funcMap := NewDefaultTemplateFuncMap(randSource) +func NewDefaultBodyTemplateFuncMap( + randSource rand.Source, + data *BodyTemplateFuncMapData, + fileCache *FileCache, +) template.FuncMap { + funcMap := NewDefaultTemplateFuncMap(randSource, fileCache) if data != nil { - funcMap["body_FormData"] = func(kv map[string]string) string { + // body_FormData creates a multipart/form-data body from key-value pairs. + // Usage: {{ body_FormData "field1" "value1" "field2" "value2" ... }} + // + // Values starting with "@" are treated as file references: + // - "@/path/to/file.txt" - local file + // - "@http://example.com/file" - remote file via HTTP + // - "@https://example.com/file" - remote file via HTTPS + // + // To send a literal string starting with "@", escape it with "@@": + // - "@@literal" sends "@literal" + // + // Example with mixed text and files: + // {{ body_FormData "name" "John" "avatar" "@/path/to/photo.jpg" "doc" "@https://example.com/file.pdf" }} + funcMap["body_FormData"] = func(pairs ...string) (string, error) { + if len(pairs)%2 != 0 { + return "", errors.New("body_FormData requires an even number of arguments (key-value pairs)") + } + var multipartData bytes.Buffer writer := multipart.NewWriter(&multipartData) data.formDataContenType = writer.FormDataContentType() - for k, v := range kv { - _ = writer.WriteField(k, v) + for i := 0; i < len(pairs); i += 2 { + key := pairs[i] + val := pairs[i+1] + + switch { + case strings.HasPrefix(val, "@@"): + // Escaped @ - send as literal string without first @ + if err := writer.WriteField(key, val[1:]); err != nil { + return "", err + } + case strings.HasPrefix(val, "@"): + // File (local path or remote URL) + if fileCache == nil { + return "", errors.New("file cache is not initialized") + } + source := val[1:] + cached, err := fileCache.GetOrLoad(source) + if err != nil { + return "", err + } + part, err := writer.CreateFormFile(key, cached.Filename) + if err != nil { + return "", err + } + if _, err := part.Write(cached.Content); err != nil { + return "", err + } + default: + // Regular text field + if err := writer.WriteField(key, val); err != nil { + return "", err + } + } } - _ = writer.Close() - return multipartData.String() + if err := writer.Close(); err != nil { + return "", err + } + return multipartData.String(), nil } }