11 Commits

Author SHA1 Message Date
533ced4b54 add scripting js/lua 2026-01-28 14:21:08 +04:00
c3ea3a34ad Merge pull request #167 from aykhans/add-badges
Add pkg.go.dev, Go Report Card, and license badges
2026-01-26 15:36:48 +04:00
aykhans
c02a079d2a Use vanity URL for Go Report Card badge 2026-01-26 11:35:55 +00:00
aykhans
f78942bfb6 Add pkg.go.dev, Go Report Card, and license badges 2026-01-26 11:32:20 +00:00
1369cb9f09 Merge pull request #165 from aykhans/feat/file-upload-support
Add file upload support with body_FormData and file_Base64 functions
2026-01-17 21:19:19 +04:00
18662e6a64 Add file upload examples and fix templating.md table of contents 2026-01-17 21:18:37 +04:00
81f08edc8d Add file upload support with body_FormData and file_Base64 functions
- Add FileCache for caching local files and remote URLs in memory
- Update body_FormData to accept variadic key-value pairs with file support
  - Use @ prefix for file paths (local or HTTP/HTTPS URLs)
  - Use @@ to escape literal @ values
- Add file_Base64 function for Base64 encoding files
- Update documentation with new syntax and examples
2026-01-17 20:27:22 +04:00
a9738c0a11 Merge pull request #164 from aykhans/docs/improvements
Fix config priority order (CLI > YAML > ENV), clarify multi-value cycling behavior, and improve documentation examples
2026-01-16 23:17:28 +04:00
76225884e6 Fix config priority order (CLI > YAML > ENV), clarify multi-value cycling behavior, and improve documentation examples 2026-01-16 23:15:12 +04:00
a512f3605d Merge pull request #163 from aykhans/dependabot/go_modules/golang.org/x/net-0.49.0
Bump golang.org/x/net from 0.48.0 to 0.49.0
2026-01-13 11:24:54 +04:00
dependabot[bot]
635c33008b Bump golang.org/x/net from 0.48.0 to 0.49.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-13 04:22:03 +00:00
21 changed files with 1431 additions and 78 deletions

View File

@@ -29,7 +29,6 @@ linters:
- errorlint - errorlint
- exptostd - exptostd
- fatcontext - fatcontext
- forcetypeassert
- funcorder - funcorder
- gocheckcompilerdirectives - gocheckcompilerdirectives
- gocritic - gocritic

View File

