mirror of
https://github.com/aykhans/sarin.git
synced 2026-02-28 23:09:13 +00:00
Compare commits
19 Commits
v1.0.0
...
6a713ef241
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a713ef241 | |||
| 6dafc082ed | |||
| e83eacf380 | |||
| c2ba1844ab | |||
|
|
054e5fd253 | ||
| 533ced4b54 | |||
| c3ea3a34ad | |||
|
|
c02a079d2a | ||
|
|
f78942bfb6 | ||
| 1369cb9f09 | |||
| 18662e6a64 | |||
| 81f08edc8d | |||
| a9738c0a11 | |||
| 76225884e6 | |||
| a512f3605d | |||
|
|
635c33008b | ||
| 3f2147ec6c | |||
| 92d0c5e003 | |||
| 27bc8f2e96 |
4
.github/workflows/lint.yaml
vendored
4
.github/workflows/lint.yaml
vendored
@@ -16,8 +16,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: 1.25.5
|
go-version: 1.25.7
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.7.2
|
version: v2.8.0
|
||||||
|
|||||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
|
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
|
||||||
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||||
echo "GO_VERSION=1.25.5" >> $GITHUB_ENV
|
echo "GO_VERSION=1.25.7" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
if: github.event_name == 'release' || inputs.build_binaries
|
if: github.event_name == 'release' || inputs.build_binaries
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ linters:
|
|||||||
- errorlint
|
- errorlint
|
||||||
- exptostd
|
- exptostd
|
||||||
- fatcontext
|
- fatcontext
|
||||||
- forcetypeassert
|
|
||||||
- funcorder
|
- funcorder
|
||||||
- gocheckcompilerdirectives
|
- gocheckcompilerdirectives
|
||||||
- gocritic
|
- gocritic
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
ARG GO_VERSION=1.25.5
|
ARG GO_VERSION=1.25.7
|
||||||
|
|
||||||
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
||||||
|
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -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.
|
||||||
|
|
||||||
|
[](https://pkg.go.dev/go.aykhans.me/sarin)
|
||||||
|
[](https://goreportcard.com/report/go.aykhans.me/sarin)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||

|

|
||||||
@@ -18,13 +22,13 @@
|
|||||||
|
|
||||||
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused.
|
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused.
|
||||||
|
|
||||||
| ✅ Supported | ❌ Not Supported |
|
| ✅ Supported | ❌ Not Supported |
|
||||||
| ---------------------------------------------------- | --------------------------------- |
|
| ---------------------------------------------------------- | --------------------------------- |
|
||||||
| High-performance with low memory footprint | Detailed response body analysis |
|
| High-performance with low memory footprint | Detailed response body analysis |
|
||||||
| Long-running duration/count based tests | Extensive response statistics |
|
| Long-running duration/count based tests | Extensive response statistics |
|
||||||
| Dynamic requests via 320+ template functions | Web UI or complex TUI |
|
| Dynamic requests via 320+ template functions | Web UI or complex TUI |
|
||||||
| Multiple proxy protocols (HTTP/HTTPS/SOCKS5/SOCKS5H) | Scripting or multi-step scenarios |
|
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | Scripting or multi-step scenarios |
|
||||||
| Flexible config (CLI, ENV, YAML) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
| Flexible config (CLI, ENV, YAML) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -74,18 +78,6 @@ Send 10,000 GET requests with 50 concurrent connections and a random User-Agent
|
|||||||
sarin -U http://example.com -r 10_000 -c 50 -H "User-Agent: {{ fakeit_UserAgent }}"
|
sarin -U http://example.com -r 10_000 -c 50 -H "User-Agent: {{ fakeit_UserAgent }}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Example output:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────┬───────┬──────────┬───────────┬─────────┬───────────┬──────────┬───────────┐
|
|
||||||
│ Response │ Count │ Min │ Max │ Average │ P90 │ P95 │ P99 │
|
|
||||||
├──────────┼───────┼──────────┼───────────┼─────────┼───────────┼──────────┼───────────┤
|
|
||||||
│ 200 │ 10000 │ 78.038ms │ 288.153ms │ 94.71ms │ 103.078ms │ 131.08ms │ 269.218ms │
|
|
||||||
├──────────┼───────┼──────────┼───────────┼─────────┼───────────┼──────────┼───────────┤
|
|
||||||
│ Total │ 10000 │ 78.038ms │ 288.153ms │ 94.71ms │ 103.078ms │ 131.08ms │ 269.218ms │
|
|
||||||
└──────────┴───────┴──────────┴───────────┴─────────┴───────────┴──────────┴───────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Run a 5-minute duration-based test:
|
Run a 5-minute duration-based test:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -105,22 +97,22 @@ 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)**.
|
||||||
|
|
||||||
## Templating
|
## Templating
|
||||||
|
|
||||||
Sarin supports Go templates in methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request.
|
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request.
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com/users \
|
sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10 \
|
||||||
-V "ID={{ fakeit_UUID }}" \
|
-V "REQUEST_ID={{ fakeit_UUID }}" \
|
||||||
-H "X-Request-ID: {{ .Values.ID }}" \
|
-H "X-Request-ID: {{ .Values.REQUEST_ID }}" \
|
||||||
-B '{"id": "{{ .Values.ID }}"}'
|
-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)**.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ version: "3"
|
|||||||
|
|
||||||
vars:
|
vars:
|
||||||
BIN_DIR: ./bin
|
BIN_DIR: ./bin
|
||||||
GOLANGCI_LINT_VERSION: v2.7.2
|
GOLANGCI_LINT_VERSION: v2.8.0
|
||||||
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ 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,
|
||||||
)
|
)
|
||||||
_ = utilsErr.MustHandle(err,
|
_ = utilsErr.MustHandle(err,
|
||||||
utilsErr.OnType(func(err types.ProxyDialError) error {
|
utilsErr.OnType(func(err types.ProxyDialError) error {
|
||||||
@@ -60,6 +61,16 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
|
utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error {
|
||||||
|
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
utilsErr.OnType(func(err types.ScriptLoadError) error {
|
||||||
|
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
srn.Start(ctx)
|
srn.Start(ctx)
|
||||||
|
|||||||
@@ -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,44 +43,87 @@ 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
|
||||||
|
|
||||||
Target URL. Must be HTTP or HTTPS.
|
Target URL. Must be HTTP or HTTPS. The URL path supports [templating](templating.md), allowing dynamic path generation per request.
|
||||||
|
|
||||||
|
> **Note:** Templating is only supported in the URL path. Host and scheme must be static.
|
||||||
|
|
||||||
|
**Example with dynamic path:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/users/{{ fakeit_UUID }}/profile
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI example with dynamic path:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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:**
|
||||||
|
|
||||||
@@ -153,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:**
|
||||||
|
|
||||||
@@ -182,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:**
|
||||||
|
|
||||||
@@ -212,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:**
|
||||||
|
|
||||||
@@ -242,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:**
|
||||||
|
|
||||||
@@ -272,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`
|
||||||
|
|
||||||
|
|||||||
430
docs/examples.md
430
docs/examples.md
@@ -6,9 +6,10 @@ This guide provides practical examples for common Sarin use cases.
|
|||||||
|
|
||||||
- [Basic Usage](#basic-usage)
|
- [Basic Usage](#basic-usage)
|
||||||
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
|
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
|
||||||
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
|
|
||||||
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
|
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
|
||||||
|
- [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)
|
||||||
@@ -108,9 +109,162 @@ concurrency: 100
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## Headers, Cookies, and Parameters
|
||||||
|
|
||||||
|
**Custom headers:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com -r 1000 -c 10 \
|
||||||
|
-H "Authorization: Bearer token123" \
|
||||||
|
-H "X-Custom-Header: value"
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
headers:
|
||||||
|
Authorization: Bearer token123
|
||||||
|
X-Custom-Header: value
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Random headers from multiple values:**
|
||||||
|
|
||||||
|
> **Note:** When multiple values are provided for the same header, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point. This ensures all values are used while maintaining some randomness.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com -r 1000 -c 10 \
|
||||||
|
-H "X-Region: us-east" \
|
||||||
|
-H "X-Region: us-west" \
|
||||||
|
-H "X-Region: eu-central"
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
headers:
|
||||||
|
X-Region:
|
||||||
|
- us-east
|
||||||
|
- us-west
|
||||||
|
- eu-central
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Query parameters:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/search -r 1000 -c 10 \
|
||||||
|
-P "query=test" \
|
||||||
|
-P "limit=10"
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/search
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
params:
|
||||||
|
query: "test"
|
||||||
|
limit: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Dynamic query parameters:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/users -r 1000 -c 10 \
|
||||||
|
-P "id={{ fakeit_IntRange 1 1000 }}" \
|
||||||
|
-P "fields=name,email"
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/users
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
params:
|
||||||
|
id: "{{ fakeit_IntRange 1 1000 }}"
|
||||||
|
fields: "name,email"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Cookies:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com -r 1000 -c 10 \
|
||||||
|
-C "session_id=abc123" \
|
||||||
|
-C "user_id={{ fakeit_UUID }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
cookies:
|
||||||
|
session_id: abc123
|
||||||
|
user_id: "{{ fakeit_UUID }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Dynamic Requests with Templating
|
## Dynamic Requests with Templating
|
||||||
|
|
||||||
Generate a random User-Agent for each request:
|
**Dynamic URL paths:**
|
||||||
|
|
||||||
|
Test different resource endpoints with random IDs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U "http://example.com/users/{{ fakeit_UUID }}/profile" -r 1000 -c 10
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/users/{{ fakeit_UUID }}/profile
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
Test with random numeric IDs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U "http://example.com/products/{{ fakeit_Number 1 10000 }}" -r 1000 -c 10
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/products/{{ fakeit_Number 1 10000 }}
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Generate a random User-Agent for each request:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com -r 1000 -c 10 \
|
sarin -U http://example.com -r 1000 -c 10 \
|
||||||
@@ -206,123 +360,6 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
|
|||||||
|
|
||||||
> For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**.
|
> For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**.
|
||||||
|
|
||||||
## Headers, Cookies, and Parameters
|
|
||||||
|
|
||||||
**Custom headers:**
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sarin -U http://example.com -r 1000 -c 10 \
|
|
||||||
-H "Authorization: Bearer token123" \
|
|
||||||
-H "X-Custom-Header: value"
|
|
||||||
```
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>YAML equivalent</summary>
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
url: http://example.com
|
|
||||||
requests: 1000
|
|
||||||
concurrency: 10
|
|
||||||
headers:
|
|
||||||
Authorization: Bearer token123
|
|
||||||
X-Custom-Header: value
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
**Random headers from multiple values:**
|
|
||||||
|
|
||||||
> **Note:** When multiple values are provided for the same header, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point. This ensures all values are used while maintaining some randomness.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sarin -U http://example.com -r 1000 -c 10 \
|
|
||||||
-H "X-Region: us-east" \
|
|
||||||
-H "X-Region: us-west" \
|
|
||||||
-H "X-Region: eu-central"
|
|
||||||
```
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>YAML equivalent</summary>
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
url: http://example.com
|
|
||||||
requests: 1000
|
|
||||||
concurrency: 10
|
|
||||||
headers:
|
|
||||||
X-Region:
|
|
||||||
- us-east
|
|
||||||
- us-west
|
|
||||||
- eu-central
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
**Query parameters:**
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sarin -U http://example.com/search -r 1000 -c 10 \
|
|
||||||
-P "query=test" \
|
|
||||||
-P "limit=10"
|
|
||||||
```
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>YAML equivalent</summary>
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
url: http://example.com/search
|
|
||||||
requests: 1000
|
|
||||||
concurrency: 10
|
|
||||||
params:
|
|
||||||
query: test
|
|
||||||
limit: "10"
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
**Dynamic query parameters:**
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sarin -U http://example.com/users -r 1000 -c 10 \
|
|
||||||
-P "id={{ fakeit_IntRange 1 1000 }}" \
|
|
||||||
-P "fields=name,email"
|
|
||||||
```
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>YAML equivalent</summary>
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
url: http://example.com/users
|
|
||||||
requests: 1000
|
|
||||||
concurrency: 10
|
|
||||||
params:
|
|
||||||
id: "{{ fakeit_IntRange 1 1000 }}"
|
|
||||||
fields: name,email
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
**Cookies:**
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sarin -U http://example.com -r 1000 -c 10 \
|
|
||||||
-C "session_id=abc123" \
|
|
||||||
-C "user_id={{ fakeit_UUID }}"
|
|
||||||
```
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>YAML equivalent</summary>
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
url: http://example.com
|
|
||||||
requests: 1000
|
|
||||||
concurrency: 10
|
|
||||||
cookies:
|
|
||||||
session_id: abc123
|
|
||||||
user_id: "{{ fakeit_UUID }}"
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Request Bodies
|
## Request Bodies
|
||||||
|
|
||||||
**Simple JSON body:**
|
**Simple JSON body:**
|
||||||
@@ -420,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>
|
||||||
@@ -431,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>
|
||||||
@@ -441,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>
|
||||||
@@ -452,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:**
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Templating
|
# Templating
|
||||||
|
|
||||||
Sarin supports Go templates in methods, bodies, headers, params, cookies, and values.
|
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values.
|
||||||
|
|
||||||
|
> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ Sarin supports Go templates in methods, bodies, headers, params, cookies, and va
|
|||||||
- [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)
|
||||||
@@ -108,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
|
||||||
|
|
||||||
@@ -530,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"` |
|
||||||
|
|||||||
22
go.mod
22
go.mod
@@ -1,19 +1,21 @@
|
|||||||
module go.aykhans.me/sarin
|
module go.aykhans.me/sarin
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.0
|
github.com/brianvoe/gofakeit/v7 v7.14.0
|
||||||
github.com/charmbracelet/bubbles v0.21.0
|
github.com/charmbracelet/bubbles v0.21.1
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
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 (
|
||||||
@@ -23,15 +25,17 @@ require (
|
|||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
github.com/charmbracelet/x/ansi v0.11.5 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.6.2 // indirect
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|||||||
48
go.sum
48
go.sum
@@ -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=
|
||||||
@@ -8,14 +10,14 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
|||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
|
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
@@ -26,28 +28,34 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG
|
|||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
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=
|
||||||
|
|||||||
@@ -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...)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,57 @@ 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://..."
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ErrScriptSourceEmpty
|
||||||
|
// - types.ErrScriptURLNoHost
|
||||||
|
// - types.URLParseError
|
||||||
|
func validateScriptSource(script string) error {
|
||||||
|
// Empty script is invalid
|
||||||
|
if script == "" {
|
||||||
|
return types.ErrScriptEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 types.ErrScriptSourceEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a http(s) URL
|
||||||
|
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
|
||||||
|
parsedURL, err := url.Parse(source)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewURLParseError(source, err)
|
||||||
|
}
|
||||||
|
if parsedURL.Host == "" {
|
||||||
|
return types.ErrScriptURLNoHost
|
||||||
|
}
|
||||||
|
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 == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ func (parser ConfigFileParser) Parse() (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetchFile retrieves file contents from a local path or HTTP/HTTPS URL.
|
// fetchFile retrieves file contents from a local path or HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
func fetchFile(ctx context.Context, src string) ([]byte, error) {
|
func fetchFile(ctx context.Context, src string) ([]byte, error) {
|
||||||
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
|
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
|
||||||
return fetchHTTP(ctx, src)
|
return fetchHTTP(ctx, src)
|
||||||
@@ -57,25 +61,28 @@ func fetchFile(ctx context.Context, src string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetchHTTP downloads file contents from an HTTP/HTTPS URL.
|
// fetchHTTP downloads file contents from an HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch file: %w", err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close() //nolint:errcheck
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("failed to fetch file: HTTP %d %s", resp.StatusCode, resp.Status)
|
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
@@ -83,19 +90,21 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
|||||||
|
|
||||||
// fetchLocal reads file contents from the local filesystem.
|
// fetchLocal reads file contents from the local filesystem.
|
||||||
// It resolves relative paths from the current working directory.
|
// It resolves relative paths from the current working directory.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
func fetchLocal(src string) ([]byte, error) {
|
func fetchLocal(src string) ([]byte, error) {
|
||||||
path := src
|
path := src
|
||||||
if !filepath.IsAbs(src) {
|
if !filepath.IsAbs(src) {
|
||||||
pwd, err := os.Getwd()
|
pwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get working directory: %w", err)
|
return nil, types.NewFileReadError(src, err)
|
||||||
}
|
}
|
||||||
path = filepath.Join(pwd, src)
|
path = filepath.Join(pwd, src)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec
|
data, err := os.ReadFile(path) //nolint:gosec
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
return nil, types.NewFileReadError(path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
@@ -202,6 +211,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 +257,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 {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.TemplateParseError
|
||||||
func validateTemplateString(value string, funcMap template.FuncMap) error {
|
func validateTemplateString(value string, funcMap template.FuncMap) error {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -15,7 +17,7 @@ func validateTemplateString(value string, funcMap template.FuncMap) error {
|
|||||||
|
|
||||||
_, err := template.New("").Funcs(funcMap).Parse(value)
|
_, err := template.New("").Funcs(funcMap).Parse(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("template parse error: %w", err)
|
return types.NewTemplateParseError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -180,16 +182,31 @@ func validateTemplateValues(values []string, funcMap template.FuncMap) []types.F
|
|||||||
return validationErrors
|
return validationErrors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.FieldValidationError {
|
||||||
|
if err := validateTemplateString(urlPath, funcMap); err != nil {
|
||||||
|
return []types.FieldValidationError{
|
||||||
|
types.NewFieldValidationError("URL.Path", urlPath, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
// Validate URL path
|
||||||
|
if config.URL != nil {
|
||||||
|
allErrors = append(allErrors, validateTemplateURLPath(config.URL.Path, funcMap)...)
|
||||||
|
}
|
||||||
|
|
||||||
// Validate methods
|
// Validate methods
|
||||||
allErrors = append(allErrors, validateTemplateMethods(config.Methods, funcMap)...)
|
allErrors = append(allErrors, validateTemplateMethods(config.Methods, funcMap)...)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -95,6 +94,9 @@ func NewHostClients(
|
|||||||
return []*fasthttp.HostClient{client}, nil
|
return []*fasthttp.HostClient{client}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewProxyDialFunc creates a dial function for the given proxy URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ProxyUnsupportedSchemeError
|
||||||
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
||||||
var (
|
var (
|
||||||
dialer fasthttp.DialFunc
|
dialer fasthttp.DialFunc
|
||||||
@@ -117,16 +119,14 @@ func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Durat
|
|||||||
case "https":
|
case "https":
|
||||||
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
|
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
|
||||||
default:
|
default:
|
||||||
return nil, errors.New("unsupported proxy scheme")
|
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
|
||||||
}
|
|
||||||
|
|
||||||
if dialer == nil {
|
|
||||||
return nil, errors.New("internal error: proxy dialer is nil")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return dialer, nil
|
return dialer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The returned dial function can return the following errors:
|
||||||
|
// - types.ProxyDialError
|
||||||
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
|
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
|
||||||
netDialer := &net.Dialer{}
|
netDialer := &net.Dialer{}
|
||||||
|
|
||||||
@@ -147,12 +147,18 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxyStr := proxyURL.String()
|
||||||
|
|
||||||
// Assert to ContextDialer for timeout support
|
// Assert to ContextDialer for timeout support
|
||||||
contextDialer, ok := socksDialer.(proxy.ContextDialer)
|
contextDialer, ok := socksDialer.(proxy.ContextDialer)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Fallback without timeout (should not happen with net.Dialer)
|
// Fallback without timeout (should not happen with net.Dialer)
|
||||||
return func(addr string) (net.Conn, error) {
|
return func(addr string) (net.Conn, error) {
|
||||||
return socksDialer.Dial("tcp", addr)
|
conn, err := socksDialer.Dial("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +169,7 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
if resolveLocally {
|
if resolveLocally {
|
||||||
host, port, err := net.SplitHostPort(addr)
|
host, port, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap DNS resolution to half the timeout to reserve time for dial
|
// Cap DNS resolution to half the timeout to reserve time for dial
|
||||||
@@ -171,10 +177,10 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
||||||
dnsCancel()
|
dnsCancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
return nil, errors.New("no IP addresses found for host: " + host)
|
return nil, types.NewProxyDialError(proxyStr, types.NewProxyResolveError(host))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the first resolved IP
|
// Use the first resolved IP
|
||||||
@@ -184,16 +190,22 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
// Use remaining time for dial
|
// Use remaining time for dial
|
||||||
remaining := time.Until(deadline)
|
remaining := time.Until(deadline)
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
return nil, context.DeadlineExceeded
|
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||||
}
|
}
|
||||||
|
|
||||||
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
||||||
defer dialCancel()
|
defer dialCancel()
|
||||||
|
|
||||||
return contextDialer.DialContext(dialCtx, "tcp", addr)
|
conn, err := contextDialer.DialContext(dialCtx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The returned dial function can return the following errors:
|
||||||
|
// - types.ProxyDialError
|
||||||
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
|
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
|
||||||
proxyAddr := proxyURL.Host
|
proxyAddr := proxyURL.Host
|
||||||
if proxyURL.Port() == "" {
|
if proxyURL.Port() == "" {
|
||||||
@@ -209,24 +221,26 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
proxyAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
proxyAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxyStr := proxyURL.String()
|
||||||
|
|
||||||
return func(addr string) (net.Conn, error) {
|
return func(addr string) (net.Conn, error) {
|
||||||
// Establish TCP connection to proxy with timeout
|
// Establish TCP connection to proxy with timeout
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining := timeout - time.Since(start)
|
remaining := timeout - time.Since(start)
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
conn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, context.DeadlineExceeded
|
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set deadline for the TLS handshake and CONNECT request
|
// Set deadline for the TLS handshake and CONNECT request
|
||||||
if err := conn.SetDeadline(time.Now().Add(remaining)); err != nil {
|
if err := conn.SetDeadline(time.Now().Add(remaining)); err != nil {
|
||||||
conn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade to TLS
|
// Upgrade to TLS
|
||||||
@@ -235,7 +249,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
})
|
})
|
||||||
if err := tlsConn.Handshake(); err != nil {
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and send CONNECT request
|
// Build and send CONNECT request
|
||||||
@@ -251,7 +265,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
|
|
||||||
if err := connectReq.Write(tlsConn); err != nil {
|
if err := connectReq.Write(tlsConn); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read response using buffered reader, but return wrapped connection
|
// Read response using buffered reader, but return wrapped connection
|
||||||
@@ -260,19 +274,19 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
resp, err := http.ReadResponse(bufReader, connectReq)
|
resp, err := http.ReadResponse(bufReader, connectReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
resp.Body.Close() //nolint:errcheck,gosec
|
resp.Body.Close() //nolint:errcheck,gosec
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, errors.New("proxy CONNECT failed: " + resp.Status)
|
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear deadline for the tunneled connection
|
// Clear deadline for the tunneled connection
|
||||||
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
|
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return wrapped connection that uses the buffered reader
|
// Return wrapped connection that uses the buffered reader
|
||||||
|
|||||||
114
internal/sarin/filecache.go
Normal file
114
internal/sarin/filecache.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package sarin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLocalFile reads a file from the local filesystem and returns its content and filename.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
|
func (fc *FileCache) readLocalFile(filePath string) ([]byte, string, error) {
|
||||||
|
content, err := os.ReadFile(filePath) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", types.NewFileReadError(filePath, err)
|
||||||
|
}
|
||||||
|
return content, filepath.Base(filePath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchURL downloads file contents from an HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
|
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, "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", types.NewHTTPFetchError(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
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package sarin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
|
||||||
"maps"
|
"maps"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -11,13 +10,14 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RequestGenerator func(*fasthttp.Request) error
|
type RequestGenerator func(*fasthttp.Request) error
|
||||||
|
|
||||||
type RequestGeneratorWithData func(*fasthttp.Request, any) error
|
type requestDataGenerator func(*script.RequestData, any) error
|
||||||
|
|
||||||
type valuesData struct {
|
type valuesData struct {
|
||||||
Values map[string]string
|
Values map[string]string
|
||||||
@@ -26,6 +26,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,110 +37,183 @@ 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)
|
||||||
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
|
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
|
||||||
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, templateFuncMap)
|
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, templateFuncMap)
|
||||||
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, templateFuncMap)
|
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, templateFuncMap)
|
||||||
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)
|
||||||
|
|
||||||
return func(req *fasthttp.Request) error {
|
hasScripts := scriptTransformer != nil && !scriptTransformer.IsEmpty()
|
||||||
req.SetRequestURI(requestURL.Path)
|
|
||||||
req.Header.SetHost(requestURL.Host)
|
|
||||||
|
|
||||||
data, err := valuesGenerator()
|
host := requestURL.Host
|
||||||
|
scheme := requestURL.Scheme
|
||||||
|
|
||||||
|
reqData := &script.RequestData{
|
||||||
|
Headers: make(map[string][]string),
|
||||||
|
Params: make(map[string][]string),
|
||||||
|
Cookies: make(map[string][]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
data valuesData
|
||||||
|
path string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
return func(req *fasthttp.Request) error {
|
||||||
|
resetRequestData(reqData)
|
||||||
|
|
||||||
|
data, err = valuesGenerator()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := methodGenerator(req, data); err != nil {
|
path, err = pathGenerator(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reqData.Path = path
|
||||||
|
|
||||||
|
if err = methodGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyTemplateFuncMapData.ClearFormDataContenType()
|
bodyTemplateFuncMapData.ClearFormDataContenType()
|
||||||
if err := bodyGenerator(req, data); err != nil {
|
if err = bodyGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := headersGenerator(req, data); err != nil {
|
if err = headersGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
|
if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
|
||||||
req.Header.Add("Content-Type", bodyTemplateFuncMapData.GetFormDataContenType())
|
reqData.Headers["Content-Type"] = append(reqData.Headers["Content-Type"], bodyTemplateFuncMapData.GetFormDataContenType())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := paramsGenerator(req, data); err != nil {
|
if err = paramsGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := cookiesGenerator(req, data); err != nil {
|
if err = cookiesGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if requestURL.Scheme == "https" {
|
if hasScripts {
|
||||||
req.URI().SetScheme("https")
|
if err = scriptTransformer.Transform(reqData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyRequestDataToFastHTTP(reqData, req, host, scheme)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}, isMethodGeneratorDynamic ||
|
}, isPathGeneratorDynamic ||
|
||||||
|
isMethodGeneratorDynamic ||
|
||||||
isParamsGeneratorDynamic ||
|
isParamsGeneratorDynamic ||
|
||||||
isHeadersGeneratorDynamic ||
|
isHeadersGeneratorDynamic ||
|
||||||
isCookiesGeneratorDynamic ||
|
isCookiesGeneratorDynamic ||
|
||||||
isBodyGeneratorDynamic
|
isBodyGeneratorDynamic ||
|
||||||
|
hasScripts
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func resetRequestData(reqData *script.RequestData) {
|
||||||
|
reqData.Method = ""
|
||||||
|
reqData.Path = ""
|
||||||
|
reqData.Body = ""
|
||||||
|
clear(reqData.Headers)
|
||||||
|
clear(reqData.Params)
|
||||||
|
clear(reqData.Cookies)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) {
|
||||||
|
req.Header.SetHost(host)
|
||||||
|
req.SetRequestURI(reqData.Path)
|
||||||
|
req.Header.SetMethod(reqData.Method)
|
||||||
|
req.SetBody([]byte(reqData.Body))
|
||||||
|
|
||||||
|
for k, values := range reqData.Headers {
|
||||||
|
for _, v := range values {
|
||||||
|
req.Header.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, values := range reqData.Params {
|
||||||
|
for _, v := range values {
|
||||||
|
req.URI().QueryArgs().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reqData.Cookies) > 0 {
|
||||||
|
cookieStrings := make([]string, 0, len(reqData.Cookies))
|
||||||
|
for k, values := range reqData.Cookies {
|
||||||
|
for _, v := range values {
|
||||||
|
cookieStrings = append(cookieStrings, k+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if scheme == "https" {
|
||||||
|
req.URI().SetScheme("https")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, templateFunctions)
|
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
method string
|
method string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
method, err = methodGenerator()(data)
|
method, err = methodGenerator()(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.SetMethod(method)
|
reqData.Method = method
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, templateFunctions)
|
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
body string
|
body string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
body, err = bodyGenerator()(data)
|
body, err = bodyGenerator()(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.SetBody([]byte(body))
|
reqData.Body = body
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, params, templateFunctions)
|
generators, isDynamic := buildKeyValueGenerators(localRand, params, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
for _, gen := range generators {
|
for _, gen := range generators {
|
||||||
key, err = gen.Key(data)
|
key, err = gen.Key(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -149,20 +225,20 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateF
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.URI().QueryArgs().Add(key, value)
|
reqData.Params[key] = append(reqData.Params[key], value)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, headers, templateFunctions)
|
generators, isDynamic := buildKeyValueGenerators(localRand, headers, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
for _, gen := range generators {
|
for _, gen := range generators {
|
||||||
key, err = gen.Key(data)
|
key, err = gen.Key(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,41 +250,33 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templa
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Add(key, value)
|
reqData.Headers[key] = append(reqData.Headers[key], value)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, templateFunctions)
|
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
if len(generators) > 0 {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
return func(req *fasthttp.Request, data any) error {
|
for _, gen := range generators {
|
||||||
cookieStrings := make([]string, 0, len(generators))
|
key, err = gen.Key(data)
|
||||||
for _, gen := range generators {
|
if err != nil {
|
||||||
key, err = gen.Key(data)
|
return err
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err = gen.Value()(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieStrings = append(cookieStrings, key+"="+value)
|
|
||||||
}
|
}
|
||||||
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
|
|
||||||
return nil
|
|
||||||
}, isDynamic
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(req *fasthttp.Request, data any) error {
|
value, err = gen.Value()(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reqData.Cookies[key] = append(reqData.Cookies[key], value)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
@@ -230,12 +298,12 @@ func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap)
|
|||||||
for _, generator := range generators {
|
for _, generator := range generators {
|
||||||
rendered, err = generator(nil)
|
rendered, err = generator(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return valuesData{}, fmt.Errorf("values rendering: %w", err)
|
return valuesData{}, types.NewTemplateRenderError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err = godotenv.Unmarshal(rendered)
|
data, err = godotenv.Unmarshal(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return valuesData{}, fmt.Errorf("values rendering: %w", err)
|
return valuesData{}, types.NewTemplateRenderError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
maps.Copy(result, data)
|
maps.Copy(result, data)
|
||||||
@@ -252,7 +320,7 @@ func createTemplateFunc(value string, templateFunctions template.FuncMap) (func(
|
|||||||
return func(data any) (string, error) {
|
return func(data any) (string, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err = tmpl.Execute(&buf, data); err != nil {
|
if err = tmpl.Execute(&buf, data); err != nil {
|
||||||
return "", fmt.Errorf("template rendering: %w", err)
|
return "", types.NewTemplateRenderError(err)
|
||||||
}
|
}
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}, true
|
}, true
|
||||||
|
|||||||
@@ -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,15 @@ 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
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ScriptLoadError
|
||||||
func NewSarin(
|
func NewSarin(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
methods []string,
|
methods []string,
|
||||||
@@ -74,6 +79,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 +91,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 +121,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 +213,21 @@ 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() {
|
||||||
|
var err error
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sarin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,9 +11,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/brianvoe/gofakeit/v7"
|
"github.com/brianvoe/gofakeit/v7"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
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 "", types.ErrFileCacheNotInitialized
|
||||||
|
}
|
||||||
|
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 "", types.ErrFormDataOddArgs
|
||||||
|
}
|
||||||
|
|
||||||
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 "", types.ErrFileCacheNotInitialized
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
107
internal/script/chain.go
Normal file
107
internal/script/chain.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptChainError
|
||||||
|
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, types.NewScriptChainError("lua", 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, types.NewScriptChainError("js", 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.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptChainError
|
||||||
|
func (t *Transformer) Transform(req *RequestData) error {
|
||||||
|
// Run Lua scripts
|
||||||
|
for i, engine := range t.luaEngines {
|
||||||
|
if err := engine.Transform(req); err != nil {
|
||||||
|
return types.NewScriptChainError("lua", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run JS scripts
|
||||||
|
for i, engine := range t.jsEngines {
|
||||||
|
if err := engine.Transform(req); err != nil {
|
||||||
|
return types.NewScriptChainError("js", 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
|
||||||
|
}
|
||||||
198
internal/script/js.go
Normal file
198
internal/script/js.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptTransformMissing
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
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, types.NewScriptExecutionError("JavaScript", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transform function
|
||||||
|
transformVal := vm.Get("transform")
|
||||||
|
if transformVal == nil || goja.IsUndefined(transformVal) || goja.IsNull(transformVal) {
|
||||||
|
return nil, types.ErrScriptTransformMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
transform, ok := goja.AssertFunction(transformVal)
|
||||||
|
if !ok {
|
||||||
|
return nil, types.NewScriptExecutionError("JavaScript", 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.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
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 types.NewScriptExecutionError("JavaScript", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update RequestData from the returned object
|
||||||
|
if err := e.objectToRequestData(result, req); err != nil {
|
||||||
|
return types.NewScriptExecutionError("JavaScript", 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("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 types.ErrScriptTransformReturnObject
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := val.ToObject(e.runtime)
|
||||||
|
if obj == nil {
|
||||||
|
return types.ErrScriptTransformReturnObject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method
|
||||||
|
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) {
|
||||||
|
req.Method = 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([]any, 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().([]any); 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
191
internal/script/lua.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptTransformMissing
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
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, types.NewScriptExecutionError("Lua", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transform function
|
||||||
|
transform := L.GetGlobal("transform")
|
||||||
|
if transform.Type() != lua.LTFunction {
|
||||||
|
L.Close()
|
||||||
|
return nil, types.ErrScriptTransformMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LuaEngine{
|
||||||
|
state: L,
|
||||||
|
transform: transform.(*lua.LFunction),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform executes the Lua transform function with the given request data.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
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 types.NewScriptExecutionError("Lua", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the result
|
||||||
|
result := e.state.Get(-1)
|
||||||
|
e.state.Pop(1)
|
||||||
|
|
||||||
|
if result.Type() != lua.LTTable {
|
||||||
|
return types.NewScriptExecutionError("Lua", 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("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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
213
internal/script/script.go
Normal file
213
internal/script/script.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
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://..."
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ScriptLoadError
|
||||||
|
func LoadSource(ctx context.Context, source string, engineType EngineType) (*Source, error) {
|
||||||
|
if source == "" {
|
||||||
|
return nil, types.ErrScriptEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
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, types.NewScriptLoadError(ref, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Inline script
|
||||||
|
content = source
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Source{
|
||||||
|
Content: content,
|
||||||
|
EngineType: engineType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSources loads multiple script sources.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ScriptLoadError
|
||||||
|
func LoadSources(ctx context.Context, sources []string, engineType EngineType) ([]*Source, error) {
|
||||||
|
loaded := make([]*Source, 0, len(sources))
|
||||||
|
for _, src := range sources {
|
||||||
|
source, err := LoadSource(ctx, src, engineType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 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.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ErrScriptTransformMissing
|
||||||
|
// - types.ScriptLoadError
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
// - types.ScriptUnknownEngineError
|
||||||
|
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 types.NewScriptUnknownEngineError(string(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.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ErrScriptTransformMissing
|
||||||
|
// - types.ScriptLoadError
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
// - types.ScriptUnknownEngineError
|
||||||
|
func ValidateScripts(ctx context.Context, sources []string, engineType EngineType) error {
|
||||||
|
for _, src := range sources {
|
||||||
|
if err := ValidateScript(ctx, src, engineType); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchURL downloads content from an HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
|
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 "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFile reads content from a local file.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
|
func readFile(path string) (string, error) {
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
pwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewFileReadError(path, err)
|
||||||
|
}
|
||||||
|
path = filepath.Join(pwd, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewFileReadError(path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
@@ -6,16 +6,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
// General
|
|
||||||
ErrNoError = errors.New("no error (internal)")
|
|
||||||
|
|
||||||
// CLI
|
|
||||||
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
|
|
||||||
)
|
|
||||||
|
|
||||||
// ======================================== General ========================================
|
// ======================================== General ========================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoError = errors.New("no error (internal)")
|
||||||
|
)
|
||||||
|
|
||||||
type FieldParseError struct {
|
type FieldParseError struct {
|
||||||
Field string
|
Field string
|
||||||
Value string
|
Value string
|
||||||
@@ -131,8 +127,147 @@ func (e UnmarshalError) Unwrap() error {
|
|||||||
return e.error
|
return e.error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================================== General I/O ========================================
|
||||||
|
|
||||||
|
type FileReadError struct {
|
||||||
|
Path string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileReadError(path string, err error) FileReadError {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrNoError
|
||||||
|
}
|
||||||
|
return FileReadError{path, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e FileReadError) Error() string {
|
||||||
|
return fmt.Sprintf("failed to read file %s: %v", e.Path, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e FileReadError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPFetchError struct {
|
||||||
|
URL string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPFetchError(url string, err error) HTTPFetchError {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrNoError
|
||||||
|
}
|
||||||
|
return HTTPFetchError{url, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e HTTPFetchError) Error() string {
|
||||||
|
return fmt.Sprintf("failed to fetch %s: %v", e.URL, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e HTTPFetchError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPStatusError struct {
|
||||||
|
URL string
|
||||||
|
StatusCode int
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPStatusError(url string, statusCode int, status string) HTTPStatusError {
|
||||||
|
return HTTPStatusError{url, statusCode, status}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e HTTPStatusError) Error() string {
|
||||||
|
return fmt.Sprintf("HTTP %d %s (url: %s)", e.StatusCode, e.Status, e.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
type URLParseError struct {
|
||||||
|
URL string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewURLParseError(url string, err error) URLParseError {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrNoError
|
||||||
|
}
|
||||||
|
return URLParseError{url, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e URLParseError) Error() string {
|
||||||
|
return fmt.Sprintf("invalid URL %q: %v", e.URL, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e URLParseError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================== Template ========================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrFileCacheNotInitialized = errors.New("file cache is not initialized")
|
||||||
|
ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)")
|
||||||
|
)
|
||||||
|
|
||||||
|
type TemplateParseError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateParseError(err error) TemplateParseError {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrNoError
|
||||||
|
}
|
||||||
|
return TemplateParseError{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateParseError) Error() string {
|
||||||
|
return "template parse error: " + e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateParseError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateRenderError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateRenderError(err error) TemplateRenderError {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrNoError
|
||||||
|
}
|
||||||
|
return TemplateRenderError{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateRenderError) Error() string {
|
||||||
|
return "template rendering: " + e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateRenderError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================== YAML ========================================
|
||||||
|
|
||||||
|
type YAMLFormatError struct {
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewYAMLFormatError(detail string) YAMLFormatError {
|
||||||
|
return YAMLFormatError{detail}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e YAMLFormatError) Error() string {
|
||||||
|
return e.Detail
|
||||||
|
}
|
||||||
|
|
||||||
// ======================================== CLI ========================================
|
// ======================================== CLI ========================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
|
||||||
|
)
|
||||||
|
|
||||||
type CLIUnexpectedArgsError struct {
|
type CLIUnexpectedArgsError struct {
|
||||||
Args []string
|
Args []string
|
||||||
}
|
}
|
||||||
@@ -168,6 +303,61 @@ func (e ConfigFileReadError) Unwrap() error {
|
|||||||
|
|
||||||
// ======================================== Proxy ========================================
|
// ======================================== Proxy ========================================
|
||||||
|
|
||||||
|
type ProxyUnsupportedSchemeError struct {
|
||||||
|
Scheme string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyUnsupportedSchemeError(scheme string) ProxyUnsupportedSchemeError {
|
||||||
|
return ProxyUnsupportedSchemeError{scheme}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyUnsupportedSchemeError) Error() string {
|
||||||
|
return "unsupported proxy scheme: " + e.Scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyParseError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyParseError(err error) ProxyParseError {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrNoError
|
||||||
|
}
|
||||||
|
return ProxyParseError{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyParseError) Error() string {
|
||||||
|
return "failed to parse proxy URL: " + e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyParseError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyConnectError struct {
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyConnectError(status string) ProxyConnectError {
|
||||||
|
return ProxyConnectError{status}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyConnectError) Error() string {
|
||||||
|
return "proxy CONNECT failed: " + e.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyResolveError struct {
|
||||||
|
Host string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyResolveError(host string) ProxyResolveError {
|
||||||
|
return ProxyResolveError{host}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyResolveError) Error() string {
|
||||||
|
return "no IP addresses found for host: " + e.Host
|
||||||
|
}
|
||||||
|
|
||||||
type ProxyDialError struct {
|
type ProxyDialError struct {
|
||||||
Proxy string
|
Proxy string
|
||||||
Err error
|
Err error
|
||||||
@@ -187,3 +377,86 @@ func (e ProxyDialError) Error() string {
|
|||||||
func (e ProxyDialError) Unwrap() error {
|
func (e ProxyDialError) Unwrap() error {
|
||||||
return e.Err
|
return e.Err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================================== Script ========================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrScriptEmpty = errors.New("script cannot be empty")
|
||||||
|
ErrScriptSourceEmpty = errors.New("script source cannot be empty after @")
|
||||||
|
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
|
||||||
|
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
|
||||||
|
ErrScriptURLNoHost = errors.New("script URL must have a host")
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScriptLoadError struct {
|
||||||
|
Source string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptLoadError(source string, err error) ScriptLoadError {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrNoError
|
||||||
|
}
|
||||||
|
return ScriptLoadError{source, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptLoadError) Error() string {
|
||||||
|
return fmt.Sprintf("failed to load script from %q: %v", e.Source, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptLoadError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptExecutionError struct {
|
||||||
|
EngineType string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptExecutionError(engineType string, err error) ScriptExecutionError {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrNoError
|
||||||
|
}
|
||||||
|
return ScriptExecutionError{engineType, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptExecutionError) Error() string {
|
||||||
|
return fmt.Sprintf("%s script error: %v", e.EngineType, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptExecutionError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptChainError struct {
|
||||||
|
EngineType string
|
||||||
|
Index int
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptChainError(engineType string, index int, err error) ScriptChainError {
|
||||||
|
if err == nil {
|
||||||
|
err = ErrNoError
|
||||||
|
}
|
||||||
|
return ScriptChainError{engineType, index, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptChainError) Error() string {
|
||||||
|
return fmt.Sprintf("%s script[%d]: %v", e.EngineType, e.Index, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptChainError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptUnknownEngineError struct {
|
||||||
|
EngineType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError {
|
||||||
|
return ScriptUnknownEngineError{engineType}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptUnknownEngineError) Error() string {
|
||||||
|
return "unknown engine type: " + e.EngineType
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +16,9 @@ func (proxies *Proxies) Append(proxy ...Proxy) {
|
|||||||
*proxies = append(*proxies, proxy...)
|
*proxies = append(*proxies, proxy...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse parses a raw proxy string and appends it to the list.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - ProxyParseError
|
||||||
func (proxies *Proxies) Parse(rawValue string) error {
|
func (proxies *Proxies) Parse(rawValue string) error {
|
||||||
parsedProxy, err := ParseProxy(rawValue)
|
parsedProxy, err := ParseProxy(rawValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,10 +29,13 @@ func (proxies *Proxies) Parse(rawValue string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseProxy parses a raw proxy URL string into a Proxy.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - ProxyParseError
|
||||||
func ParseProxy(rawValue string) (*Proxy, error) {
|
func ParseProxy(rawValue string) (*Proxy, error) {
|
||||||
urlParsed, err := url.Parse(rawValue)
|
urlParsed, err := url.Parse(rawValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse proxy URL: %w", err)
|
return nil, NewProxyParseError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyParsed := Proxy(*urlParsed)
|
proxyParsed := Proxy(*urlParsed)
|
||||||
|
|||||||
Reference in New Issue
Block a user