@@ -2,6 +2,10 @@
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp. ## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
[![Go Reference](https://pkg.go.dev/badge/go.aykhans.me/sarin.svg)](https://pkg.go.dev/go.aykhans.me/sarin)
[![Go Report Card](https://goreportcard.com/badge/go.aykhans.me/sarin)](https://goreportcard.com/report/go.aykhans.me/sarin)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
</div> </div>
![Demo](docs/static/demo.gif) ![Demo](docs/static/demo.gif)
@@ -93,7 +97,7 @@ For more usage examples, see the **[Examples Guide](docs/examples.md)**.
Sarin supports environment variables, CLI flags, and YAML files. When the same option is specified in multiple sources, the following priority order applies: Sarin supports environment variables, CLI flags, and YAML files. When the same option is specified in multiple sources, the following priority order applies:
``` ```
YAML (Highest) > CLI Flags > Environment Variables (Lowest) CLI Flags (Highest) > YAML > Environment Variables (Lowest)
``` ```
For detailed documentation on all configuration options (URL, method, timeout, concurrency, headers, cookies, proxy, etc.), see the **[Configuration Guide](docs/configuration.md)**. For detailed documentation on all configuration options (URL, method, timeout, concurrency, headers, cookies, proxy, etc.), see the **[Configuration Guide](docs/configuration.md)**.
@@ -106,9 +110,9 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
```sh ```sh
sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10 \ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10 \
-V "RequestID={{ fakeit_UUID }}" \ -V "REQUEST_ID={{ fakeit_UUID }}" \
-H "X-Request-ID: {{ .Values.RequestID }}" \ -H "X-Request-ID: {{ .Values.REQUEST_ID }}" \
-B '{"request_id": "{{ .Values.RequestID }}"}' -B '{"request_id": "{{ .Values.REQUEST_ID }}"}'
``` ```
For the complete templating guide and functions reference, see the **[Templating Guide](docs/templating.md)**. For the complete templating guide and functions reference, see the **[Templating Guide](docs/templating.md)**.

View File

@@ -53,7 +53,12 @@ func main() {
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values, combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
*combinedConfig.Output != config.ConfigOutputTypeNone, *combinedConfig.Output != config.ConfigOutputTypeNone,
*combinedConfig.DryRun, *combinedConfig.DryRun,
combinedConfig.Lua, combinedConfig.Js,
) )
if err != nil {
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[ERROR] ")+err.Error())
os.Exit(1)
}
_ = utilsErr.MustHandle(err, _ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.ProxyDialError) error { utilsErr.OnType(func(err types.ProxyDialError) error {
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[PROXY] ")+err.Error()) fmt.Fprintln(os.Stderr, config.StyleRed.Render("[PROXY] ")+err.Error())

View File

@@ -5,7 +5,7 @@ Sarin supports environment variables, CLI flags, and YAML files. However, they a
When the same option is specified in multiple sources, the following priority order applies: When the same option is specified in multiple sources, the following priority order applies:
``` ```
YAML (Highest) > CLI Flags > Environment Variables (Lowest) CLI Flags (Highest) > YAML > Environment Variables (Lowest)
``` ```
Use `-s` or `--show-config` to see the final merged configuration before sending requests. Use `-s` or `--show-config` to see the final merged configuration before sending requests.
@@ -14,8 +14,8 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
> **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values. > **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values.
| Name | YAML | CLI | ENV | Default | Description | | Name | YAML | CLI | ENV | Default | Description |
| --------------------------- | ----------------------------------- | --------------------------------------------- | -------------------------------- | ------- | ---------------------------- | | --------------------------- | ----------------------------------- | -------------------------------------------- | -------------------------------- | ------- | ---------------------------- |
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message | | [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info | | [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info |
| [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration | | [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration |
@@ -43,36 +43,65 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
Show help message. Show help message.
```sh
sarin -help
```
## Version ## Version
Show version and build information. Show version and build information.
```sh
sarin -version
```
## Show Config ## Show Config
Show the final merged configuration before sending requests. Show the final merged configuration before sending requests.
```sh
sarin -show-config
```
## Config File ## Config File
Path to configuration file(s). Supports local paths and remote URLs. Path to configuration file(s). Supports local paths and remote URLs.
If multiple config files are specified, they are merged in order. Later files override earlier ones. **Priority Rules:**
1. **CLI flags** (`-f`) have highest priority, processed left to right (rightmost wins)
2. **Included files** (via `configFile` property) are processed with lower priority than their parent
3. **Environment variable** (`SARIN_CONFIG_FILE`) has lowest priority
**Example:** **Example:**
```yaml ```yaml
# config2.yaml # config2.yaml
configFile: /config4.yaml configFile: /config4.yaml
url: http://from-config2.com
``` ```
```sh ```sh
SARIN_CONFIG_FILE=/config1.yaml sarin -f /config2.yaml -f https://example.com/config3.yaml SARIN_CONFIG_FILE=/config1.yaml sarin -f /config2.yaml -f https://example.com/config3.yaml
``` ```
In this example, all 4 config files are read and merged with the following priority: **Resolution order (lowest to highest priority):**
``` | Source | File | Priority |
config3.yaml > config2.yaml > config4.yaml > config1.yaml | ------------------------ | ------------ | -------- |
``` | ENV (SARIN_CONFIG_FILE) | config1.yaml | Lowest |
| Included by config2.yaml | config4.yaml | ↑ |
| CLI -f (first) | config2.yaml | ↑ |
| CLI -f (second) | config3.yaml | Highest |
**Why this order?**
- `config1.yaml` comes from ENV → lowest priority
- `config2.yaml` comes from CLI → higher than ENV
- `config4.yaml` is included BY `config2.yaml` → inherits position below its parent
- `config3.yaml` comes from CLI after `config2.yaml` → highest priority
If all four files define `url`, the value from `config3.yaml` wins.
## URL ## URL
@@ -94,7 +123,7 @@ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10
## Method ## Method
HTTP method(s). If multiple values are provided, Sarin cycles through them randomly for each request. Supports [templating](templating.md). HTTP method(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
**YAML example:** **YAML example:**
@@ -167,7 +196,7 @@ Skip TLS certificate verification.
## Body ## Body
Request body. If multiple values are provided, Sarin cycles through them randomly for each request. Supports [templating](templating.md). Request body. If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
**YAML example:** **YAML example:**
@@ -196,7 +225,7 @@ SARIN_BODY='{"product": "car"}'
## Params ## Params
URL query parameters. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md). URL query parameters. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
**YAML example:** **YAML example:**
@@ -226,7 +255,7 @@ SARIN_PARAM="key1=value1"
## Headers ## Headers
HTTP headers. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md). HTTP headers. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
**YAML example:** **YAML example:**
@@ -256,7 +285,7 @@ SARIN_HEADER="key1: value1"
## Cookies ## Cookies
HTTP cookies. If multiple values are provided for a key, Sarin cycles through them randomly for each request. Supports [templating](templating.md). HTTP cookies. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
**YAML example:** **YAML example:**
@@ -286,7 +315,7 @@ SARIN_COOKIE="key1=value1"
## Proxy ## Proxy
Proxy URL(s). If multiple values are provided, Sarin cycles through them randomly for each request. Proxy URL(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request.
Supported protocols: `http`, `https`, `socks5`, `socks5h` Supported protocols: `http`, `https`, `socks5`, `socks5h`

View File

@@ -9,6 +9,7 @@ This guide provides practical examples for common Sarin use cases.
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters) - [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
- [Dynamic Requests with Templating](#dynamic-requests-with-templating) - [Dynamic Requests with Templating](#dynamic-requests-with-templating)
- [Request Bodies](#request-bodies) - [Request Bodies](#request-bodies)
- [File Uploads](#file-uploads)
- [Using Proxies](#using-proxies) - [Using Proxies](#using-proxies)
- [Output Formats](#output-formats) - [Output Formats](#output-formats)
- [Docker Usage](#docker-usage) - [Docker Usage](#docker-usage)
@@ -175,8 +176,8 @@ url: http://example.com/search
requests: 1000 requests: 1000
concurrency: 10 concurrency: 10
params: params:
query: test query: "test"
limit: "10" limit: 10
``` ```
</details> </details>
@@ -198,7 +199,7 @@ requests: 1000
concurrency: 10 concurrency: 10
params: params:
id: "{{ fakeit_IntRange 1 1000 }}" id: "{{ fakeit_IntRange 1 1000 }}"
fields: name,email fields: "name,email"
``` ```
</details> </details>
@@ -456,7 +457,7 @@ body: |
```sh ```sh
sarin -U http://example.com/api/upload -r 1000 -c 10 \ sarin -U http://example.com/api/upload -r 1000 -c 10 \
-M POST \ -M POST \
-B '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}' -B '{{ body_FormData "username" "john" "email" "john@example.com" }}'
``` ```
<details> <details>
@@ -467,7 +468,7 @@ url: http://example.com/api/upload
requests: 1000 requests: 1000
concurrency: 10 concurrency: 10
method: POST method: POST
body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}' body: '{{ body_FormData "username" "john" "email" "john@example.com" }}'
``` ```
</details> </details>
@@ -477,7 +478,7 @@ body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com")
```sh ```sh
sarin -U http://example.com/api/users -r 1000 -c 10 \ sarin -U http://example.com/api/users -r 1000 -c 10 \
-M POST \ -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) }}'
``` ```
<details> <details>
@@ -488,13 +489,160 @@ url: http://example.com/api/users
requests: 1000 requests: 1000
concurrency: 10 concurrency: 10
method: POST 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) }}'
``` ```
</details> </details>
> **Note:** `body_FormData` automatically sets the `Content-Type` header to `multipart/form-data` with the appropriate boundary. > **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" }}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/upload
requests: 100
concurrency: 10
method: POST
body: '{{ body_FormData "title" "My Document" "document" "@/path/to/file.pdf" }}'
```
</details>
**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" }}'
```
<details>
<summary>YAML equivalent</summary>
```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"
}}
```
</details>
**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" }}'
```
<details>
<summary>YAML equivalent</summary>
```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"
}}
```
</details>
**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" }}'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/upload
requests: 100
concurrency: 10
method: POST
body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
```
</details>
> **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"}'
```
<details>
<summary>YAML equivalent</summary>
```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"}'
```
</details>
**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"}'
```
<details>
<summary>YAML equivalent</summary>
```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"}'
```
</details>
## Using Proxies ## Using Proxies
**Single HTTP proxy:** **Single HTTP proxy:**

View File

@@ -11,6 +11,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
- [String Functions](#string-functions) - [String Functions](#string-functions)
- [Collection Functions](#collection-functions) - [Collection Functions](#collection-functions)
- [Body Functions](#body-functions) - [Body Functions](#body-functions)
- [File Functions](#file-functions)
- [Fake Data Functions](#fake-data-functions) - [Fake Data Functions](#fake-data-functions)
- [File](#file) - [File](#file)
- [ID](#id) - [ID](#id)
@@ -110,9 +111,63 @@ sarin -U http://example.com/users \
### Body Functions ### Body Functions
| Function | Description | Example | | 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") }}` | | `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 ## Fake Data Functions
@@ -532,7 +587,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| Function | Description | Example | | Function | Description | Example |
| ------------------------------------- | ------------------------------- | --------------------------------------------------------------- | | ------------------------------------- | ------------------------------- | --------------------------------------------------------------- |
| `fakeit_Digit` | Single random digit | `"0"` | | `fakeit_Digit` | Single random digit | `"0"` |
| `fakeit_DigitN(n uint)` | Generate `n` random digits | `{{ fakeit_DigitN 5 }}``"0136459948"` | | `fakeit_DigitN(n uint)` | Generate `n` random digits | `{{ fakeit_DigitN 5 }}``"71364"` |
| `fakeit_Letter` | Single random letter | `"g"` | | `fakeit_Letter` | Single random letter | `"g"` |
| `fakeit_LetterN(n uint)` | Generate `n` random letters | `{{ fakeit_LetterN 10 }}``"gbRMaRxHki"` | | `fakeit_LetterN(n uint)` | Generate `n` random letters | `{{ fakeit_LetterN 10 }}``"gbRMaRxHki"` |
| `fakeit_Lexify(pattern string)` | Replace `?` with random letters | `{{ fakeit_Lexify "?????@??????.com" }}``"billy@mister.com"` | | `fakeit_Lexify(pattern string)` | Replace `?` with random letters | `{{ fakeit_Lexify "?????@??????.com" }}``"billy@mister.com"` |

10
go.mod
View File

@@ -9,11 +9,13 @@ require (
github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/term v0.2.2 github.com/charmbracelet/x/term v0.2.2
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/valyala/fasthttp v1.69.0 github.com/valyala/fasthttp v1.69.0
github.com/yuin/gopher-lua v1.1.1
go.aykhans.me/utils v1.0.7 go.aykhans.me/utils v1.0.7
go.yaml.in/yaml/v4 v4.0.0-rc.3 go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/net v0.48.0 golang.org/x/net v0.49.0
) )
require ( require (
@@ -32,6 +34,8 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/compress v1.18.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -50,6 +54,6 @@ require (
github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.38.0 // indirect golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.33.0 // indirect
) )

24
go.sum
View File

@@ -1,3 +1,5 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA= github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
@@ -46,8 +48,14 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -95,21 +103,25 @@ github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw= go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI= go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -17,20 +17,7 @@ const cliUsageText = `Usage:
sarin [flags] sarin [flags]
Simple usage: Simple usage:
sarin -U https://example.com -d 1m sarin -U https://example.com -r 1
Usage with all flags:
sarin -s -q -z -o json -f ./config.yaml -c 50 -r 100_000 -d 2m30s \
-U https://example.com \
-M POST \
-V "sharedUUID={{ fakeit_UUID }}" \
-B '{"product": "car"}' \
-P "id={{ .Values.sharedUUID }}" \
-H "User-Agent: {{ fakeit_UserAgent }}" -H "Accept: */*" \
-C "token={{ .Values.sharedUUID }}" \
-X "http://proxy.example.com" \
-T 3s \
-I
Flags: Flags:
General Config: General Config:
@@ -55,7 +42,9 @@ Flags:
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080") -X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
-V, -values []string List of values for templating (e.g. "key1=value1") -V, -values []string List of values for templating (e.g. "key1=value1")
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v) -T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)` -I, -insecure bool Skip SSL/TLS certificate verification (default %v)
-lua []string Lua script for request transformation (inline or @file/@url)
-js []string JavaScript script for request transformation (inline or @file/@url)`
var _ IParser = ConfigCLIParser{} var _ IParser = ConfigCLIParser{}
@@ -106,16 +95,18 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
dryRun bool dryRun bool
// Request config // Request config
urlInput string urlInput string
methods = stringSliceArg{} methods = stringSliceArg{}
bodies = stringSliceArg{} bodies = stringSliceArg{}
params = stringSliceArg{} params = stringSliceArg{}
headers = stringSliceArg{} headers = stringSliceArg{}
cookies = stringSliceArg{} cookies = stringSliceArg{}
proxies = stringSliceArg{} proxies = stringSliceArg{}
values = stringSliceArg{} values = stringSliceArg{}
timeout time.Duration timeout time.Duration
insecure bool insecure bool
luaScripts = stringSliceArg{}
jsScripts = stringSliceArg{}
) )
{ {
@@ -177,6 +168,10 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification") flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification") flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
flagSet.Var(&luaScripts, "lua", "Lua script for request transformation (inline or @file/@url)")
flagSet.Var(&jsScripts, "js", "JavaScript script for request transformation (inline or @file/@url)")
} }
// Parse the specific arguments provided to the parser, skipping the program name. // Parse the specific arguments provided to the parser, skipping the program name.
@@ -259,6 +254,10 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
config.Timeout = common.ToPtr(timeout) config.Timeout = common.ToPtr(timeout)
case "insecure", "I": case "insecure", "I":
config.Insecure = common.ToPtr(insecure) config.Insecure = common.ToPtr(insecure)
case "lua":
config.Lua = append(config.Lua, luaScripts...)
case "js":
config.Js = append(config.Js, jsScripts...)
} }
}) })

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
@@ -16,6 +17,7 @@ import (
"github.com/charmbracelet/glamour/styles" "github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/term" "github.com/charmbracelet/x/term"
"go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
"go.aykhans.me/sarin/internal/version" "go.aykhans.me/sarin/internal/version"
"go.aykhans.me/utils/common" "go.aykhans.me/utils/common"
@@ -87,6 +89,8 @@ type Config struct {
Bodies []string `yaml:"bodies,omitempty"` Bodies []string `yaml:"bodies,omitempty"`
Proxies types.Proxies `yaml:"proxies,omitempty"` Proxies types.Proxies `yaml:"proxies,omitempty"`
Values []string `yaml:"values,omitempty"` Values []string `yaml:"values,omitempty"`
Lua []string `yaml:"lua,omitempty"`
Js []string `yaml:"js,omitempty"`
} }
func NewConfig() *Config { func NewConfig() *Config {
@@ -219,6 +223,8 @@ func (config Config) MarshalYAML() (any, error) {
} }
addStringSlice(content, "values", config.Values, false) addStringSlice(content, "values", config.Values, false)
addStringSlice(content, "lua", config.Lua, false)
addStringSlice(content, "js", config.Js, false)
return root, nil return root, nil
} }
@@ -323,6 +329,12 @@ func (config *Config) Merge(newConfig *Config) {
if len(newConfig.Values) != 0 { if len(newConfig.Values) != 0 {
config.Values = append(config.Values, newConfig.Values...) config.Values = append(config.Values, newConfig.Values...)
} }
if len(newConfig.Lua) != 0 {
config.Lua = append(config.Lua, newConfig.Lua...)
}
if len(newConfig.Js) != 0 {
config.Js = append(config.Js, newConfig.Js...)
}
} }
func (config *Config) SetDefaults() { func (config *Config) SetDefaults() {
@@ -465,6 +477,44 @@ func (config Config) Validate() error {
} }
} }
// Create a context with timeout for script validation (loading from URLs)
scriptCtx, scriptCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer scriptCancel()
for i, scriptSrc := range config.Lua {
if err := validateScriptSource(scriptSrc); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(fmt.Sprintf("Lua[%d]", i), scriptSrc, err),
)
continue
}
// Validate script syntax
if err := script.ValidateScript(scriptCtx, scriptSrc, script.EngineTypeLua); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(fmt.Sprintf("Lua[%d]", i), scriptSrc, err),
)
}
}
for i, scriptSrc := range config.Js {
if err := validateScriptSource(scriptSrc); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(fmt.Sprintf("Js[%d]", i), scriptSrc, err),
)
continue
}
// Validate script syntax
if err := script.ValidateScript(scriptCtx, scriptSrc, script.EngineTypeJavaScript); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(fmt.Sprintf("Js[%d]", i), scriptSrc, err),
)
}
}
templateErrors := ValidateTemplates(&config) templateErrors := ValidateTemplates(&config)
validationErrors = append(validationErrors, templateErrors...) validationErrors = append(validationErrors, templateErrors...)
@@ -582,6 +632,51 @@ func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error)
return fileConfig, nil return fileConfig, nil
} }
// validateScriptSource validates a script source string.
// Scripts can be:
// - Inline script: any string not starting with "@"
// - Escaped "@": strings starting with "@@" (literal "@" at start)
// - File reference: "@/path/to/file" or "@./relative/path"
// - URL reference: "@http://..." or "@https://..."
func validateScriptSource(script string) error {
// Empty script is invalid
if script == "" {
return errors.New("script cannot be empty")
}
// Not a file/URL reference - it's an inline script
if !strings.HasPrefix(script, "@") {
return nil
}
// Escaped @ - it's an inline script starting with literal @
if strings.HasPrefix(script, "@@") {
return nil
}
// It's a file or URL reference - validate the source
source := script[1:] // Remove the @ prefix
if source == "" {
return errors.New("script source cannot be empty after @")
}
// Check if it's a URL
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
parsedURL, err := url.Parse(source)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if parsedURL.Host == "" {
return errors.New("URL must have a host")
}
return nil
}
// It's a file path - basic validation (not empty, checked above)
return nil
}
func printParseErrors(parserName string, errors ...types.FieldParseError) { func printParseErrors(parserName string, errors ...types.FieldParseError) {
for _, fieldErr := range errors { for _, fieldErr := range errors {
if fieldErr.Value == "" { if fieldErr.Value == "" {

View File

@@ -216,6 +216,14 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
config.Values = []string{values} config.Values = []string{values}
} }
if lua := parser.getEnv("LUA"); lua != "" {
config.Lua = []string{lua}
}
if js := parser.getEnv("JS"); js != "" {
config.Js = []string{js}
}
if len(fieldParseErrors) > 0 { if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors) return nil, types.NewFieldParseErrors(fieldParseErrors)
} }

View File

@@ -202,6 +202,8 @@ type configYAML struct {
Bodies stringOrSliceField `yaml:"body"` Bodies stringOrSliceField `yaml:"body"`
Proxies stringOrSliceField `yaml:"proxy"` Proxies stringOrSliceField `yaml:"proxy"`
Values stringOrSliceField `yaml:"values"` Values stringOrSliceField `yaml:"values"`
Lua stringOrSliceField `yaml:"lua"`
Js stringOrSliceField `yaml:"js"`
} }
// ParseYAML parses YAML config file arguments into a Config object. // ParseYAML parses YAML config file arguments into a Config object.
@@ -246,6 +248,8 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
} }
config.Bodies = append(config.Bodies, parsedData.Bodies...) config.Bodies = append(config.Bodies, parsedData.Bodies...)
config.Values = append(config.Values, parsedData.Values...) config.Values = append(config.Values, parsedData.Values...)
config.Lua = append(config.Lua, parsedData.Lua...)
config.Js = append(config.Js, parsedData.Js...)
if len(parsedData.ConfigFiles) > 0 { if len(parsedData.ConfigFiles) > 0 {
for _, configFile := range parsedData.ConfigFiles { for _, configFile := range parsedData.ConfigFiles {

View File

@@ -191,11 +191,12 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F
func ValidateTemplates(config *Config) []types.FieldValidationError { func ValidateTemplates(config *Config) []types.FieldValidationError {
// Create template function map using the same functions as sarin package // 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() randSource := sarin.NewDefaultRandSource()
funcMap := sarin.NewDefaultTemplateFuncMap(randSource) funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
bodyFuncMapData := &sarin.BodyTemplateFuncMapData{} bodyFuncMapData := &sarin.BodyTemplateFuncMapData{}
bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData) bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData, nil)
var allErrors []types.FieldValidationError var allErrors []types.FieldValidationError

102
internal/sarin/filecache.go Normal file
View File

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

View File

@@ -11,6 +11,7 @@ import (
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
utilsSlice "go.aykhans.me/utils/slice" utilsSlice "go.aykhans.me/utils/slice"
) )
@@ -26,6 +27,9 @@ type valuesData struct {
// NewRequestGenerator creates a new RequestGenerator function that generates HTTP requests // NewRequestGenerator creates a new RequestGenerator function that generates HTTP requests
// with the specified configuration. The returned RequestGenerator is NOT safe for concurrent // with the specified configuration. The returned RequestGenerator is NOT safe for concurrent
// use by multiple goroutines. // use by multiple goroutines.
//
// Note: Scripts must be validated before calling this function (e.g., in NewSarin).
// The caller is responsible for managing the scriptTransformer lifecycle.
func NewRequestGenerator( func NewRequestGenerator(
methods []string, methods []string,
requestURL *url.URL, requestURL *url.URL,
@@ -34,11 +38,13 @@ func NewRequestGenerator(
cookies types.Cookies, cookies types.Cookies,
bodies []string, bodies []string,
values []string, values []string,
fileCache *FileCache,
scriptTransformer *script.Transformer,
) (RequestGenerator, bool) { ) (RequestGenerator, bool) {
randSource := NewDefaultRandSource() randSource := NewDefaultRandSource()
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security //nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
localRand := rand.New(randSource) localRand := rand.New(randSource)
templateFuncMap := NewDefaultTemplateFuncMap(randSource) templateFuncMap := NewDefaultTemplateFuncMap(randSource, fileCache)
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap) pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap)
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap) methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
@@ -47,11 +53,13 @@ func NewRequestGenerator(
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap) cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap)
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{} bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData) bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache)
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap) bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap)
valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap) valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)
hasScripts := scriptTransformer != nil && !scriptTransformer.IsEmpty()
var ( var (
data valuesData data valuesData
path string path string
@@ -97,13 +105,24 @@ func NewRequestGenerator(
if requestURL.Scheme == "https" { if requestURL.Scheme == "https" {
req.URI().SetScheme("https") req.URI().SetScheme("https")
} }
// Apply script transformations if any
if hasScripts {
reqData := script.RequestDataFromFastHTTP(req)
if err = scriptTransformer.Transform(reqData); err != nil {
return err
}
script.ApplyToFastHTTP(reqData, req)
}
return nil return nil
}, isPathGeneratorDynamic || }, isPathGeneratorDynamic ||
isMethodGeneratorDynamic || isMethodGeneratorDynamic ||
isParamsGeneratorDynamic || isParamsGeneratorDynamic ||
isHeadersGeneratorDynamic || isHeadersGeneratorDynamic ||
isCookiesGeneratorDynamic || isCookiesGeneratorDynamic ||
isBodyGeneratorDynamic isBodyGeneratorDynamic ||
hasScripts
} }
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) { func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {

View File

@@ -14,6 +14,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
) )
@@ -51,11 +52,14 @@ type sarin struct {
hostClients []*fasthttp.HostClient hostClients []*fasthttp.HostClient
responses *SarinResponseData responses *SarinResponseData
fileCache *FileCache
scriptChain *script.Chain
} }
// NewSarin creates a new sarin instance for load testing. // NewSarin creates a new sarin instance for load testing.
// It can return the following errors: // It can return the following errors:
// - types.ProxyDialError // - types.ProxyDialError
// - script loading errors
func NewSarin( func NewSarin(
ctx context.Context, ctx context.Context,
methods []string, methods []string,
@@ -74,6 +78,8 @@ func NewSarin(
values []string, values []string,
collectStats bool, collectStats bool,
dryRun bool, dryRun bool,
luaScripts []string,
jsScripts []string,
) (*sarin, error) { ) (*sarin, error) {
if workers == 0 { if workers == 0 {
workers = 1 workers = 1
@@ -84,6 +90,19 @@ func NewSarin(
return nil, err return nil, err
} }
// Load script sources
luaSources, err := script.LoadSources(ctx, luaScripts, script.EngineTypeLua)
if err != nil {
return nil, err
}
jsSources, err := script.LoadSources(ctx, jsScripts, script.EngineTypeJavaScript)
if err != nil {
return nil, err
}
scriptChain := script.NewChain(luaSources, jsSources)
srn := &sarin{ srn := &sarin{
workers: workers, workers: workers,
requestURL: requestURL, requestURL: requestURL,
@@ -101,6 +120,8 @@ func NewSarin(
collectStats: collectStats, collectStats: collectStats,
dryRun: dryRun, dryRun: dryRun,
hostClients: hostClients, hostClients: hostClients,
fileCache: NewFileCache(time.Second * 10),
scriptChain: scriptChain,
} }
if collectStats { if collectStats {
@@ -191,7 +212,20 @@ func (q sarin) Worker(
defer fasthttp.ReleaseRequest(req) defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp) defer fasthttp.ReleaseResponse(resp)
requestGenerator, isDynamic := NewRequestGenerator(q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values) // Create script transformer for this worker (engines are not thread-safe)
// Scripts are pre-validated in NewSarin, so this should not fail
var scriptTransformer *script.Transformer
if !q.scriptChain.IsEmpty() {
scriptTransformer, err := q.scriptChain.NewTransformer()
if err != nil {
panic(err)
}
defer scriptTransformer.Close()
}
requestGenerator, isDynamic := NewRequestGenerator(
q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache, scriptTransformer,
)
if q.dryRun { if q.dryRun {
switch { switch {

View File

@@ -2,6 +2,8 @@ package sarin
import ( import (
"bytes" "bytes"
"encoding/base64"
"errors"
"math/rand/v2" "math/rand/v2"
"mime/multipart" "mime/multipart"
"strings" "strings"
@@ -12,7 +14,7 @@ import (
"github.com/brianvoe/gofakeit/v7" "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) fakeit := gofakeit.NewFaker(randSource, false)
return template.FuncMap{ return template.FuncMap{
@@ -82,6 +84,21 @@ func NewDefaultTemplateFuncMap(randSource rand.Source) template.FuncMap {
"slice_Int": func(values ...int) []int { return values }, "slice_Int": func(values ...int) []int { return values },
"slice_Uint": func(values ...uint) []uint { 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 / File
// "fakeit_CSV": fakeit.CSV(nil), // "fakeit_CSV": fakeit.CSV(nil),
// "fakeit_JSON": fakeit.JSON(nil), // "fakeit_JSON": fakeit.JSON(nil),
@@ -542,21 +559,75 @@ func (data *BodyTemplateFuncMapData) ClearFormDataContenType() {
data.formDataContenType = "" data.formDataContenType = ""
} }
func NewDefaultBodyTemplateFuncMap(randSource rand.Source, data *BodyTemplateFuncMapData) template.FuncMap { func NewDefaultBodyTemplateFuncMap(
funcMap := NewDefaultTemplateFuncMap(randSource) randSource rand.Source,
data *BodyTemplateFuncMapData,
fileCache *FileCache,
) template.FuncMap {
funcMap := NewDefaultTemplateFuncMap(randSource, fileCache)
if data != nil { 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 var multipartData bytes.Buffer
writer := multipart.NewWriter(&multipartData) writer := multipart.NewWriter(&multipartData)
data.formDataContenType = writer.FormDataContentType() data.formDataContenType = writer.FormDataContentType()
for k, v := range kv { for i := 0; i < len(pairs); i += 2 {
_ = writer.WriteField(k, v) 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() if err := writer.Close(); err != nil {
return multipartData.String() return "", err
}
return multipartData.String(), nil
} }
} }

185
internal/script/chain.go Normal file
View File

@@ -0,0 +1,185 @@
package script
import (
"fmt"
"github.com/valyala/fasthttp"
)
// Chain holds the loaded script sources and can create engine instances.
// The sources are loaded once, but engines are created per-worker since they're not thread-safe.
type Chain struct {
luaSources []*Source
jsSources []*Source
}
// NewChain creates a new script chain from loaded sources.
// Lua scripts run first, then JavaScript scripts, in the order provided.
func NewChain(luaSources, jsSources []*Source) *Chain {
return &Chain{
luaSources: luaSources,
jsSources: jsSources,
}
}
// IsEmpty returns true if there are no scripts to execute.
func (c *Chain) IsEmpty() bool {
return len(c.luaSources) == 0 && len(c.jsSources) == 0
}
// Transformer holds instantiated script engines for a single worker.
// It is NOT safe for concurrent use.
type Transformer struct {
luaEngines []*LuaEngine
jsEngines []*JsEngine
}
// NewTransformer creates engine instances from the chain's sources.
// Call this once per worker goroutine.
func (c *Chain) NewTransformer() (*Transformer, error) {
if c.IsEmpty() {
return &Transformer{}, nil
}
t := &Transformer{
luaEngines: make([]*LuaEngine, 0, len(c.luaSources)),
jsEngines: make([]*JsEngine, 0, len(c.jsSources)),
}
// Create Lua engines
for i, src := range c.luaSources {
engine, err := NewLuaEngine(src.Content)
if err != nil {
t.Close() // Clean up already created engines
return nil, fmt.Errorf("lua script[%d]: %w", i, err)
}
t.luaEngines = append(t.luaEngines, engine)
}
// Create JS engines
for i, src := range c.jsSources {
engine, err := NewJsEngine(src.Content)
if err != nil {
t.Close() // Clean up already created engines
return nil, fmt.Errorf("js script[%d]: %w", i, err)
}
t.jsEngines = append(t.jsEngines, engine)
}
return t, nil
}
// Transform applies all scripts to the request data.
// Lua scripts run first, then JavaScript scripts.
func (t *Transformer) Transform(req *RequestData) error {
// Run Lua scripts
for i, engine := range t.luaEngines {
if err := engine.Transform(req); err != nil {
return fmt.Errorf("lua script[%d]: %w", i, err)
}
}
// Run JS scripts
for i, engine := range t.jsEngines {
if err := engine.Transform(req); err != nil {
return fmt.Errorf("js script[%d]: %w", i, err)
}
}
return nil
}
// Close releases all engine resources.
func (t *Transformer) Close() {
for _, engine := range t.luaEngines {
engine.Close()
}
for _, engine := range t.jsEngines {
engine.Close()
}
}
// IsEmpty returns true if there are no engines.
func (t *Transformer) IsEmpty() bool {
return len(t.luaEngines) == 0 && len(t.jsEngines) == 0
}
// RequestDataFromFastHTTP extracts RequestData from a fasthttp.Request.
func RequestDataFromFastHTTP(req *fasthttp.Request) *RequestData {
data := &RequestData{
Method: string(req.Header.Method()),
URL: string(req.URI().FullURI()),
Path: string(req.URI().Path()),
Body: string(req.Body()),
Headers: make(map[string][]string),
Params: make(map[string][]string),
Cookies: make(map[string][]string),
}
// Extract headers (supports multiple values per key)
req.Header.All()(func(key, value []byte) bool {
k := string(key)
data.Headers[k] = append(data.Headers[k], string(value))
return true
})
// Extract query params (supports multiple values per key)
req.URI().QueryArgs().All()(func(key, value []byte) bool {
k := string(key)
data.Params[k] = append(data.Params[k], string(value))
return true
})
// Extract cookies (supports multiple values per key)
req.Header.Cookies()(func(key, value []byte) bool {
k := string(key)
data.Cookies[k] = append(data.Cookies[k], string(value))
return true
})
return data
}
// ApplyToFastHTTP applies the modified RequestData back to a fasthttp.Request.
func ApplyToFastHTTP(data *RequestData, req *fasthttp.Request) {
// Method
req.Header.SetMethod(data.Method)
// Path (preserve scheme and host)
req.URI().SetPath(data.Path)
// Body
req.SetBody([]byte(data.Body))
// Clear and set headers (supports multiple values per key)
req.Header.All()(func(key, _ []byte) bool {
keyStr := string(key)
if keyStr != "Host" {
req.Header.Del(keyStr)
}
return true
})
for k, values := range data.Headers {
if k != "Host" { // Don't overwrite Host
for _, v := range values {
req.Header.Add(k, v)
}
}
}
// Clear and set query params (supports multiple values per key)
req.URI().QueryArgs().Reset()
for k, values := range data.Params {
for _, v := range values {
req.URI().QueryArgs().Add(k, v)
}
}
// Clear and set cookies (supports multiple values per key)
req.Header.DelAllCookies()
for k, values := range data.Cookies {
for _, v := range values {
req.Header.SetCookie(k, v)
}
}
}

198
internal/script/js.go Normal file
View File

@@ -0,0 +1,198 @@
package script
import (
"errors"
"fmt"
"github.com/dop251/goja"
)
// JsEngine implements the Engine interface using goja (JavaScript).
type JsEngine struct {
runtime *goja.Runtime
transform goja.Callable
}
// NewJsEngine creates a new JavaScript script engine with the given script content.
// The script must define a global `transform` function that takes a request object
// and returns the modified request object.
//
// Example JavaScript script:
//
// function transform(req) {
// req.headers["X-Custom"] = "value";
// return req;
// }
func NewJsEngine(scriptContent string) (*JsEngine, error) {
vm := goja.New()
// Execute the script to define the transform function
_, err := vm.RunString(scriptContent)
if err != nil {
return nil, fmt.Errorf("failed to execute JavaScript script: %w", err)
}
// Get the transform function
transformVal := vm.Get("transform")
if transformVal == nil || goja.IsUndefined(transformVal) || goja.IsNull(transformVal) {
return nil, errors.New("script must define a global 'transform' function")
}
transform, ok := goja.AssertFunction(transformVal)
if !ok {
return nil, errors.New("'transform' must be a function")
}
return &JsEngine{
runtime: vm,
transform: transform,
}, nil
}
// Transform executes the JavaScript transform function with the given request data.
func (e *JsEngine) Transform(req *RequestData) error {
// Convert RequestData to JavaScript object
reqObj := e.requestDataToObject(req)
// Call transform(req)
result, err := e.transform(goja.Undefined(), reqObj)
if err != nil {
return fmt.Errorf("JavaScript transform error: %w", err)
}
// Update RequestData from the returned object
if err := e.objectToRequestData(result, req); err != nil {
return fmt.Errorf("failed to parse transform result: %w", err)
}
return nil
}
// Close releases the JavaScript runtime resources.
func (e *JsEngine) Close() {
// goja doesn't have an explicit close method, but we can help GC
e.runtime = nil
e.transform = nil
}
// requestDataToObject converts RequestData to a goja Value (JavaScript object).
func (e *JsEngine) requestDataToObject(req *RequestData) goja.Value {
obj := e.runtime.NewObject()
_ = obj.Set("method", req.Method)
_ = obj.Set("url", req.URL)
_ = obj.Set("path", req.Path)
_ = obj.Set("body", req.Body)
// Headers (map[string][]string -> object of arrays)
headers := e.runtime.NewObject()
for k, values := range req.Headers {
_ = headers.Set(k, e.stringSliceToArray(values))
}
_ = obj.Set("headers", headers)
// Params (map[string][]string -> object of arrays)
params := e.runtime.NewObject()
for k, values := range req.Params {
_ = params.Set(k, e.stringSliceToArray(values))
}
_ = obj.Set("params", params)
// Cookies (map[string][]string -> object of arrays)
cookies := e.runtime.NewObject()
for k, values := range req.Cookies {
_ = cookies.Set(k, e.stringSliceToArray(values))
}
_ = obj.Set("cookies", cookies)
return obj
}
// objectToRequestData updates RequestData from a JavaScript object.
func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error {
if val == nil || goja.IsUndefined(val) || goja.IsNull(val) {
return errors.New("transform function must return an object")
}
obj := val.ToObject(e.runtime)
if obj == nil {
return errors.New("transform function must return an object")
}
// Method
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) {
req.Method = v.String()
}
// URL
if v := obj.Get("url"); v != nil && !goja.IsUndefined(v) {
req.URL = v.String()
}
// Path
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) {
req.Path = v.String()
}
// Body
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) {
req.Body = v.String()
}
// Headers
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Headers = e.objectToStringSliceMap(v.ToObject(e.runtime))
}
// Params
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Params = e.objectToStringSliceMap(v.ToObject(e.runtime))
}
// Cookies
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Cookies = e.objectToStringSliceMap(v.ToObject(e.runtime))
}
return nil
}
// stringSliceToArray converts a Go []string to a JavaScript array.
func (e *JsEngine) stringSliceToArray(values []string) *goja.Object {
ifaces := make([]interface{}, len(values))
for i, v := range values {
ifaces[i] = v
}
return e.runtime.NewArray(ifaces...)
}
// objectToStringSliceMap converts a JavaScript object to a Go map[string][]string.
// Supports both single string values and array values.
func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string {
if obj == nil {
return make(map[string][]string)
}
result := make(map[string][]string)
for _, key := range obj.Keys() {
v := obj.Get(key)
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
continue
}
// Check if it's an array
if arr, ok := v.Export().([]interface{}); ok {
var values []string
for _, item := range arr {
if s, ok := item.(string); ok {
values = append(values, s)
}
}
result[key] = values
} else {
// Single value - wrap in slice
result[key] = []string{v.String()}
}
}
return result
}

191
internal/script/lua.go Normal file
View File

@@ -0,0 +1,191 @@
package script
import (
"errors"
"fmt"
lua "github.com/yuin/gopher-lua"
)
// LuaEngine implements the Engine interface using gopher-lua.
type LuaEngine struct {
state *lua.LState
transform *lua.LFunction
}
// NewLuaEngine creates a new Lua script engine with the given script content.
// The script must define a global `transform` function that takes a request table
// and returns the modified request table.
//
// Example Lua script:
//
// function transform(req)
// req.headers["X-Custom"] = "value"
// return req
// end
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
L := lua.NewState()
// Execute the script to define the transform function
if err := L.DoString(scriptContent); err != nil {
L.Close()
return nil, fmt.Errorf("failed to execute Lua script: %w", err)
}
// Get the transform function
transform := L.GetGlobal("transform")
if transform.Type() != lua.LTFunction {
L.Close()
return nil, errors.New("script must define a global 'transform' function")
}
return &LuaEngine{
state: L,
transform: transform.(*lua.LFunction),
}, nil
}
// Transform executes the Lua transform function with the given request data.
func (e *LuaEngine) Transform(req *RequestData) error {
// Convert RequestData to Lua table
reqTable := e.requestDataToTable(req)
// Call transform(req)
e.state.Push(e.transform)
e.state.Push(reqTable)
if err := e.state.PCall(1, 1, nil); err != nil {
return fmt.Errorf("lua transform error: %w", err)
}
// Get the result
result := e.state.Get(-1)
e.state.Pop(1)
if result.Type() != lua.LTTable {
return fmt.Errorf("transform function must return a table, got %s", result.Type())
}
// Update RequestData from the returned table
e.tableToRequestData(result.(*lua.LTable), req)
return nil
}
// Close releases the Lua state resources.
func (e *LuaEngine) Close() {
if e.state != nil {
e.state.Close()
}
}
// requestDataToTable converts RequestData to a Lua table.
func (e *LuaEngine) requestDataToTable(req *RequestData) *lua.LTable {
L := e.state
t := L.NewTable()
t.RawSetString("method", lua.LString(req.Method))
t.RawSetString("url", lua.LString(req.URL))
t.RawSetString("path", lua.LString(req.Path))
t.RawSetString("body", lua.LString(req.Body))
// Headers (map[string][]string -> table of arrays)
headers := L.NewTable()
for k, values := range req.Headers {
arr := L.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
headers.RawSetString(k, arr)
}
t.RawSetString("headers", headers)
// Params (map[string][]string -> table of arrays)
params := L.NewTable()
for k, values := range req.Params {
arr := L.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
params.RawSetString(k, arr)
}
t.RawSetString("params", params)
// Cookies (map[string][]string -> table of arrays)
cookies := L.NewTable()
for k, values := range req.Cookies {
arr := L.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
cookies.RawSetString(k, arr)
}
t.RawSetString("cookies", cookies)
return t
}
// tableToRequestData updates RequestData from a Lua table.
func (e *LuaEngine) tableToRequestData(t *lua.LTable, req *RequestData) {
// Method
if v := t.RawGetString("method"); v.Type() == lua.LTString {
req.Method = string(v.(lua.LString))
}
// URL
if v := t.RawGetString("url"); v.Type() == lua.LTString {
req.URL = string(v.(lua.LString))
}
// Path
if v := t.RawGetString("path"); v.Type() == lua.LTString {
req.Path = string(v.(lua.LString))
}
// Body
if v := t.RawGetString("body"); v.Type() == lua.LTString {
req.Body = string(v.(lua.LString))
}
// Headers
if v := t.RawGetString("headers"); v.Type() == lua.LTTable {
req.Headers = e.tableToStringSliceMap(v.(*lua.LTable))
}
// Params
if v := t.RawGetString("params"); v.Type() == lua.LTTable {
req.Params = e.tableToStringSliceMap(v.(*lua.LTable))
}
// Cookies
if v := t.RawGetString("cookies"); v.Type() == lua.LTTable {
req.Cookies = e.tableToStringSliceMap(v.(*lua.LTable))
}
}
// tableToStringSliceMap converts a Lua table to a Go map[string][]string.
// Supports both single string values and array values.
func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
result := make(map[string][]string)
t.ForEach(func(k, v lua.LValue) {
if k.Type() != lua.LTString {
return
}
key := string(k.(lua.LString))
switch v.Type() {
case lua.LTString:
// Single string value
result[key] = []string{string(v.(lua.LString))}
case lua.LTTable:
// Array of strings
var values []string
v.(*lua.LTable).ForEach(func(_, item lua.LValue) {
if item.Type() == lua.LTString {
values = append(values, string(item.(lua.LString)))
}
})
result[key] = values
}
})
return result
}

190
internal/script/script.go Normal file
View File

@@ -0,0 +1,190 @@
package script
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// RequestData represents the request data passed to scripts for transformation.
// Scripts can modify any field and the changes will be applied to the actual request.
// Headers, Params, and Cookies use []string values to support multiple values per key.
type RequestData struct {
Method string `json:"method"`
URL string `json:"url"`
Path string `json:"path"`
Headers map[string][]string `json:"headers"`
Params map[string][]string `json:"params"`
Cookies map[string][]string `json:"cookies"`
Body string `json:"body"`
}
// Engine defines the interface for script engines (Lua, JavaScript).
// Each engine must be able to transform request data using a user-provided script.
type Engine interface {
// Transform executes the script's transform function with the given request data.
// The script should modify the RequestData and return it.
Transform(req *RequestData) error
// Close releases any resources held by the engine.
Close()
}
// EngineType represents the type of script engine.
type EngineType string
const (
EngineTypeLua EngineType = "lua"
EngineTypeJavaScript EngineType = "js"
)
// Source represents a loaded script source.
type Source struct {
Content string
EngineType EngineType
}
// LoadSource loads a script from the given source string.
// The source can be:
// - Inline script: any string not starting with "@"
// - Escaped "@": strings starting with "@@" (literal "@" at start, returns string without first @)
// - File reference: "@/path/to/file" or "@./relative/path"
// - URL reference: "@http://..." or "@https://..."
func LoadSource(ctx context.Context, source string, engineType EngineType) (*Source, error) {
if source == "" {
return nil, errors.New("script source cannot be empty")
}
var content string
var err error
switch {
case strings.HasPrefix(source, "@@"):
// Escaped @ - it's an inline script starting with literal @
content = source[1:] // Remove first @, keep the rest
case strings.HasPrefix(source, "@"):
// File or URL reference
ref := source[1:]
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
content, err = fetchURL(ctx, ref)
} else {
content, err = readFile(ref)
}
if err != nil {
return nil, fmt.Errorf("failed to load script from %q: %w", ref, err)
}
default:
// Inline script
content = source
}
return &Source{
Content: content,
EngineType: engineType,
}, nil
}
// LoadSources loads multiple script sources.
func LoadSources(ctx context.Context, sources []string, engineType EngineType) ([]*Source, error) {
loaded := make([]*Source, 0, len(sources))
for i, src := range sources {
source, err := LoadSource(ctx, src, engineType)
if err != nil {
return nil, fmt.Errorf("script[%d]: %w", i, err)
}
loaded = append(loaded, source)
}
return loaded, nil
}
// ValidateScript validates a script source by loading it and checking syntax.
// It loads the script (from file/URL/inline), parses it, and verifies
// that a 'transform' function is defined.
func ValidateScript(ctx context.Context, source string, engineType EngineType) error {
// Load the script source
src, err := LoadSource(ctx, source, engineType)
if err != nil {
return err
}
// Try to create an engine - this validates syntax and transform function
var engine Engine
switch engineType {
case EngineTypeLua:
engine, err = NewLuaEngine(src.Content)
case EngineTypeJavaScript:
engine, err = NewJsEngine(src.Content)
default:
return fmt.Errorf("unknown engine type: %s", engineType)
}
if err != nil {
return err
}
// Clean up the engine - we only needed it for validation
engine.Close()
return nil
}
// ValidateScripts validates multiple script sources.
func ValidateScripts(ctx context.Context, sources []string, engineType EngineType) error {
for i, src := range sources {
if err := ValidateScript(ctx, src, engineType); err != nil {
return fmt.Errorf("script[%d]: %w", i, err)
}
}
return nil
}
// fetchURL downloads content from an HTTP/HTTPS URL.
func fetchURL(ctx context.Context, url string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch: %w", err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(data), nil
}
// readFile reads content from a local file.
func readFile(path string) (string, error) {
if !filepath.IsAbs(path) {
pwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get working directory: %w", err)
}
path = filepath.Join(pwd, path)
}
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return "", fmt.Errorf("failed to read file: %w", err)
}
return string(data), nil
}