mirror of
https://github.com/aykhans/sarin.git
synced 2026-10-16 06:33:53 +00:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a41bdc4824
|
||
|
|
1899280590
|
||
|
|
11b147381f | ||
|
|
99e5bd4277 | ||
|
|
ad943867dc | ||
|
|
a58ef60316 | ||
|
|
5b11ef2933
|
||
|
|
3e01ce47cc
|
||
|
|
95f55e1056
|
||
|
|
824f03452e
|
||
|
|
b7c589de53
|
||
|
|
23b3d7b1cc
|
||
|
|
ab3b458f15
|
||
|
|
51e8f2a57f
|
||
|
|
a1e4c15285
|
||
|
|
bea48eeab0
|
||
|
|
cd0fa453c2
|
||
|
|
847c4b39ce
|
||
|
|
9e773a4e6c
|
||
|
|
bcf2ea3446
|
||
|
|
e85a6d4613
|
||
|
|
4a14d57d15
|
||
|
|
633f548e7b
|
||
|
|
68de415645
|
||
|
|
fb8443fd25
|
||
|
|
e0f6d4aeba
|
||
|
|
17dfa16051
|
||
|
|
a327c4978b
|
||
|
|
9d6e4e6bf8
|
||
|
|
9c7b7c26a0
|
||
|
|
ef5907dc0c
|
||
|
|
1956eb1c44
|
||
|
|
f1d91e3d77
|
||
|
|
3d535d89e3 | ||
|
|
3a23b1b370 | ||
|
|
a37885a308 | ||
|
|
412a02fea3
|
||
|
|
8da05bb10c
|
||
|
|
c32949c8e0
|
||
|
|
a30c862936
|
||
|
|
5d91141f80
|
||
|
|
878ca6dfbd
|
||
|
|
9de1d5ac93 | ||
|
|
6fc17d1e10 | ||
|
|
4518895ab2 |
No files matched your search
@@ -24,4 +24,4 @@ jobs:
|
|||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.13.2
|
version: v2.14.0
|
||||||
@@ -67,7 +67,7 @@ nix run github:aykhans/sarin/release -- -U http://example.com -r 100 -c 10
|
|||||||
Install into your profile:
|
Install into your profile:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix profile install github:aykhans/sarin/release
|
nix profile add github:aykhans/sarin/release
|
||||||
```
|
```
|
||||||
|
|
||||||
Or add it to your own flake via the overlay:
|
Or add it to your own flake via the overlay:
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ vars:
|
|||||||
BIN_DIR: ./bin
|
BIN_DIR: ./bin
|
||||||
GO_VERSION:
|
GO_VERSION:
|
||||||
sh: sed -n 's/^go //p' go.mod
|
sh: sed -n 's/^go //p' go.mod
|
||||||
GOLANGCI_LINT_VERSION: v2.13.2
|
GOLANGCI_LINT_VERSION: v2.14.0
|
||||||
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
|||||||
+3
-2
@@ -51,7 +51,8 @@ func main() {
|
|||||||
ctx,
|
ctx,
|
||||||
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
|
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
|
||||||
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
|
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
|
||||||
*combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers,
|
*combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, *combinedConfig.MaxResponseBody,
|
||||||
|
combinedConfig.Params, combinedConfig.Headers,
|
||||||
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.LogLevel, *combinedConfig.LogFile,
|
*combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile,
|
||||||
@@ -75,7 +76,7 @@ func main() {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
srn.Start(ctx, stopCtrl)
|
srn.Start(stopCtrl)
|
||||||
|
|
||||||
switch *combinedConfig.Output {
|
switch *combinedConfig.Output {
|
||||||
case config.ConfigOutputTypeNone:
|
case config.ConfigOutputTypeNone:
|
||||||
|
|||||||
+55
-31
@@ -14,32 +14,33 @@ 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 |
|
||||||
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
|
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
|
||||||
| [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) |
|
| [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) |
|
||||||
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
|
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
|
||||||
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
|
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
|
||||||
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
|
| [Max Response Body](#max-response-body) | `maxResponseBody`<br>(size) | `-max-response-body` / `-S`<br>(size) | `SARIN_MAX_RESPONSE_BODY`<br>(size) | `10MiB` | Largest response body to read |
|
||||||
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
|
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
|
||||||
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
|
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
|
||||||
| [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit |
|
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
|
||||||
| [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
|
| [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit |
|
||||||
| [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
|
| [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
|
||||||
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
|
| [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
|
||||||
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
|
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
|
||||||
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
|
||||||
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
|
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
||||||
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
|
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
|
||||||
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
|
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
|
||||||
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
|
||||||
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
|
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
||||||
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
|
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
|
||||||
| [Lua](#lua) | `lua`<br>(string / []string) | `-lua`<br>(string / []string) | `SARIN_LUA`<br>(string) | - | Lua script(s) |
|
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
|
||||||
| [Js](#js) | `js`<br>(string / []string) | `-js`<br>(string / []string) | `SARIN_JS`<br>(string) | - | JavaScript script(s) |
|
| [Lua](#lua) | `lua`<br>(string / []string) | `-lua`<br>(string / []string) | `SARIN_LUA`<br>(string) | - | Lua script(s) |
|
||||||
|
| [Js](#js) | `js`<br>(string / []string) | `-js`<br>(string / []string) | `SARIN_JS`<br>(string) | - | JavaScript script(s) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -168,6 +169,20 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
|||||||
|
|
||||||
**Examples:** `5s`, `300ms`, `1m20s`
|
**Examples:** `5s`, `300ms`, `1m20s`
|
||||||
|
|
||||||
|
## Max Response Body
|
||||||
|
|
||||||
|
Largest response body sarin reads. Defaults to `10MiB`, and `0` removes the limit.
|
||||||
|
|
||||||
|
The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1GiB`, because one response can take a few times the limit in memory.
|
||||||
|
|
||||||
|
**Examples:** `1048576`, `10MiB`, `2MB`, `512KiB`
|
||||||
|
|
||||||
|
A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `response body exceeds the body limit` and is counted like any other error.
|
||||||
|
|
||||||
|
The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. It covers the requests scripts make as well.
|
||||||
|
|
||||||
|
A compressed response in a script is decoded first, so the limit covers the decoded size as well as the bytes that arrived. Decoding holds both at once, so a brotli or zstd body peaks at over ten times the limit. Ratios are unbounded, and 106 bytes of brotli can hold 64MiB, so with `0` a tiny download can still exhaust memory.
|
||||||
|
|
||||||
## Concurrency
|
## Concurrency
|
||||||
|
|
||||||
Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`.
|
Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`.
|
||||||
@@ -188,7 +203,7 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
|||||||
|
|
||||||
Runtime log levels to emit, comma-separated. Valid levels: `info`, `error`. Defaults to `error`.
|
Runtime log levels to emit, comma-separated. Valid levels: `info`, `error`. Defaults to `error`.
|
||||||
|
|
||||||
- `error`: errors that occur while generating or sending a request
|
- `error`: errors that occur while generating a request. A request that fails to send is counted in the results instead
|
||||||
- `info`: every completed response
|
- `info`: every completed response
|
||||||
|
|
||||||
Leave empty to disable logging entirely.
|
Leave empty to disable logging entirely.
|
||||||
@@ -219,6 +234,10 @@ Using `none` disables output and reduces memory usage since response statistics
|
|||||||
|
|
||||||
Generate requests without sending them. Useful for testing templates.
|
Generate requests without sending them. Useful for testing templates.
|
||||||
|
|
||||||
|
A script's own `http` calls are still sent, since a script needs them to build the request you are inspecting. Only the requests sarin generates are held back.
|
||||||
|
|
||||||
|
The duration in the results is how long generating each request took, templates and scripts included, so it is a different measurement from the response time a real run reports. A request with neither renders once before the run and reports `0s`.
|
||||||
|
|
||||||
## Insecure
|
## Insecure
|
||||||
|
|
||||||
Skip TLS certificate verification.
|
Skip TLS certificate verification.
|
||||||
@@ -456,7 +475,7 @@ function transform(req)
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`).
|
> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`). Numbers and booleans are converted to text, other values are skipped.
|
||||||
|
|
||||||
> **Note:** Lua scripts can send their own HTTP requests with `http` and work with JSON through the global `json` table. See [HTTP Requests in Scripts](#http-requests-in-scripts).
|
> **Note:** Lua scripts can send their own HTTP requests with `http` and work with JSON through the global `json` table. See [HTTP Requests in Scripts](#http-requests-in-scripts).
|
||||||
|
|
||||||
@@ -523,7 +542,7 @@ function transform(req) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`).
|
> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`). Numbers and booleans are converted to text, other values are skipped.
|
||||||
|
|
||||||
> **Note:** JavaScript scripts can send their own HTTP requests with `http`. See [HTTP Requests in Scripts](#http-requests-in-scripts).
|
> **Note:** JavaScript scripts can send their own HTTP requests with `http`. See [HTTP Requests in Scripts](#http-requests-in-scripts).
|
||||||
|
|
||||||
@@ -580,9 +599,9 @@ http(url, opts)
|
|||||||
| `params` | table/object | - | Query parameters, added to any already in the URL |
|
| `params` | table/object | - | Query parameters, added to any already in the URL |
|
||||||
| `cookies` | table/object | - | Cookies, sent as a single `Cookie` header |
|
| `cookies` | table/object | - | Cookies, sent as a single `Cookie` header |
|
||||||
| `body` | string | `""` | Request body |
|
| `body` | string | `""` | Request body |
|
||||||
| `timeout` | duration string (`"500ms"`) | `30s` | Request timeout (the `timeout` config does not apply), applied to each hop when redirects are followed |
|
| `timeout` | duration string (`"500ms"`) | `10s` | Request timeout (the `timeout` config does not apply), applied to each hop when redirects are followed |
|
||||||
| `insecure` | boolean | `false` | Skip TLS verification (the `insecure` config does not apply) |
|
| `insecure` | boolean | `false` | Skip TLS verification (the `insecure` config does not apply) |
|
||||||
| `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none |
|
| `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none, maximum 100 |
|
||||||
|
|
||||||
Unknown options and values of the wrong type raise an error.
|
Unknown options and values of the wrong type raise an error.
|
||||||
|
|
||||||
@@ -596,15 +615,20 @@ Unknown options and values of the wrong type raise an error.
|
|||||||
| `header(name)` | First value of a header, case-insensitive; `nil`/`null` if missing |
|
| `header(name)` | First value of a header, case-insensitive; `nil`/`null` if missing |
|
||||||
| `cookie(name)` | Value of a cookie from `Set-Cookie`; `nil`/`null` if missing |
|
| `cookie(name)` | Value of a cookie from `Set-Cookie`; `nil`/`null` if missing |
|
||||||
| `json()` | JavaScript only: parses the body as JSON |
|
| `json()` | JavaScript only: parses the body as JSON |
|
||||||
|
| `bytes()` | JavaScript only: the body as a `Uint8Array` |
|
||||||
|
|
||||||
In Lua, call the helpers as `res:header("name")` or `res.header("name")`.
|
In Lua, call the helpers as `res:header("name")` or `res.header("name")`.
|
||||||
|
|
||||||
|
> **Note:** JavaScript strings hold UTF-16, so `body` replaces bytes that are not valid UTF-8 and `body.length` counts code units rather than bytes. Use `bytes()` for binary responses and for the real byte count. Lua strings keep the bytes as they arrived.
|
||||||
|
|
||||||
**Behavior:**
|
**Behavior:**
|
||||||
|
|
||||||
- **Runs on every request:** the requests are sent each time `transform` runs, so every main request triggers them. Script globals persist across requests within a worker, so a script can cache a value itself (e.g. `if not token then token = login() end`).
|
- **Runs on every request:** the requests are sent each time `transform` runs, so every main request triggers them. Script globals persist across requests within a worker, so a script can cache a value itself (e.g. `if not token then token = login() end`).
|
||||||
- **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to.
|
- **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to.
|
||||||
- **Status codes:** non-2xx responses are returned normally; check `status` in the script.
|
- **Status codes:** non-2xx responses are returned normally; check `status` in the script.
|
||||||
- **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript).
|
- **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript).
|
||||||
|
- **Response bodies:** the [Max Response Body](#max-response-body) config bounds these requests too, and a body above it raises an error the script can catch.
|
||||||
|
- **Compressed responses:** sarin asks for no encoding, so this only applies when a script sends `Accept-Encoding` itself or the server compresses unasked. A `gzip`, `deflate`, `br` or `zstd` body is then decoded before the script sees it, and `Content-Encoding` and `Content-Length` are both dropped from `headers` because neither describes what the script receives. Anything else is left raw with its header intact: an empty body, `identity`, a `Content-Encoding` sarin does not recognise, and two or more codings at once. A body that fails to decode raises an error the script can catch, though `br` carries no checksum, so damage to a brotli body can go unnoticed.
|
||||||
- **Only inside `transform`:** `http` works anywhere while `transform` runs, including in helper functions it calls. Calling it at the top level of a script raises an error, because that code also runs during config validation and when each worker starts.
|
- **Only inside `transform`:** `http` works anywhere while `transform` runs, including in helper functions it calls. Calling it at the top level of a script raises an error, because that code also runs during config validation and when each worker starts.
|
||||||
- **Dry run:** script requests are still sent in dry-run mode.
|
- **Dry run:** script requests are still sent in dry-run mode.
|
||||||
- **Not measured:** script requests are not included in the results.
|
- **Not measured:** script requests are not included in the results.
|
||||||
|
|||||||
+3
-19
@@ -857,7 +857,7 @@ progress: none
|
|||||||
|
|
||||||
## Runtime Logging
|
## Runtime Logging
|
||||||
|
|
||||||
`--log-level` selects which runtime logs Sarin emits (comma-separated `info` and `error`, default `error`). `error` covers request and generation errors, `info` covers every completed response (status, duration, headers, body). Logs appear in the progress log box on an interactive terminal, go to stderr when piped, or go to a file with `--log-file`.
|
`--log-level` selects which runtime logs Sarin emits (comma-separated `info` and `error`, default `error`). `error` covers errors raised while generating a request, `info` covers every completed response (status, duration, headers, body). A request that fails to send is counted in the results rather than logged. Logs appear in the progress log box on an interactive terminal, go to stderr when piped, or go to a file with `--log-file`.
|
||||||
|
|
||||||
**Log responses and errors:**
|
**Log responses and errors:**
|
||||||
|
|
||||||
@@ -883,9 +883,8 @@ sarin -U http://example.com -r 1000 -l info -o json > stats.json 2> run.log
|
|||||||
```yaml
|
```yaml
|
||||||
url: http://example.com
|
url: http://example.com
|
||||||
requests: 1000
|
requests: 1000
|
||||||
concurrency: 10
|
logLevel: info
|
||||||
logLevel: info,error
|
output: json
|
||||||
logFile: ./run.log
|
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -1193,21 +1192,6 @@ js: |
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Follow redirects and use a slower timeout for a login endpoint:**
|
|
||||||
|
|
||||||
```sh
|
|
||||||
sarin -U http://example.com/api/me -r 200 -c 5 \
|
|
||||||
-lua 'function transform(req)
|
|
||||||
local res = http("http://example.com/login", {
|
|
||||||
method = "POST",
|
|
||||||
maxRedirects = 5,
|
|
||||||
timeout = "10s",
|
|
||||||
})
|
|
||||||
req.cookies["session"] = res:cookie("session")
|
|
||||||
return req
|
|
||||||
end'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Keep sending the main request even when the extra call fails:**
|
**Keep sending the main request even when the extra call fails:**
|
||||||
|
|
||||||
A failed `http` call raises an error, which skips the main request and records the failure in the results. Catch it to decide yourself:
|
A failed `http` call raises an error, which skips the main request and records the failure in the results. Catch it to decide yourself:
|
||||||
|
|||||||
+8
-5
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Sarin supports Go templates in URL paths, 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.
|
> **Note:** Templating in URL host and scheme is not supported. Only the path and query string of the URL can contain templates.
|
||||||
|
|
||||||
> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take.
|
> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take. Downloads have their own limits: 30 seconds and 64MiB per file.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
|
|||||||
- [Generate](#generate)
|
- [Generate](#generate)
|
||||||
- [Auth](#auth)
|
- [Auth](#auth)
|
||||||
- [Address](#address)
|
- [Address](#address)
|
||||||
|
- [Airline](#airline)
|
||||||
- [Game](#game)
|
- [Game](#game)
|
||||||
- [Beer](#beer)
|
- [Beer](#beer)
|
||||||
- [Car](#car)
|
- [Car](#car)
|
||||||
@@ -151,7 +152,7 @@ body: '{{ json_Encode (dict_Str "key1" "value1" "key2" "value2") }}'
|
|||||||
| ------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------- |
|
| ------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||||
| `time_NowUnix` | Current Unix timestamp (seconds) | `{{ time_NowUnix }}` → `1735689600` |
|
| `time_NowUnix` | Current Unix timestamp (seconds) | `{{ time_NowUnix }}` → `1735689600` |
|
||||||
| `time_NowUnixMilli` | Current Unix timestamp (milliseconds) | `{{ time_NowUnixMilli }}` → `1735689600123` |
|
| `time_NowUnixMilli` | Current Unix timestamp (milliseconds) | `{{ time_NowUnixMilli }}` → `1735689600123` |
|
||||||
| `time_NowRFC3339` | Current time in RFC3339 format | `{{ time_NowRFC3339 }}` → `"2026-02-26T21:00:00Z"` |
|
| `time_NowRFC3339` | Current time in RFC3339 format | `{{ time_NowRFC3339 }}` → `"2026-02-26T21:00:00+04:00"` (local time) |
|
||||||
| `time_Format(layout, t)` | Format a `time.Time` value with a Go layout | `{{ time_Format "2006-01-02" (strings_ToDate "2024-05-10") }}` → `"2024-05-10"` |
|
| `time_Format(layout, t)` | Format a `time.Time` value with a Go layout | `{{ time_Format "2006-01-02" (strings_ToDate "2024-05-10") }}` → `"2024-05-10"` |
|
||||||
|
|
||||||
### Crypto Functions
|
### Crypto Functions
|
||||||
@@ -165,6 +166,8 @@ body: '{{ json_Encode (dict_Str "key1" "value1" "key2" "value2") }}'
|
|||||||
|
|
||||||
### Body Functions
|
### Body Functions
|
||||||
|
|
||||||
|
> **Note:** These are available in `body` only, not in the URL, headers, params, cookies or values.
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||||
| `body_FormData(pairs ...string)` | Create multipart form data from key-value pairs. Automatically sets the `Content-Type` header. Values starting with `@` are treated as file references (local path or URL). Use `@@` to escape literal `@`. | `{{ body_FormData "field1" "value1" "file" "@/path/to/file.pdf" }}` |
|
| `body_FormData(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" }}` |
|
||||||
@@ -387,7 +390,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- |
|
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- |
|
||||||
| `fakeit_Username` | Username | `"Daniel1364"` |
|
| `fakeit_Username` | Username | `"Daniel1364"` |
|
||||||
| `fakeit_Password(upper bool, lower bool, numeric bool, special bool, space bool, length int)` | Generate password with specified character types and length | `{{ fakeit_Password true true true false false 16 }}` |
|
| `fakeit_Password(lower bool, upper bool, numeric bool, special bool, space bool, length int)` | Generate password with specified character types and length | `{{ fakeit_Password true true true false false 16 }}` |
|
||||||
|
|
||||||
### Address
|
### Address
|
||||||
|
|
||||||
@@ -521,7 +524,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
|
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
|
||||||
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
||||||
| `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` |
|
| `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` |
|
||||||
| `fakeit_Quote` | Random quote | `"Energy is contagious—so is fear"` |
|
| `fakeit_Quote` | Random quote | `"Energy is contagious so is fear"` |
|
||||||
| `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` |
|
| `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` |
|
||||||
| `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` |
|
| `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` |
|
||||||
| `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` |
|
| `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` |
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ go 1.27.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
charm.land/bubbles/v2 v2.2.1
|
charm.land/bubbles/v2 v2.2.1
|
||||||
charm.land/bubbletea/v2 v2.0.9
|
charm.land/bubbletea/v2 v2.1.0
|
||||||
charm.land/glamour/v2 v2.0.1
|
charm.land/glamour/v2 v2.0.1
|
||||||
charm.land/lipgloss/v2 v2.0.6
|
charm.land/lipgloss/v2 v2.0.6
|
||||||
github.com/brianvoe/gofakeit/v7 v7.17.0
|
github.com/brianvoe/gofakeit/v7 v7.17.1
|
||||||
github.com/charmbracelet/x/term v0.2.2
|
github.com/charmbracelet/x/term v0.2.2
|
||||||
github.com/dop251/goja v0.0.0-20260911104922-fabc3b8078ad
|
github.com/dop251/goja v0.0.0-20261008222547-e9f698900731
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/valyala/fasthttp v1.74.0
|
github.com/valyala/fasthttp v1.75.0
|
||||||
github.com/yuin/gopher-lua v1.1.2
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
go.aykhans.me/utils v1.0.7
|
go.aykhans.me/utils v1.0.7
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
golang.org/x/net v0.59.0
|
golang.org/x/net v0.60.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -23,31 +23,31 @@ require (
|
|||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260906004030-3986e9119cf9 // indirect
|
github.com/charmbracelet/x/exp/slice v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.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/v2 v2.8.0 // indirect
|
github.com/dlclark/regexp2/v2 v2.8.4 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe // indirect
|
github.com/google/pprof v0.0.0-20261008003335-7bae8d8c4c9e // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.1 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
github.com/mattn/go-runewidth v0.0.31 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/molecule-man/go-brrr v1.1.0 // indirect
|
github.com/molecule-man/go-brrr v1.2.0 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/xo/terminfo v1.0.0 // indirect
|
github.com/xo/terminfo v1.2.0 // indirect
|
||||||
github.com/yuin/goldmark v1.8.6 // indirect
|
github.com/yuin/goldmark v1.8.6 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.24.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.49.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||||
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||||
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||||
charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
|
charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
|
||||||
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
|
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
|
||||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||||
@@ -18,20 +18,20 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
|
|||||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
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.17.0 h1:usvHa73vmAjC1JmX4dpZAm1eQkR0riXaxvilScRTFb0=
|
github.com/brianvoe/gofakeit/v7 v7.17.1 h1:50FLBhTGVJQaj6ysRUu0it8wCdYO2uGM9VfuxI+csEc=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.17.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
github.com/brianvoe/gofakeit/v7 v7.17.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
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/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 h1:/hSCZO1puetbGd2JikHKN0nAcb2mFevzxJElFoduhaw=
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16/go.mod h1:JFvmQPZh3uAu7PpSZU91bcR0BrIy24HhW+0EsW1uGxE=
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
||||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260906004030-3986e9119cf9 h1:zW96vcZ3zDetccerbN7sS/sCydOE/l/9S+C94pozsaU=
|
github.com/charmbracelet/x/exp/slice v0.1.0 h1:m46dwuDf3M5SFYMRhxYkLC6z9WnENGVKbcbJlp13/og=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260906004030-3986e9119cf9/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
github.com/charmbracelet/x/exp/slice v0.1.0/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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
@@ -44,32 +44,32 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
|
|||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
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/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7BbdneDfw=
|
github.com/dlclark/regexp2/v2 v2.8.4 h1:yfDDXTn4oxyLDC10/ieOBNDCCdIVWS9WbT6ddVXsPKI=
|
||||||
github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
github.com/dlclark/regexp2/v2 v2.8.4/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
github.com/dop251/goja v0.0.0-20260911104922-fabc3b8078ad h1:5gT9hQ9h8CplLZfAGgFMcoLqQS6I9T2sZwQOCXy8/3s=
|
github.com/dop251/goja v0.0.0-20261008222547-e9f698900731 h1:j4pHbSFW20XvSKGrXR/m3YbITBSt8S2SvqFc29BXaHo=
|
||||||
github.com/dop251/goja v0.0.0-20260911104922-fabc3b8078ad/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
|
github.com/dop251/goja v0.0.0-20261008222547-e9f698900731/go.mod h1:GrCNKUrVplXZj0juFue0Cj2Xt/eL2Nuu2+y3UTanEo8=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY=
|
github.com/google/pprof v0.0.0-20261008003335-7bae8d8c4c9e h1:e+e5v2rfqKrd4lDA2n3azN3ludWyM2p+4MWob4EBlbk=
|
||||||
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20261008003335-7bae8d8c4c9e/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
|
||||||
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=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
|
||||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/molecule-man/go-brrr v1.1.0 h1:rLBGSXA393y+5ttCBLkW34CHESop5tahrOFxVv/XDEU=
|
github.com/molecule-man/go-brrr v1.2.0 h1:dOJU45BC3Gc2WXoxAW0rgCAgVuu2gruAOGncfieA0Jw=
|
||||||
github.com/molecule-man/go-brrr v1.1.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
|
github.com/molecule-man/go-brrr v1.2.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
@@ -80,10 +80,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU=
|
github.com/valyala/fasthttp v1.75.0 h1:W3xrvNJHG60j2ZCjxpP8kkB+oPG8kh9/g1n4+3xUwNA=
|
||||||
github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE=
|
github.com/valyala/fasthttp v1.75.0/go.mod h1:JiVkPRZU124PuPsreV/s9dZRgG2lRK7wNEikdQ0Rk4s=
|
||||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
|
||||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
|
||||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.6/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=
|
||||||
@@ -94,14 +94,12 @@ 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.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ flag.Value = (*byteSize)(nil)
|
||||||
|
_ yaml.Unmarshaler = (*byteSize)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
// byteSize is a size in bytes that reads and prints unit suffixes such as 10MiB.
|
||||||
|
type byteSize uint64
|
||||||
|
|
||||||
|
func (size byteSize) String() string {
|
||||||
|
return types.FormatByteSize(uint64(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set fills the size from a command line value.
|
||||||
|
func (size *byteSize) Set(value string) error {
|
||||||
|
parsed, err := types.ParseByteSize(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*size = byteSize(parsed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalYAML reads the raw scalar so a size parses the same way here as on the command line.
|
||||||
|
func (size *byteSize) UnmarshalYAML(node *yaml.Node) error {
|
||||||
|
if node.Kind != yaml.ScalarNode {
|
||||||
|
return fmt.Errorf("expected a size, but got %s", yamlNodeName(node))
|
||||||
|
}
|
||||||
|
|
||||||
|
return size.Set(node.Value)
|
||||||
|
}
|
||||||
+46
-38
@@ -20,32 +20,33 @@ Simple usage:
|
|||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
General Config:
|
General Config:
|
||||||
-h, -help Help for sarin
|
-h, -help Help for sarin
|
||||||
-v, -version Version for sarin
|
-v, -version Version for sarin
|
||||||
-s, -show-config bool Show the final config after parsing all sources (default %v)
|
-s, -show-config bool Show the final config after parsing all sources (default %v)
|
||||||
-f, -config-file []string Path to the config file (local file / http URL)
|
-f, -config-file []string Path to the config file (local file / http URL)
|
||||||
-c, -concurrency uint Number of concurrent requests (default %d)
|
-c, -concurrency uint Number of concurrent requests (default %d)
|
||||||
-r, -requests uint Number of total requests
|
-r, -requests uint Number of total requests
|
||||||
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
||||||
-l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s)
|
-l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s)
|
||||||
-w, -log-file string Write runtime logs to this file instead of the terminal/stderr
|
-w, -log-file string Write runtime logs to this file instead of the terminal/stderr
|
||||||
-p, -progress string Progress display (possible values: bar, none) (default '%v')
|
-p, -progress string Progress display (possible values: bar, none) (default '%v')
|
||||||
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
|
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
|
||||||
-z, -dry-run bool Run without sending requests (default %v)
|
-z, -dry-run bool Run without sending the generated requests (default %v)
|
||||||
|
|
||||||
Request Config:
|
Request Config:
|
||||||
-U, -url string Target URL for the request
|
-U, -url string Target URL for the request
|
||||||
-M, -method []string HTTP method for the request (default %s)
|
-M, -method []string HTTP method for the request (default %s)
|
||||||
-B, -body []string Body for the request (e.g. "body text")
|
-B, -body []string Body for the request (e.g. "body text")
|
||||||
-P, -param []string URL parameter for the request (e.g. "key1=value1")
|
-P, -param []string URL parameter for the request (e.g. "key1=value1")
|
||||||
-H, -header []string Header for the request (e.g. "key1: value1")
|
-H, -header []string Header for the request (e.g. "key1: value1")
|
||||||
-C, -cookie []string Cookie for the request (e.g. "key1=value1")
|
-C, -cookie []string Cookie for the request (e.g. "key1=value1")
|
||||||
-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)
|
-S, -max-response-body size Largest response body to read (e.g. 10MiB, 2MB), 0 for no limit (default %s)
|
||||||
-lua []string Lua script for request transformation (inline or @file/@url)
|
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)
|
||||||
-js []string JavaScript script for request transformation (inline or @file/@url)`
|
-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{}
|
||||||
|
|
||||||
@@ -97,18 +98,19 @@ 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
|
maxResponseBody byteSize
|
||||||
luaScripts = stringSliceArg{}
|
insecure bool
|
||||||
jsScripts = stringSliceArg{}
|
luaScripts = stringSliceArg{}
|
||||||
|
jsScripts = stringSliceArg{}
|
||||||
)
|
)
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -143,8 +145,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)")
|
flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)")
|
||||||
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
|
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
|
||||||
|
|
||||||
flagSet.BoolVar(&dryRun, "dry-run", false, "Run without sending requests")
|
flagSet.BoolVar(&dryRun, "dry-run", false, "Run without sending the generated requests")
|
||||||
flagSet.BoolVar(&dryRun, "z", false, "Run without sending requests")
|
flagSet.BoolVar(&dryRun, "z", false, "Run without sending the generated requests")
|
||||||
|
|
||||||
// Request config
|
// Request config
|
||||||
flagSet.StringVar(&urlInput, "url", "", "Target URL for the request")
|
flagSet.StringVar(&urlInput, "url", "", "Target URL for the request")
|
||||||
@@ -174,6 +176,9 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
||||||
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
||||||
|
|
||||||
|
flagSet.Var(&maxResponseBody, "max-response-body", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
|
||||||
|
flagSet.Var(&maxResponseBody, "S", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
@@ -258,6 +263,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
config.Values = append(config.Values, values...)
|
config.Values = append(config.Values, values...)
|
||||||
case "timeout", "T":
|
case "timeout", "T":
|
||||||
config.Timeout = new(timeout)
|
config.Timeout = new(timeout)
|
||||||
|
case "max-response-body", "S":
|
||||||
|
config.MaxResponseBody = new(uint64(maxResponseBody))
|
||||||
case "insecure", "I":
|
case "insecure", "I":
|
||||||
config.Insecure = new(insecure)
|
config.Insecure = new(insecure)
|
||||||
case "lua":
|
case "lua":
|
||||||
@@ -286,6 +293,7 @@ func (parser ConfigCLIParser) PrintHelp() {
|
|||||||
|
|
||||||
Defaults.Method,
|
Defaults.Method,
|
||||||
Defaults.RequestTimeout,
|
Defaults.RequestTimeout,
|
||||||
|
types.FormatByteSize(Defaults.MaxResponseBody),
|
||||||
Defaults.Insecure,
|
Defaults.Insecure,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+74
-47
@@ -27,27 +27,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var Defaults = struct {
|
var Defaults = struct {
|
||||||
UserAgent string
|
UserAgent string
|
||||||
Method string
|
Method string
|
||||||
RequestTimeout time.Duration
|
RequestTimeout time.Duration
|
||||||
Concurrency uint
|
MaxResponseBody uint64
|
||||||
ShowConfig bool
|
Concurrency uint
|
||||||
Progress ConfigProgressType
|
ShowConfig bool
|
||||||
Insecure bool
|
Progress ConfigProgressType
|
||||||
Output ConfigOutputType
|
Insecure bool
|
||||||
DryRun bool
|
Output ConfigOutputType
|
||||||
LogLevel string
|
DryRun bool
|
||||||
|
LogLevel string
|
||||||
}{
|
}{
|
||||||
UserAgent: "Sarin/" + version.Version,
|
UserAgent: "Sarin/" + version.Version,
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
RequestTimeout: time.Second * 10,
|
RequestTimeout: time.Second * 10,
|
||||||
Concurrency: 1,
|
MaxResponseBody: 10 << 20, // 10 MiB
|
||||||
ShowConfig: false,
|
Concurrency: 1,
|
||||||
Progress: ConfigProgressTypeBar,
|
ShowConfig: false,
|
||||||
Insecure: false,
|
Progress: ConfigProgressTypeBar,
|
||||||
Output: ConfigOutputTypeTable,
|
Insecure: false,
|
||||||
DryRun: false,
|
Output: ConfigOutputTypeTable,
|
||||||
LogLevel: "error",
|
DryRun: false,
|
||||||
|
LogLevel: "error",
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -82,28 +84,29 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
||||||
Files []types.ConfigFile `yaml:"files,omitempty"`
|
Files []types.ConfigFile `yaml:"files,omitempty"`
|
||||||
Methods []string `yaml:"methods,omitempty"`
|
Methods []string `yaml:"methods,omitempty"`
|
||||||
URL *url.URL `yaml:"url,omitempty"`
|
URL *url.URL `yaml:"url,omitempty"`
|
||||||
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
||||||
Concurrency *uint `yaml:"concurrency,omitempty"`
|
MaxResponseBody *uint64 `yaml:"maxResponseBody,omitempty"`
|
||||||
Requests *uint64 `yaml:"requests,omitempty"`
|
Concurrency *uint `yaml:"concurrency,omitempty"`
|
||||||
Duration *time.Duration `yaml:"duration,omitempty"`
|
Requests *uint64 `yaml:"requests,omitempty"`
|
||||||
Progress *ConfigProgressType `yaml:"progress,omitempty"`
|
Duration *time.Duration `yaml:"duration,omitempty"`
|
||||||
Output *ConfigOutputType `yaml:"output,omitempty"`
|
Progress *ConfigProgressType `yaml:"progress,omitempty"`
|
||||||
Insecure *bool `yaml:"insecure,omitempty"`
|
Output *ConfigOutputType `yaml:"output,omitempty"`
|
||||||
DryRun *bool `yaml:"dryRun,omitempty"`
|
Insecure *bool `yaml:"insecure,omitempty"`
|
||||||
Params types.Params `yaml:"params,omitempty"`
|
DryRun *bool `yaml:"dryRun,omitempty"`
|
||||||
Headers types.Headers `yaml:"headers,omitempty"`
|
Params types.Params `yaml:"params,omitempty"`
|
||||||
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
Headers types.Headers `yaml:"headers,omitempty"`
|
||||||
Bodies []string `yaml:"bodies,omitempty"`
|
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
||||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
Bodies []string `yaml:"bodies,omitempty"`
|
||||||
Values []string `yaml:"values,omitempty"`
|
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||||
Lua []string `yaml:"lua,omitempty"`
|
Values []string `yaml:"values,omitempty"`
|
||||||
Js []string `yaml:"js,omitempty"`
|
Lua []string `yaml:"lua,omitempty"`
|
||||||
LogLevel *string `yaml:"logLevel,omitempty"`
|
Js []string `yaml:"js,omitempty"`
|
||||||
LogFile *string `yaml:"logFile,omitempty"`
|
LogLevel *string `yaml:"logLevel,omitempty"`
|
||||||
|
LogFile *string `yaml:"logFile,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config Config) MarshalYAML() (any, error) {
|
func (config Config) MarshalYAML() (any, error) {
|
||||||
@@ -177,6 +180,9 @@ func (config Config) MarshalYAML() (any, error) {
|
|||||||
if config.Timeout != nil {
|
if config.Timeout != nil {
|
||||||
addField(content, "timeout", toNode(*config.Timeout), "")
|
addField(content, "timeout", toNode(*config.Timeout), "")
|
||||||
}
|
}
|
||||||
|
if config.MaxResponseBody != nil {
|
||||||
|
addField(content, "maxResponseBody", toNode(types.FormatByteSize(*config.MaxResponseBody)), "")
|
||||||
|
}
|
||||||
if config.Concurrency != nil {
|
if config.Concurrency != nil {
|
||||||
addField(content, "concurrency", toNode(*config.Concurrency), "")
|
addField(content, "concurrency", toNode(*config.Concurrency), "")
|
||||||
}
|
}
|
||||||
@@ -301,6 +307,9 @@ func (config *Config) Merge(newConfig *Config) {
|
|||||||
if newConfig.Timeout != nil {
|
if newConfig.Timeout != nil {
|
||||||
config.Timeout = newConfig.Timeout
|
config.Timeout = newConfig.Timeout
|
||||||
}
|
}
|
||||||
|
if newConfig.MaxResponseBody != nil {
|
||||||
|
config.MaxResponseBody = newConfig.MaxResponseBody
|
||||||
|
}
|
||||||
if newConfig.Concurrency != nil {
|
if newConfig.Concurrency != nil {
|
||||||
config.Concurrency = newConfig.Concurrency
|
config.Concurrency = newConfig.Concurrency
|
||||||
}
|
}
|
||||||
@@ -379,6 +388,9 @@ func (config *Config) SetDefaults() {
|
|||||||
if config.Timeout == nil {
|
if config.Timeout == nil {
|
||||||
config.Timeout = &Defaults.RequestTimeout
|
config.Timeout = &Defaults.RequestTimeout
|
||||||
}
|
}
|
||||||
|
if config.MaxResponseBody == nil {
|
||||||
|
config.MaxResponseBody = &Defaults.MaxResponseBody
|
||||||
|
}
|
||||||
if config.Concurrency == nil {
|
if config.Concurrency == nil {
|
||||||
config.Concurrency = new(Defaults.Concurrency)
|
config.Concurrency = new(Defaults.Concurrency)
|
||||||
}
|
}
|
||||||
@@ -454,6 +466,14 @@ func (config Config) Validate() error {
|
|||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.MaxResponseBody != nil && *config.MaxResponseBody > types.ByteSizeLimit {
|
||||||
|
validationErrors = append(validationErrors, types.NewFieldValidationError(
|
||||||
|
"MaxResponseBody",
|
||||||
|
types.FormatByteSize(*config.MaxResponseBody),
|
||||||
|
errors.New("max response body must not exceed "+types.FormatByteSize(types.ByteSizeLimit)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
if config.ShowConfig == nil {
|
if config.ShowConfig == nil {
|
||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
|
||||||
}
|
}
|
||||||
@@ -632,12 +652,19 @@ func ReadAllConfigs() *Config {
|
|||||||
|
|
||||||
for _, configFile := range append(envConfig.Files, cliConf.Files...) {
|
for _, configFile := range append(envConfig.Files, cliConf.Files...) {
|
||||||
fileConfig, err := parseConfigFile(configFile, 10)
|
fileConfig, err := parseConfigFile(configFile, 10)
|
||||||
|
|
||||||
|
// A nested configFile fails under its own name, not the one given on the command line.
|
||||||
|
path := configFile.Path()
|
||||||
|
if configFileErr, ok := errors.AsType[types.ConfigFileError](err); ok {
|
||||||
|
path = configFileErr.Path
|
||||||
|
}
|
||||||
|
|
||||||
_ = utilsErr.MustHandle(err,
|
_ = utilsErr.MustHandle(err,
|
||||||
utilsErr.OnType(func(err types.ConfigFileReadError) error {
|
utilsErr.OnType(func(err types.ConfigFileReadError) error {
|
||||||
cliParser.PrintHelp()
|
cliParser.PrintHelp()
|
||||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||||
StyleYellow.Render(
|
StyleYellow.Render(
|
||||||
fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(),
|
fmt.Sprintf("\nFailed to read config file (%s): ", path)+err.Error(),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -646,14 +673,14 @@ func ReadAllConfigs() *Config {
|
|||||||
utilsErr.OnType(func(err types.UnmarshalError) error {
|
utilsErr.OnType(func(err types.UnmarshalError) error {
|
||||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||||
StyleYellow.Render(
|
StyleYellow.Render(
|
||||||
fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(),
|
fmt.Sprintf("\nFailed to parse config file (%s): ", path)+err.Error(),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
utilsErr.OnType(func(err types.FieldParseErrors) error {
|
utilsErr.OnType(func(err types.FieldParseErrors) error {
|
||||||
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", configFile.Path()), err.Errors...)
|
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", path), err.Errors...)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
@@ -669,7 +696,7 @@ func ReadAllConfigs() *Config {
|
|||||||
|
|
||||||
// parseConfigFile recursively parses a config file and its nested files up to maxDepth levels.
|
// parseConfigFile recursively parses a config file and its nested files up to maxDepth levels.
|
||||||
// Returns the merged configuration or an error if parsing fails.
|
// Returns the merged configuration or an error if parsing fails.
|
||||||
// It can return the following errors:
|
// It can return the following errors, each wrapped in a types.ConfigFileError naming the file:
|
||||||
// - types.ConfigFileReadError
|
// - types.ConfigFileReadError
|
||||||
// - types.UnmarshalError
|
// - types.UnmarshalError
|
||||||
// - types.FieldParseErrors
|
// - types.FieldParseErrors
|
||||||
@@ -677,7 +704,7 @@ func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error)
|
|||||||
configFileParser := NewConfigFileParser(configFile)
|
configFileParser := NewConfigFileParser(configFile)
|
||||||
fileConfig, err := configFileParser.Parse()
|
fileConfig, err := configFileParser.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewConfigFileError(configFile.Path(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if maxDepth <= 0 {
|
if maxDepth <= 0 {
|
||||||
|
|||||||
@@ -195,6 +195,22 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if maxResponseBody := parser.getEnv("MAX_RESPONSE_BODY"); maxResponseBody != "" {
|
||||||
|
maxResponseBodyParsed, err := types.ParseByteSize(maxResponseBody)
|
||||||
|
if err != nil {
|
||||||
|
fieldParseErrors = append(
|
||||||
|
fieldParseErrors,
|
||||||
|
types.NewFieldParseError(
|
||||||
|
parser.getFullEnvName("MAX_RESPONSE_BODY"),
|
||||||
|
maxResponseBody,
|
||||||
|
err,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
config.MaxResponseBody = &maxResponseBodyParsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
||||||
insecureParsed, err := utilsParse.ParseString[bool](insecure)
|
insecureParsed, err := utilsParse.ParseString[bool](insecure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+248
-32
@@ -1,13 +1,17 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -79,10 +83,18 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
|||||||
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
// A declared size above the limit is refused before anything is read.
|
||||||
|
if resp.ContentLength > types.RemoteFetchLimit {
|
||||||
|
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewHTTPFetchError(url, err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
if len(data) > types.RemoteFetchLimit {
|
||||||
|
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
@@ -126,7 +138,7 @@ func (ss *stringOrSliceField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
*ss = slice
|
*ss = slice
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("expected a string or a sequence of strings, but got %v", node.Kind)
|
return fmt.Errorf("expected a string or a sequence of strings, but got %s", yamlNodeName(node))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +159,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
// Handle sequence of maps: [{key1: value1}, {key2: value2}]
|
// Handle sequence of maps: [{key1: value1}, {key2: value2}]
|
||||||
for _, item := range node.Content {
|
for _, item := range node.Content {
|
||||||
if item.Kind != yaml.MappingNode {
|
if item.Kind != yaml.MappingNode {
|
||||||
return fmt.Errorf("expected a mapping in sequence, but got %v", item.Kind)
|
return fmt.Errorf("expected a mapping in sequence, but got %s", yamlNodeName(item))
|
||||||
}
|
}
|
||||||
if err := kv.unmarshalMapping(item); err != nil {
|
if err := kv.unmarshalMapping(item); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -155,7 +167,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("expected a mapping or sequence of mappings, but got %v", node.Kind)
|
return fmt.Errorf("expected a mapping or sequence of mappings, but got %s", yamlNodeName(node))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +178,7 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
|||||||
valueNode := node.Content[i+1]
|
valueNode := node.Content[i+1]
|
||||||
|
|
||||||
if keyNode.Kind != yaml.ScalarNode {
|
if keyNode.Kind != yaml.ScalarNode {
|
||||||
return fmt.Errorf("expected a string key, but got %v", keyNode.Kind)
|
return fmt.Errorf("expected a string key, but got %s", yamlNodeName(keyNode))
|
||||||
}
|
}
|
||||||
|
|
||||||
key := keyNode.Value
|
key := keyNode.Value
|
||||||
@@ -178,12 +190,12 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
|||||||
case yaml.SequenceNode:
|
case yaml.SequenceNode:
|
||||||
for _, v := range valueNode.Content {
|
for _, v := range valueNode.Content {
|
||||||
if v.Kind != yaml.ScalarNode {
|
if v.Kind != yaml.ScalarNode {
|
||||||
return fmt.Errorf("expected string values in array for key %q, but got %v", key, v.Kind)
|
return fmt.Errorf("expected string values in array for key %q, but got %s", key, yamlNodeName(v))
|
||||||
}
|
}
|
||||||
values = append(values, v.Value)
|
values = append(values, v.Value)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("expected a string or array of strings for key %q, but got %v", key, valueNode.Kind)
|
return fmt.Errorf("expected a string or array of strings for key %q, but got %s", key, yamlNodeName(valueNode))
|
||||||
}
|
}
|
||||||
|
|
||||||
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
|
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
|
||||||
@@ -192,28 +204,29 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type configYAML struct {
|
type configYAML struct {
|
||||||
ShowConfig *bool `yaml:"showConfig"`
|
ShowConfig *bool `yaml:"showConfig"`
|
||||||
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
||||||
Concurrency *uint `yaml:"concurrency"`
|
Concurrency *uint `yaml:"concurrency"`
|
||||||
RequestCount *uint64 `yaml:"requests"`
|
RequestCount *uint64 `yaml:"requests"`
|
||||||
Duration *time.Duration `yaml:"duration"`
|
Duration *time.Duration `yaml:"duration"`
|
||||||
LogLevel *string `yaml:"logLevel"`
|
LogLevel *string `yaml:"logLevel"`
|
||||||
LogFile *string `yaml:"logFile"`
|
LogFile *string `yaml:"logFile"`
|
||||||
Progress *string `yaml:"progress"`
|
Progress *string `yaml:"progress"`
|
||||||
Output *string `yaml:"output"`
|
Output *string `yaml:"output"`
|
||||||
DryRun *bool `yaml:"dryRun"`
|
DryRun *bool `yaml:"dryRun"`
|
||||||
URL *string `yaml:"url"`
|
URL *string `yaml:"url"`
|
||||||
Method stringOrSliceField `yaml:"method"`
|
Method stringOrSliceField `yaml:"method"`
|
||||||
Bodies stringOrSliceField `yaml:"body"`
|
Bodies stringOrSliceField `yaml:"body"`
|
||||||
Params keyValuesField `yaml:"params"`
|
Params keyValuesField `yaml:"params"`
|
||||||
Headers keyValuesField `yaml:"headers"`
|
Headers keyValuesField `yaml:"headers"`
|
||||||
Cookies keyValuesField `yaml:"cookies"`
|
Cookies keyValuesField `yaml:"cookies"`
|
||||||
Proxies stringOrSliceField `yaml:"proxy"`
|
Proxies stringOrSliceField `yaml:"proxy"`
|
||||||
Values stringOrSliceField `yaml:"values"`
|
Values stringOrSliceField `yaml:"values"`
|
||||||
Timeout *time.Duration `yaml:"timeout"`
|
Timeout *time.Duration `yaml:"timeout"`
|
||||||
Insecure *bool `yaml:"insecure"`
|
MaxResponseBody *byteSize `yaml:"maxResponseBody"`
|
||||||
Lua stringOrSliceField `yaml:"lua"`
|
Insecure *bool `yaml:"insecure"`
|
||||||
Js stringOrSliceField `yaml:"js"`
|
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.
|
||||||
@@ -226,9 +239,26 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
parsedData = &configYAML{}
|
parsedData = &configYAML{}
|
||||||
)
|
)
|
||||||
|
|
||||||
err := yaml.Unmarshal(data, &parsedData)
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
if err != nil {
|
decoder.KnownFields(true)
|
||||||
return nil, types.NewUnmarshalError(err)
|
// The decoder returns a bare io.EOF for an empty file, errors.Is would also match a wrapped one.
|
||||||
|
if err := decoder.Decode(parsedData); err != nil && err != io.EOF { //nolint:errorlint
|
||||||
|
return nil, types.NewUnmarshalError(yamlError(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode reads one document, so anything after the first "---" would be dropped silently.
|
||||||
|
for {
|
||||||
|
var next yaml.Node
|
||||||
|
err := decoder.Decode(&next)
|
||||||
|
if err == io.EOF { //nolint:errorlint
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewUnmarshalError(yamlError(err))
|
||||||
|
}
|
||||||
|
if !isEmptyDocument(&next) {
|
||||||
|
return nil, types.NewUnmarshalError(types.ErrYAMLMultipleDocuments)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fieldParseErrors []types.FieldParseError
|
var fieldParseErrors []types.FieldParseError
|
||||||
@@ -290,6 +320,9 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
|
|
||||||
config.Values = append(config.Values, parsedData.Values...)
|
config.Values = append(config.Values, parsedData.Values...)
|
||||||
config.Timeout = parsedData.Timeout
|
config.Timeout = parsedData.Timeout
|
||||||
|
if parsedData.MaxResponseBody != nil {
|
||||||
|
config.MaxResponseBody = new(uint64(*parsedData.MaxResponseBody))
|
||||||
|
}
|
||||||
config.Insecure = parsedData.Insecure
|
config.Insecure = parsedData.Insecure
|
||||||
config.Lua = append(config.Lua, parsedData.Lua...)
|
config.Lua = append(config.Lua, parsedData.Lua...)
|
||||||
config.Js = append(config.Js, parsedData.Js...)
|
config.Js = append(config.Js, parsedData.Js...)
|
||||||
@@ -300,3 +333,186 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configYAMLTypeName is the Go type name the yaml package puts in its messages.
|
||||||
|
var configYAMLTypeName = reflect.TypeFor[configYAML]().String()
|
||||||
|
|
||||||
|
// isEmptyDocument reports whether a document holds nothing, which a trailing "---" produces.
|
||||||
|
// A null tag alone is not enough, since a user can hang it on a mapping that does hold content.
|
||||||
|
func isEmptyDocument(node *yaml.Node) bool {
|
||||||
|
if len(node.Content) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
root := node.Content[0]
|
||||||
|
return root.Kind == yaml.ScalarNode && root.Tag == "!!null"
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlError rewrites yaml decode errors so they read like config file errors.
|
||||||
|
// Errors from building the config arrive collected, everything earlier arrives on its own.
|
||||||
|
func yamlError(err error) error {
|
||||||
|
var loadErrors *yaml.LoadErrors
|
||||||
|
if errors.As(err, &loadErrors) && len(loadErrors.Errors) > 0 {
|
||||||
|
var builder strings.Builder
|
||||||
|
for i, loadError := range loadErrors.Errors {
|
||||||
|
if i > 0 {
|
||||||
|
builder.WriteString("\n")
|
||||||
|
}
|
||||||
|
builder.WriteString(yamlErrorText(loadError))
|
||||||
|
}
|
||||||
|
return errors.New(builder.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadError, ok := errors.AsType[*yaml.LoadError](err); ok {
|
||||||
|
return errors.New(yamlErrorText(loadError))
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlErrorText renders one error as "line 3: while scanning a plain scalar: found a tab character".
|
||||||
|
// The mark is where reading gave up and the context mark starts the construct that broke.
|
||||||
|
func yamlErrorText(loadError *yaml.LoadError) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
if loadError.Mark.Line > 0 {
|
||||||
|
fmt.Fprintf(&builder, "line %d: ", loadError.Mark.Line)
|
||||||
|
}
|
||||||
|
if loadError.ContextMsg != "" {
|
||||||
|
builder.WriteString(loadError.ContextMsg + ": ")
|
||||||
|
}
|
||||||
|
builder.WriteString(yamlErrorMessage(loadError.Message))
|
||||||
|
|
||||||
|
start := loadError.ContextMark
|
||||||
|
if loadError.ContextMsg != "" && start.Line > 0 && start.Line != loadError.Mark.Line {
|
||||||
|
fmt.Fprintf(&builder, " (started at line %d)", start.Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlTypeNames maps the Go types and YAML tags the yaml package prints to words a user knows.
|
||||||
|
var yamlTypeNames = map[string]string{
|
||||||
|
"!!str": "text",
|
||||||
|
"!!int": "a number",
|
||||||
|
"!!float": "a number",
|
||||||
|
"!!bool": "true or false",
|
||||||
|
"!!seq": "a list",
|
||||||
|
"!!map": "a mapping",
|
||||||
|
"!!null": "an empty value",
|
||||||
|
"!!timestamp": "a date",
|
||||||
|
"!!binary": "binary data",
|
||||||
|
"string": "text",
|
||||||
|
"bool": "true or false",
|
||||||
|
"uint": "a whole number of 0 or more",
|
||||||
|
"uint64": "a whole number of 0 or more",
|
||||||
|
"time.Duration": "a duration such as 10s",
|
||||||
|
configYAMLTypeName: "a mapping of config keys",
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlNodeName names what a node holds, so an error can say "a list" instead of a kind number.
|
||||||
|
// A tag that contradicts the shape is kept, since the shape alone would read as a contradiction.
|
||||||
|
func yamlNodeName(node *yaml.Node) string {
|
||||||
|
if name, ok := yamlTypeNames[node.Tag]; ok && yamlTagKind(node.Tag) == node.Kind {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if node.Tag == "" {
|
||||||
|
return yamlKindName(node.Kind)
|
||||||
|
}
|
||||||
|
return yamlKindName(node.Kind) + " tagged " + node.Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlTagKind returns the node shape a tag belongs on, so a mismatch can be spotted.
|
||||||
|
func yamlTagKind(tag string) yaml.Kind {
|
||||||
|
switch tag {
|
||||||
|
case "!!seq":
|
||||||
|
return yaml.SequenceNode
|
||||||
|
case "!!map":
|
||||||
|
return yaml.MappingNode
|
||||||
|
default:
|
||||||
|
return yaml.ScalarNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlKindName names a node by its shape, for when its tag says nothing a user would know.
|
||||||
|
func yamlKindName(kind yaml.Kind) string {
|
||||||
|
switch kind {
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
return "a list"
|
||||||
|
case yaml.MappingNode:
|
||||||
|
return "a mapping"
|
||||||
|
case yaml.AliasNode:
|
||||||
|
return "an alias"
|
||||||
|
default:
|
||||||
|
return "a value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlErrorMessage turns "field x not found in type config.configYAML" into "unknown key "x"".
|
||||||
|
func yamlErrorMessage(message string) string {
|
||||||
|
if field, ok := strings.CutSuffix(message, " not found in type "+configYAMLTypeName); ok {
|
||||||
|
if name, ok := strings.CutPrefix(field, "field "); ok {
|
||||||
|
return fmt.Sprintf("unknown key %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user controls the value, so the rightmost separator is the only one that is ours.
|
||||||
|
// After "as a" comes the tag the user wrote, after "into" comes the type the key accepts.
|
||||||
|
if names, ok := strings.CutPrefix(message, "cannot construct "); ok {
|
||||||
|
at, width, tagged := -1, 0, false
|
||||||
|
for index, separator := range []string{" into ", " as a "} {
|
||||||
|
if found := strings.LastIndex(names, separator); found > at {
|
||||||
|
at, width, tagged = found, len(separator), index == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at >= 0 {
|
||||||
|
got, want := yamlGotName(names[:at]), yamlWantedName(names[at+width:])
|
||||||
|
if tagged {
|
||||||
|
return fmt.Sprintf("%s is not %s", got, want)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("expected %s, but got %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.ReplaceAll(message, configYAMLTypeName, "the config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlWantedName names what a key accepts, which the yaml package prints as a Go type.
|
||||||
|
func yamlWantedName(text string) string {
|
||||||
|
if name, ok := yamlTypeNames[text]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlGotName names what the file held, which the yaml package prints as a tag and sometimes a value.
|
||||||
|
// A tag the user would not know stays in the text, since without it the message reads as nonsense.
|
||||||
|
func yamlGotName(text string) string {
|
||||||
|
// A collection tag carries no backticks, so a scalar wearing one arrives glued to it.
|
||||||
|
for _, tag := range []string{"!!seq", "!!map"} {
|
||||||
|
if value, ok := strings.CutPrefix(text, tag); ok && value != "" {
|
||||||
|
return yamlValueText(value) + " tagged " + tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, value, _ := strings.Cut(text, " ")
|
||||||
|
value = strings.TrimSuffix(strings.TrimPrefix(value, "`"), "`")
|
||||||
|
|
||||||
|
name, known := yamlTypeNames[tag]
|
||||||
|
switch {
|
||||||
|
case known && value != "":
|
||||||
|
return yamlValueText(value)
|
||||||
|
case known:
|
||||||
|
return name
|
||||||
|
case value != "":
|
||||||
|
return yamlValueText(value) + " tagged " + tag
|
||||||
|
default:
|
||||||
|
return "a value tagged " + tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlValueText quotes a value, saying so when the yaml package shortened it to seven characters.
|
||||||
|
func yamlValueText(value string) string {
|
||||||
|
if start, ok := strings.CutSuffix(value, "..."); ok && len(start) == 7 {
|
||||||
|
return "a value starting with " + strconv.Quote(start)
|
||||||
|
}
|
||||||
|
return strconv.Quote(value)
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"math"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -20,11 +19,88 @@ import (
|
|||||||
// clientIndexGenerator returns the index of the host client (and proxy) to use next.
|
// clientIndexGenerator returns the index of the host client (and proxy) to use next.
|
||||||
type clientIndexGenerator func() int
|
type clientIndexGenerator func() int
|
||||||
|
|
||||||
func safeUintToInt(u uint) int {
|
// preferIPv4Resolver hides a host's IPv6 addresses whenever it also has IPv4 ones.
|
||||||
if u > math.MaxInt {
|
type preferIPv4Resolver struct{}
|
||||||
return math.MaxInt
|
|
||||||
|
func (preferIPv4Resolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||||
|
return lookupPreferred(ctx, host, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// preferIPv6Resolver is the mirror of preferIPv4Resolver, used for the fallback dial.
|
||||||
|
type preferIPv6Resolver struct{}
|
||||||
|
|
||||||
|
func (preferIPv6Resolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||||
|
return lookupPreferred(ctx, host, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupPreferred resolves host and keeps only the wanted family, unless that family is absent.
|
||||||
|
func lookupPreferred(ctx context.Context, host string, wantIPv4 bool) ([]net.IPAddr, error) {
|
||||||
|
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
preferred := make([]net.IPAddr, 0, len(ips))
|
||||||
|
for _, ip := range ips {
|
||||||
|
if (ip.IP.To4() != nil) == wantIPv4 {
|
||||||
|
preferred = append(preferred, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(preferred) > 0 {
|
||||||
|
return preferred, nil
|
||||||
|
}
|
||||||
|
return ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// preferIPv4DialTimeoutShare leaves half the timeout for the IPv6 retry.
|
||||||
|
const preferIPv4DialTimeoutShare = 2
|
||||||
|
|
||||||
|
// preferIPv4Dialer dials over IPv4 and retries over IPv6 alone when that fails.
|
||||||
|
type preferIPv4Dialer struct {
|
||||||
|
ipv4 *fasthttp.TCPDialer
|
||||||
|
ipv6 *fasthttp.TCPDialer
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialerConcurrency matches the limit on fasthttp own package-level dialer.
|
||||||
|
const dialerConcurrency = 1000
|
||||||
|
|
||||||
|
// dialer is shared so all clients use one DNS cache.
|
||||||
|
var dialer = &preferIPv4Dialer{
|
||||||
|
ipv4: &fasthttp.TCPDialer{Resolver: preferIPv4Resolver{}, Concurrency: dialerConcurrency},
|
||||||
|
ipv6: &fasthttp.TCPDialer{Resolver: preferIPv6Resolver{}, Concurrency: dialerConcurrency},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialTimeout dials addr, which must carry a port.
|
||||||
|
func (d *preferIPv4Dialer) DialTimeout(addr string, timeout time.Duration) (net.Conn, error) {
|
||||||
|
// fasthttp passes 0 from its pooled dial path.
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = fasthttp.DefaultDialTimeout
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
|
||||||
|
conn, err := d.ipv4.DialDualStackTimeout(addr, timeout/preferIPv4DialTimeoutShare)
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, fallbackErr := d.ipv6.DialDualStackTimeout(addr, remaining)
|
||||||
|
if fallbackErr != nil {
|
||||||
|
// Report the IPv4 error, not the fallback one.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialFunc fills in the default port, which fasthttp skips for a custom dial func.
|
||||||
|
func (d *preferIPv4Dialer) dialFunc(isTLS bool) fasthttp.DialFuncWithTimeout {
|
||||||
|
return func(addr string, timeout time.Duration) (net.Conn, error) {
|
||||||
|
return d.DialTimeout(fasthttp.AddMissingPort(addr, isTLS), timeout)
|
||||||
}
|
}
|
||||||
return int(u)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
|
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
|
||||||
@@ -38,6 +114,7 @@ func NewHostClients(
|
|||||||
maxConns uint,
|
maxConns uint,
|
||||||
requestURL *url.URL,
|
requestURL *url.URL,
|
||||||
skipVerify bool,
|
skipVerify bool,
|
||||||
|
maxResponseBody uint64,
|
||||||
) ([]*fasthttp.HostClient, error) {
|
) ([]*fasthttp.HostClient, error) {
|
||||||
isTLS := requestURL.Scheme == "https"
|
isTLS := requestURL.Scheme == "https"
|
||||||
|
|
||||||
@@ -66,6 +143,9 @@ func NewHostClients(
|
|||||||
MaxConnDuration: timeout,
|
MaxConnDuration: timeout,
|
||||||
WriteTimeout: timeout,
|
WriteTimeout: timeout,
|
||||||
ReadTimeout: timeout,
|
ReadTimeout: timeout,
|
||||||
|
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||||
|
MaxIdemponentCallAttempts: 1,
|
||||||
|
ReadBufferSize: 64 << 10, // 64 KiB
|
||||||
DisableHeaderNamesNormalizing: true,
|
DisableHeaderNamesNormalizing: true,
|
||||||
DisablePathNormalizing: true,
|
DisablePathNormalizing: true,
|
||||||
NoDefaultUserAgentHeader: true,
|
NoDefaultUserAgentHeader: true,
|
||||||
@@ -77,9 +157,9 @@ func NewHostClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := &fasthttp.HostClient{
|
client := &fasthttp.HostClient{
|
||||||
MaxConns: safeUintToInt(maxConns),
|
MaxConns: safeUintToInt(maxConns),
|
||||||
IsTLS: isTLS,
|
IsTLS: isTLS,
|
||||||
DialDualStack: true,
|
DialTimeout: dialer.dialFunc(isTLS),
|
||||||
TLSConfig: &tls.Config{
|
TLSConfig: &tls.Config{
|
||||||
InsecureSkipVerify: skipVerify, //nolint:gosec
|
InsecureSkipVerify: skipVerify, //nolint:gosec
|
||||||
},
|
},
|
||||||
@@ -88,6 +168,9 @@ func NewHostClients(
|
|||||||
MaxConnDuration: timeout,
|
MaxConnDuration: timeout,
|
||||||
WriteTimeout: timeout,
|
WriteTimeout: timeout,
|
||||||
ReadTimeout: timeout,
|
ReadTimeout: timeout,
|
||||||
|
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||||
|
MaxIdemponentCallAttempts: 1,
|
||||||
|
ReadBufferSize: 64 << 10, // 64 KiB
|
||||||
DisableHeaderNamesNormalizing: true,
|
DisableHeaderNamesNormalizing: true,
|
||||||
DisablePathNormalizing: true,
|
DisablePathNormalizing: true,
|
||||||
NoDefaultUserAgentHeader: true,
|
NoDefaultUserAgentHeader: true,
|
||||||
@@ -257,7 +340,7 @@ func fasthttpConnectDialer(proxyURL *url.URL, useTLS bool) fasthttp.DialFuncWith
|
|||||||
return func(addr string, timeout time.Duration) (net.Conn, error) {
|
return func(addr string, timeout time.Duration) (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 := dialer.DialTimeout(proxyAddr, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,10 +93,18 @@ func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
|
|||||||
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := io.ReadAll(resp.Body)
|
// A declared size above the limit is refused before anything is read.
|
||||||
|
if resp.ContentLength > types.RemoteFetchLimit {
|
||||||
|
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", types.NewHTTPFetchError(url, err)
|
return nil, "", types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
if len(content) > types.RemoteFetchLimit {
|
||||||
|
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
// Extract filename from URL path
|
// Extract filename from URL path
|
||||||
filename := path.Base(url)
|
filename := path.Base(url)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sarin
|
package sarin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -19,3 +20,17 @@ func firstOrEmpty(values []string) string {
|
|||||||
}
|
}
|
||||||
return values[0]
|
return values[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func safeUintToInt(u uint) int {
|
||||||
|
if u > math.MaxInt {
|
||||||
|
return math.MaxInt
|
||||||
|
}
|
||||||
|
return int(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeUint64ToInt(u uint64) int {
|
||||||
|
if u > math.MaxInt {
|
||||||
|
return math.MaxInt
|
||||||
|
}
|
||||||
|
return int(u)
|
||||||
|
}
|
||||||
@@ -17,6 +17,14 @@ import (
|
|||||||
const DefaultResponseDurationAccuracy uint32 = 1
|
const DefaultResponseDurationAccuracy uint32 = 1
|
||||||
const DefaultResponseColumnMaxWidth = 50
|
const DefaultResponseColumnMaxWidth = 50
|
||||||
|
|
||||||
|
// maxResponseKeys bounds the distinct response keys, so error messages carrying
|
||||||
|
// per-request text cannot grow the map without limit. It stays above the number of
|
||||||
|
// HTTP status codes, so real ones are never merged.
|
||||||
|
const maxResponseKeys = 1000
|
||||||
|
|
||||||
|
// otherResponseKey collects everything that arrives after the key limit is reached.
|
||||||
|
const otherResponseKey = "other"
|
||||||
|
|
||||||
// Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings.
|
// Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings.
|
||||||
type Duration time.Duration
|
type Duration time.Duration
|
||||||
|
|
||||||
@@ -95,6 +103,11 @@ func (data *SarinResponseData) Add(responseKey string, responseTime time.Duratio
|
|||||||
defer data.Unlock()
|
defer data.Unlock()
|
||||||
|
|
||||||
response, ok := data.Responses[responseKey]
|
response, ok := data.Responses[responseKey]
|
||||||
|
if !ok && len(data.Responses) >= maxResponseKeys {
|
||||||
|
responseKey = otherResponseKey
|
||||||
|
response, ok = data.Responses[responseKey]
|
||||||
|
}
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
data.Responses[responseKey] = &Response{
|
data.Responses[responseKey] = &Response{
|
||||||
durations: map[time.Duration]uint64{
|
durations: map[time.Duration]uint64{
|
||||||
|
|||||||
+22
-15
@@ -199,6 +199,10 @@ type sarin struct {
|
|||||||
logError bool
|
logError bool
|
||||||
logFile string
|
logFile string
|
||||||
|
|
||||||
|
// jobsCtx ends the run, either when the duration expires or on Ctrl+C.
|
||||||
|
jobsCtx context.Context //nolint:containedctx
|
||||||
|
jobsCancel context.CancelFunc
|
||||||
|
|
||||||
hostClients []*fasthttp.HostClient
|
hostClients []*fasthttp.HostClient
|
||||||
scriptHTTPClients []*scriptHTTPClient
|
scriptHTTPClients []*scriptHTTPClient
|
||||||
responses *SarinResponseData
|
responses *SarinResponseData
|
||||||
@@ -221,6 +225,7 @@ func NewSarin(
|
|||||||
totalDuration *time.Duration,
|
totalDuration *time.Duration,
|
||||||
showProgress bool,
|
showProgress bool,
|
||||||
skipCertVerify bool,
|
skipCertVerify bool,
|
||||||
|
maxResponseBody uint64,
|
||||||
params types.Params,
|
params types.Params,
|
||||||
headers types.Headers,
|
headers types.Headers,
|
||||||
cookies types.Cookies,
|
cookies types.Cookies,
|
||||||
@@ -254,7 +259,7 @@ func NewSarin(
|
|||||||
proxyURLs[i] = url.URL(proxy)
|
proxyURLs[i] = url.URL(proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
hostClients, err := NewHostClients(ctx, timeout, proxyURLs, workers, requestURL, skipCertVerify)
|
hostClients, err := NewHostClients(ctx, timeout, proxyURLs, workers, requestURL, skipCertVerify, maxResponseBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -272,15 +277,20 @@ func NewSarin(
|
|||||||
|
|
||||||
scriptChain := script.NewChain(luaSources, jsSources)
|
scriptChain := script.NewChain(luaSources, jsSources)
|
||||||
|
|
||||||
|
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
var scriptHTTPClients []*scriptHTTPClient
|
var scriptHTTPClients []*scriptHTTPClient
|
||||||
if !scriptChain.IsEmpty() {
|
if !scriptChain.IsEmpty() {
|
||||||
scriptHTTPClients, err = newScriptHTTPClients(ctx, proxyURLs, workers)
|
scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers, maxResponseBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
jobsCancel()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
srn := &sarin{
|
srn := &sarin{
|
||||||
|
jobsCtx: jobsCtx,
|
||||||
|
jobsCancel: jobsCancel,
|
||||||
workers: workers,
|
workers: workers,
|
||||||
requestURL: requestURL,
|
requestURL: requestURL,
|
||||||
methods: methods,
|
methods: methods,
|
||||||
@@ -301,7 +311,7 @@ func NewSarin(
|
|||||||
logFile: logFile,
|
logFile: logFile,
|
||||||
hostClients: hostClients,
|
hostClients: hostClients,
|
||||||
scriptHTTPClients: scriptHTTPClients,
|
scriptHTTPClients: scriptHTTPClients,
|
||||||
fileCache: NewFileCache(time.Second * 10),
|
fileCache: NewFileCache(time.Second * 30),
|
||||||
scriptChain: scriptChain,
|
scriptChain: scriptChain,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,9 +326,7 @@ func (s sarin) GetResponses() *SarinResponseData {
|
|||||||
return s.responses
|
return s.responses
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
func (s sarin) Start(stopCtrl *StopController) {
|
||||||
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
|
||||||
|
|
||||||
var workersWG sync.WaitGroup
|
var workersWG sync.WaitGroup
|
||||||
jobsCh := make(chan struct{}, max(s.workers, 1))
|
jobsCh := make(chan struct{}, max(s.workers, 1))
|
||||||
|
|
||||||
@@ -382,15 +390,14 @@ func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
|||||||
s.startWorkers(&workersWG, jobsCh, &counter, sendLog, sendRespLog)
|
s.startWorkers(&workersWG, jobsCh, &counter, sendLog, sendRespLog)
|
||||||
|
|
||||||
if runTUI {
|
if runTUI {
|
||||||
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
|
|
||||||
go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
|
go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup duration-based cancellation
|
// Setup duration-based cancellation
|
||||||
s.setupDurationTimeout(ctx, jobsCancel)
|
s.setupDurationTimeout()
|
||||||
// Distribute jobs to workers.
|
// Distribute jobs to workers.
|
||||||
// This blocks until all jobs are sent or the context is canceled.
|
// This blocks until all jobs are sent or the context is canceled.
|
||||||
s.sendJobs(jobsCtx, jobsCh)
|
s.sendJobs(jobsCh)
|
||||||
|
|
||||||
// Close the jobs channel so workers stop after completing their current job
|
// Close the jobs channel so workers stop after completing their current job
|
||||||
close(jobsCh)
|
close(jobsCh)
|
||||||
@@ -455,31 +462,31 @@ func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, counter *a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
|
func (s sarin) setupDurationTimeout() {
|
||||||
if s.totalDuration != nil {
|
if s.totalDuration != nil {
|
||||||
go func() {
|
go func() {
|
||||||
timer := time.NewTimer(*s.totalDuration)
|
timer := time.NewTimer(*s.totalDuration)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
select {
|
select {
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
cancel()
|
s.jobsCancel()
|
||||||
case <-ctx.Done():
|
case <-s.jobsCtx.Done():
|
||||||
// Context cancelled, cleanup
|
// Context cancelled, cleanup
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
|
func (s sarin) sendJobs(jobs chan<- struct{}) {
|
||||||
if s.totalRequests != nil && *s.totalRequests > 0 {
|
if s.totalRequests != nil && *s.totalRequests > 0 {
|
||||||
for range *s.totalRequests {
|
for range *s.totalRequests {
|
||||||
if ctx.Err() != nil {
|
if s.jobsCtx.Err() != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
jobs <- struct{}{}
|
jobs <- struct{}{}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for ctx.Err() == nil {
|
for s.jobsCtx.Err() == nil {
|
||||||
jobs <- struct{}{}
|
jobs <- struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package sarin
|
package sarin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"compress/zlib"
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,14 +14,16 @@ import (
|
|||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// scriptHTTPDefaultTimeout is the default timeout for script requests, independent of -T.
|
// scriptHTTPDefaultTimeout is the default timeout for script requests.
|
||||||
const scriptHTTPDefaultTimeout = 30 * time.Second
|
const scriptHTTPDefaultTimeout = 10 * time.Second
|
||||||
|
|
||||||
// scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use.
|
// scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use.
|
||||||
type scriptHTTPClient struct {
|
type scriptHTTPClient struct {
|
||||||
client *fasthttp.Client
|
ctx context.Context //nolint:containedctx
|
||||||
insecureClient *fasthttp.Client
|
client *fasthttp.Client
|
||||||
defaultTimeout time.Duration
|
insecureClient *fasthttp.Client
|
||||||
|
defaultTimeout time.Duration
|
||||||
|
maxResponseBody int
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ script.HTTPDoer = (*scriptHTTPClient)(nil)
|
var _ script.HTTPDoer = (*scriptHTTPClient)(nil)
|
||||||
@@ -27,9 +31,9 @@ var _ script.HTTPDoer = (*scriptHTTPClient)(nil)
|
|||||||
// newScriptHTTPClients creates one client per proxy, in the same order as NewHostClients.
|
// newScriptHTTPClients creates one client per proxy, in the same order as NewHostClients.
|
||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ProxyDialError
|
// - types.ProxyDialError
|
||||||
func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint) ([]*scriptHTTPClient, error) {
|
func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint, maxResponseBody uint64) ([]*scriptHTTPClient, error) {
|
||||||
if len(proxies) == 0 {
|
if len(proxies) == 0 {
|
||||||
return []*scriptHTTPClient{newScriptHTTPClient(fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns)}, nil
|
return []*scriptHTTPClient{newScriptHTTPClient(ctx, dialer.DialTimeout, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
clients := make([]*scriptHTTPClient, 0, len(proxies))
|
clients := make([]*scriptHTTPClient, 0, len(proxies))
|
||||||
@@ -38,18 +42,27 @@ func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewProxyDialError(proxy.String(), err)
|
return nil, types.NewProxyDialError(proxy.String(), err)
|
||||||
}
|
}
|
||||||
clients = append(clients, newScriptHTTPClient(dial, scriptHTTPDefaultTimeout, maxConns))
|
clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns, maxResponseBody))
|
||||||
}
|
}
|
||||||
return clients, nil
|
return clients, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newScriptHTTPClient(dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint) *scriptHTTPClient {
|
func newScriptHTTPClient(
|
||||||
|
ctx context.Context,
|
||||||
|
dial fasthttp.DialFuncWithTimeout,
|
||||||
|
defaultTimeout time.Duration,
|
||||||
|
maxConns uint,
|
||||||
|
maxResponseBody uint64,
|
||||||
|
) *scriptHTTPClient {
|
||||||
newClient := func(insecure bool) *fasthttp.Client {
|
newClient := func(insecure bool) *fasthttp.Client {
|
||||||
// Read and write timeouts stay unset because they would cap the per-call timeout,
|
// Read and write timeouts stay unset because they would cap the per-call timeout,
|
||||||
// so dialWithDeadline is what bounds the TLS handshake.
|
// so dialWithDeadline is what bounds the TLS handshake.
|
||||||
return &fasthttp.Client{
|
return &fasthttp.Client{
|
||||||
DialTimeout: dialWithDeadline(dial),
|
DialTimeout: dialWithDeadline(dial),
|
||||||
MaxConnsPerHost: safeUintToInt(maxConns),
|
MaxConnsPerHost: safeUintToInt(maxConns),
|
||||||
|
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||||
|
MaxIdemponentCallAttempts: 1,
|
||||||
|
ReadBufferSize: 64 << 10, // 64 KiB
|
||||||
TLSConfig: &tls.Config{
|
TLSConfig: &tls.Config{
|
||||||
InsecureSkipVerify: insecure, //nolint:gosec
|
InsecureSkipVerify: insecure, //nolint:gosec
|
||||||
},
|
},
|
||||||
@@ -60,9 +73,11 @@ func newScriptHTTPClient(dial fasthttp.DialFuncWithTimeout, defaultTimeout time.
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &scriptHTTPClient{
|
return &scriptHTTPClient{
|
||||||
client: newClient(false),
|
ctx: ctx,
|
||||||
insecureClient: newClient(true),
|
client: newClient(false),
|
||||||
defaultTimeout: defaultTimeout,
|
insecureClient: newClient(true),
|
||||||
|
defaultTimeout: defaultTimeout,
|
||||||
|
maxResponseBody: safeUint64ToInt(maxResponseBody),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +106,10 @@ func dialWithDeadline(dial fasthttp.DialFuncWithTimeout) fasthttp.DialFuncWithTi
|
|||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ScriptHTTPRequestError
|
// - types.ScriptHTTPRequestError
|
||||||
func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, error) {
|
func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, error) {
|
||||||
|
if err := c.ctx.Err(); err != nil {
|
||||||
|
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
|
||||||
|
}
|
||||||
|
|
||||||
parsedURL, err := url.Parse(r.URL)
|
parsedURL, err := url.Parse(r.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
|
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
|
||||||
@@ -143,6 +162,14 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro
|
|||||||
} else {
|
} else {
|
||||||
err = client.Do(req, resp)
|
err = client.Do(req, resp)
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
|
||||||
|
err = types.ErrResponseBodyTooLarge
|
||||||
|
}
|
||||||
|
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := decodeResponseBody(resp, c.maxResponseBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
|
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
|
||||||
}
|
}
|
||||||
@@ -150,6 +177,34 @@ func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, erro
|
|||||||
return &script.HTTPResponse{
|
return &script.HTTPResponse{
|
||||||
Status: resp.StatusCode(),
|
Status: resp.StatusCode(),
|
||||||
Headers: collectRespHeaders(resp),
|
Headers: collectRespHeaders(resp),
|
||||||
Body: string(resp.Body()),
|
Body: string(body),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeResponseBody decodes a body the server compressed, since scripts read it as text.
|
||||||
|
// The limit covers the decoded size, which is what a compression bomb inflates to.
|
||||||
|
// An empty body is left alone because the decoders reject zero bytes, and a HEAD or a 304
|
||||||
|
// carries the encoding with no body. A coding fasthttp does not decode, and the bare
|
||||||
|
// deflate stream some servers send without a zlib header, are left raw for the script.
|
||||||
|
func decodeResponseBody(resp *fasthttp.Response, maxResponseBody int) ([]byte, error) {
|
||||||
|
if len(resp.Header.ContentEncoding()) == 0 || len(resp.Body()) == 0 {
|
||||||
|
return resp.Body(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := resp.BodyUncompressedWithLimit(maxResponseBody)
|
||||||
|
if errors.Is(err, fasthttp.ErrContentEncodingUnsupported) || errors.Is(err, zlib.ErrHeader) {
|
||||||
|
return resp.Body(), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
|
||||||
|
err = types.ErrResponseBodyTooLarge
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither header describes the decoded body, so both go, as net/http does on a gunzip.
|
||||||
|
resp.Header.SetContentEncoding("")
|
||||||
|
resp.Header.Del(fasthttp.HeaderContentLength)
|
||||||
|
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
@@ -270,7 +270,8 @@ func (s sarin) streamProgress(
|
|||||||
stop: stopCtrl.Stop,
|
stop: stopCtrl.Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
program = tea.NewProgram(model)
|
// Sarin handles SIGINT/SIGTERM itself.
|
||||||
|
program = tea.NewProgram(model, tea.WithoutSignalHandler())
|
||||||
} else {
|
} else {
|
||||||
model := infiniteProgressModel{
|
model := infiniteProgressModel{
|
||||||
spinner: spinner.New(
|
spinner: spinner.New(
|
||||||
@@ -300,7 +301,8 @@ func (s sarin) streamProgress(
|
|||||||
quit: false,
|
quit: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
program = tea.NewProgram(model)
|
// Sarin handles SIGINT/SIGTERM itself.
|
||||||
|
program = tea.NewProgram(model, tea.WithoutSignalHandler())
|
||||||
}
|
}
|
||||||
|
|
||||||
stopCtrl.AttachProgram(program)
|
stopCtrl.AttachProgram(program)
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package sarin
|
package sarin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"go.aykhans.me/sarin/internal/script"
|
"go.aykhans.me/sarin/internal/script"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const dryRunResponseKey = "dry-run"
|
const dryRunResponseKey = "dry-run"
|
||||||
@@ -29,6 +33,34 @@ func statusCodeToString(code int) string {
|
|||||||
return strconv.Itoa(code)
|
return strconv.Itoa(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// responseErrorKey drops the local address, whose port would mint a stats entry per connection.
|
||||||
|
func responseErrorKey(err error) string {
|
||||||
|
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
|
||||||
|
return types.ErrResponseBodyTooLarge.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
message := err.Error()
|
||||||
|
|
||||||
|
var opErr *net.OpError
|
||||||
|
if !errors.As(err, &opErr) || opErr.Source == nil {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutSource := *opErr
|
||||||
|
withoutSource.Source = nil
|
||||||
|
return strings.Replace(message, opErr.Error(), withoutSource.Error(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestErrorKey keys a generation failure by its source instead of its message.
|
||||||
|
// A script picks its own URLs and error texts, so the message would mint a new
|
||||||
|
// stats entry per request. The full message still goes to the runtime log.
|
||||||
|
func requestErrorKey(err error) string {
|
||||||
|
if chainErr, ok := errors.AsType[types.ScriptChainError](err); ok {
|
||||||
|
return chainErr.EngineType + " script[" + strconv.Itoa(chainErr.Index) + "] error"
|
||||||
|
}
|
||||||
|
return "request generation error"
|
||||||
|
}
|
||||||
|
|
||||||
func (s sarin) Worker(
|
func (s sarin) Worker(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
@@ -112,7 +144,7 @@ func (s sarin) workerStatsWithDynamic(
|
|||||||
clientIndex := nextClientIndex()
|
clientIndex := nextClientIndex()
|
||||||
bindScriptProxy(clientIndex)
|
bindScriptProxy(clientIndex)
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
s.responses.Add(err.Error(), 0)
|
s.responses.Add(requestErrorKey(err), 0)
|
||||||
sendLog(runtimeLogLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
continue
|
continue
|
||||||
@@ -123,7 +155,7 @@ func (s sarin) workerStatsWithDynamic(
|
|||||||
respDuration := time.Since(startTime)
|
respDuration := time.Since(startTime)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.responses.Add(err.Error(), respDuration)
|
s.responses.Add(responseErrorKey(err), respDuration)
|
||||||
} else {
|
} else {
|
||||||
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||||
sendRespLog(respDuration, resp)
|
sendRespLog(respDuration, resp)
|
||||||
@@ -144,8 +176,9 @@ func (s sarin) workerStatsWithStatic(
|
|||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
// Static request generation failed, so record all jobs as errors
|
// Static request generation failed, so record all jobs as errors
|
||||||
|
errorKey := requestErrorKey(err)
|
||||||
for range jobs {
|
for range jobs {
|
||||||
s.responses.Add(err.Error(), 0)
|
s.responses.Add(errorKey, 0)
|
||||||
sendLog(runtimeLogLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
@@ -157,7 +190,7 @@ func (s sarin) workerStatsWithStatic(
|
|||||||
err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
|
err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
|
||||||
respDuration := time.Since(startTime)
|
respDuration := time.Since(startTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.responses.Add(err.Error(), respDuration)
|
s.responses.Add(responseErrorKey(err), respDuration)
|
||||||
} else {
|
} else {
|
||||||
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||||
sendRespLog(respDuration, resp)
|
sendRespLog(respDuration, resp)
|
||||||
@@ -241,7 +274,7 @@ func (s sarin) workerDryRunStatsWithDynamic(
|
|||||||
bindScriptProxy(nextClientIndex())
|
bindScriptProxy(nextClientIndex())
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
s.responses.Add(err.Error(), time.Since(startTime))
|
s.responses.Add(requestErrorKey(err), time.Since(startTime))
|
||||||
sendLog(runtimeLogLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
continue
|
continue
|
||||||
@@ -260,8 +293,9 @@ func (s sarin) workerDryRunStatsWithStatic(
|
|||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
// Static request generation failed, so record all jobs as errors
|
// Static request generation failed, so record all jobs as errors
|
||||||
|
errorKey := requestErrorKey(err)
|
||||||
for range jobs {
|
for range jobs {
|
||||||
s.responses.Add(err.Error(), 0)
|
s.responses.Add(errorKey, 0)
|
||||||
sendLog(runtimeLogLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ type HTTPDoer interface {
|
|||||||
// httpDefaultMethod is used when the options set no method.
|
// httpDefaultMethod is used when the options set no method.
|
||||||
const httpDefaultMethod = http.MethodGet
|
const httpDefaultMethod = http.MethodGet
|
||||||
|
|
||||||
|
// httpMaxRedirectsLimit is the highest maxRedirects a script may ask for.
|
||||||
|
const httpMaxRedirectsLimit = 100
|
||||||
|
|
||||||
// Option keys accepted by the http function.
|
// Option keys accepted by the http function.
|
||||||
const (
|
const (
|
||||||
httpOptionMethod = "method"
|
httpOptionMethod = "method"
|
||||||
@@ -103,10 +106,13 @@ func (b *httpBridge) do(req *HTTPRequest) (*HTTPResponse, error) {
|
|||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ScriptHTTPOptionError
|
// - types.ScriptHTTPOptionError
|
||||||
func parseHTTPMaxRedirects(count float64) (int, error) {
|
func parseHTTPMaxRedirects(count float64) (int, error) {
|
||||||
if count < 0 || count != math.Trunc(count) {
|
if math.IsNaN(count) || count < 0 || count > httpMaxRedirectsLimit || count != math.Trunc(count) {
|
||||||
return 0, types.NewScriptHTTPOptionError(
|
return 0, types.NewScriptHTTPOptionError(
|
||||||
httpOptionMaxRedirects,
|
httpOptionMaxRedirects,
|
||||||
types.NewScriptTypeError("a whole number of 0 or more", strconv.FormatFloat(count, 'f', -1, 64)),
|
types.NewScriptTypeError(
|
||||||
|
"a whole number between 0 and "+strconv.Itoa(httpMaxRedirectsLimit),
|
||||||
|
strconv.FormatFloat(count, 'g', -1, 64),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return int(count), nil
|
return int(count), nil
|
||||||
|
|||||||
+142
-45
@@ -2,6 +2,9 @@ package script
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
@@ -17,6 +20,9 @@ type JsEngine struct {
|
|||||||
http httpBridge
|
http httpBridge
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// promiseType is what a transform returns when it is declared async or as a generator.
|
||||||
|
var promiseType = reflect.TypeFor[*goja.Promise]()
|
||||||
|
|
||||||
// NewJsEngine creates a new JavaScript script engine with the given script content.
|
// 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
|
// The script must define a global `transform` function that takes a request object
|
||||||
// and returns the modified request object.
|
// and returns the modified request object.
|
||||||
@@ -41,9 +47,8 @@ func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute the script to define the transform function
|
// Execute the script to define the transform function
|
||||||
_, err := vm.RunString(scriptContent)
|
if _, err := vm.RunString(scriptContent); err != nil {
|
||||||
if err != nil {
|
return nil, types.NewScriptExecutionError("JavaScript", engine.exceptionError(err))
|
||||||
return nil, types.NewScriptExecutionError("JavaScript", jsExceptionError(err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the transform function
|
// Get the transform function
|
||||||
@@ -57,6 +62,12 @@ func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
|||||||
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
|
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A generator returns an iterator, which is an ordinary object to Go, so the
|
||||||
|
// call cannot tell it apart and the declaration is the only thing left to read.
|
||||||
|
if isGeneratorFunction(vm, transformVal) {
|
||||||
|
return nil, types.ErrScriptTransformAsync
|
||||||
|
}
|
||||||
|
|
||||||
engine.transform = transform
|
engine.transform = transform
|
||||||
|
|
||||||
return engine, nil
|
return engine, nil
|
||||||
@@ -80,7 +91,14 @@ func (e *JsEngine) Transform(req *RequestData) error {
|
|||||||
// Call transform(req)
|
// Call transform(req)
|
||||||
result, err := e.transform(goja.Undefined(), reqObj)
|
result, err := e.transform(goja.Undefined(), reqObj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewScriptExecutionError("JavaScript", jsExceptionError(err))
|
return types.NewScriptExecutionError("JavaScript", e.exceptionError(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// An async or generator transform returns a promise nothing ever awaits, and a promise
|
||||||
|
// carries none of the request fields, so the request would go out untransformed.
|
||||||
|
// ExportType reads the type alone, while Export would run every getter on the result.
|
||||||
|
if result.ExportType() == promiseType {
|
||||||
|
return types.ErrScriptTransformAsync
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update RequestData from the returned object
|
// Update RequestData from the returned object
|
||||||
@@ -143,37 +161,70 @@ func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error {
|
|||||||
return types.ErrScriptTransformReturnObject
|
return types.ErrScriptTransformReturnObject
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method
|
// Reading a field runs the script's own getters and toString, which throw as a Go
|
||||||
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) {
|
// panic outside goja's execution context, so the whole read runs inside Try.
|
||||||
req.Method = v.String()
|
var fieldErr error
|
||||||
|
if thrown := e.runtime.Try(func() {
|
||||||
|
// Method
|
||||||
|
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Method = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path
|
||||||
|
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Path = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body
|
||||||
|
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Body = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
values, err := objectField("headers", v)
|
||||||
|
if err != nil {
|
||||||
|
fieldErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Headers = e.objectToStringSliceMap(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params
|
||||||
|
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
values, err := objectField("params", v)
|
||||||
|
if err != nil {
|
||||||
|
fieldErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Params = e.objectToStringSliceMap(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookies
|
||||||
|
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
values, err := objectField("cookies", v)
|
||||||
|
if err != nil {
|
||||||
|
fieldErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Cookies = e.objectToStringSliceMap(values)
|
||||||
|
}
|
||||||
|
}); thrown != nil {
|
||||||
|
return e.exceptionError(thrown)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path
|
return fieldErr
|
||||||
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) {
|
}
|
||||||
req.Path = v.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Body
|
// objectField rejects anything that is not a plain object, since reading keys off an
|
||||||
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) {
|
// array or a boxed string yields headers named after indexes and off a Date yields none.
|
||||||
req.Body = v.String()
|
// A Map, a Set and a typed array still pass, so the field is emptied rather than refused.
|
||||||
|
func objectField(name string, value goja.Value) (*goja.Object, error) {
|
||||||
|
obj, ok := value.(*goja.Object)
|
||||||
|
if !ok || obj.ClassName() != "Object" {
|
||||||
|
return nil, fmt.Errorf("%s must be an object", name)
|
||||||
}
|
}
|
||||||
|
return obj, nil
|
||||||
// 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.
|
// stringSliceToArray converts a Go []string to a JavaScript array.
|
||||||
@@ -195,32 +246,78 @@ func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string
|
|||||||
result := make(map[string][]string)
|
result := make(map[string][]string)
|
||||||
for _, key := range obj.Keys() {
|
for _, key := range obj.Keys() {
|
||||||
v := obj.Get(key)
|
v := obj.Get(key)
|
||||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's an array
|
// Check if it's an array
|
||||||
if arr, ok := v.Export().([]any); ok {
|
if arr, ok := v.(*goja.Object); ok && arr.ClassName() == "Array" {
|
||||||
var values []string
|
length := int(arr.Get("length").ToInteger())
|
||||||
for _, item := range arr {
|
values := make([]string, 0, length)
|
||||||
if s, ok := item.(string); ok {
|
for i := range length {
|
||||||
values = append(values, s)
|
if text, ok := jsStringValue(arr.Get(strconv.Itoa(i))); ok {
|
||||||
|
values = append(values, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result[key] = values
|
result[key] = values
|
||||||
} else {
|
continue
|
||||||
// Single value, wrap it in a slice
|
}
|
||||||
result[key] = []string{v.String()}
|
|
||||||
|
// Single value, wrap it in a slice
|
||||||
|
if text, ok := jsStringValue(v); ok {
|
||||||
|
result[key] = []string{text}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// jsExceptionError keeps the thrown value's message without goja's stack frame.
|
// exceptionError keeps the thrown value's message without goja's stack frame.
|
||||||
func jsExceptionError(err error) error {
|
// Stringifying runs through Runtime.Try because a thrown value with no usable
|
||||||
|
// toString makes goja panic outside its own execution context.
|
||||||
|
func (e *JsEngine) exceptionError(err error) error {
|
||||||
var exception *goja.Exception
|
var exception *goja.Exception
|
||||||
if !errors.As(err, &exception) {
|
if !errors.As(err, &exception) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return errors.New(exception.Value().String())
|
|
||||||
|
var message string
|
||||||
|
if thrown := e.runtime.Try(func() { message = exception.Value().String() }); thrown != nil {
|
||||||
|
message = "unprintable thrown value"
|
||||||
|
}
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsStringValue renders a JavaScript primitive the way the script would see it.
|
||||||
|
// Objects, arrays and functions have no useful text form, so they are skipped.
|
||||||
|
func jsStringValue(v goja.Value) (string, bool) {
|
||||||
|
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if _, isObject := v.(*goja.Object); isObject {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return v.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isGeneratorFunction reports whether a function is declared with a star.
|
||||||
|
// The name comes from the script's own prototype chain, so a script can falsify it either way.
|
||||||
|
// Reading it can run a script getter, so the whole walk stays inside Try.
|
||||||
|
func isGeneratorFunction(runtime *goja.Runtime, value goja.Value) bool {
|
||||||
|
var name string
|
||||||
|
thrown := runtime.Try(func() {
|
||||||
|
obj := value.ToObject(runtime)
|
||||||
|
if obj == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
constructor := obj.Get("constructor")
|
||||||
|
if constructor == nil || goja.IsUndefined(constructor) || goja.IsNull(constructor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
constructorObj := constructor.ToObject(runtime)
|
||||||
|
if constructorObj == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if value := constructorObj.Get("name"); value != nil {
|
||||||
|
name = value.String()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return thrown == nil && name == "GeneratorFunction"
|
||||||
}
|
}
|
||||||
@@ -159,6 +159,16 @@ func (e *JsEngine) httpResponseToObject(resp *HTTPResponse) *goja.Object {
|
|||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
})
|
})
|
||||||
|
// body is a JavaScript string, so bytes that are not valid UTF-8 reach the script
|
||||||
|
// only through this.
|
||||||
|
_ = obj.Set("bytes", func(goja.FunctionCall) goja.Value {
|
||||||
|
buffer := rt.NewArrayBuffer([]byte(resp.Body))
|
||||||
|
value, err := rt.New(rt.Get("Uint8Array").ToObject(rt), rt.ToValue(buffer))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
|
||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-11
@@ -168,20 +168,33 @@ func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
|
|||||||
}
|
}
|
||||||
key := string(k.(lua.LString))
|
key := string(k.(lua.LString))
|
||||||
|
|
||||||
switch v.Type() {
|
if v.Type() == lua.LTTable {
|
||||||
case lua.LTString:
|
// Array of values, keys outside 1..n are skipped
|
||||||
// Single string value
|
arr := v.(*lua.LTable)
|
||||||
result[key] = []string{string(v.(lua.LString))}
|
length := arr.Len()
|
||||||
case lua.LTTable:
|
values := make([]string, 0, length)
|
||||||
// Array of strings
|
for i := 1; i <= length; i++ {
|
||||||
var values []string
|
if text, ok := luaScalarString(arr.RawGetInt(i)); ok {
|
||||||
v.(*lua.LTable).ForEach(func(_, item lua.LValue) {
|
values = append(values, text)
|
||||||
if item.Type() == lua.LTString {
|
|
||||||
values = append(values, string(item.(lua.LString)))
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
result[key] = values
|
result[key] = values
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if text, ok := luaScalarString(v); ok {
|
||||||
|
result[key] = []string{text}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// luaScalarString renders strings, numbers and booleans as text.
|
||||||
|
func luaScalarString(v lua.LValue) (string, bool) {
|
||||||
|
switch v.Type() {
|
||||||
|
case lua.LTString, lua.LTNumber, lua.LTBool:
|
||||||
|
return v.String(), true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,9 +45,14 @@ func luaJSONDecode(state *lua.LState) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// luaJSONMaxDepth matches the depth encoding/json accepts, so a table a script
|
||||||
|
// decoded always encodes again, and it stays far below what overflows the Go stack.
|
||||||
|
const luaJSONMaxDepth = 10000
|
||||||
|
|
||||||
// luaToGo converts a Lua value to a JSON-encodable Go value.
|
// luaToGo converts a Lua value to a JSON-encodable Go value.
|
||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ErrScriptJSONCycle
|
// - types.ErrScriptJSONCycle
|
||||||
|
// - types.ErrScriptJSONDepth
|
||||||
// - types.ScriptTypeError
|
// - types.ScriptTypeError
|
||||||
func luaToGo(value lua.LValue, visiting map[*lua.LTable]bool) (any, error) {
|
func luaToGo(value lua.LValue, visiting map[*lua.LTable]bool) (any, error) {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
@@ -63,6 +68,12 @@ func luaToGo(value lua.LValue, visiting map[*lua.LTable]bool) (any, error) {
|
|||||||
if visiting[v] {
|
if visiting[v] {
|
||||||
return nil, types.ErrScriptJSONCycle
|
return nil, types.ErrScriptJSONCycle
|
||||||
}
|
}
|
||||||
|
// visiting holds the tables on the current path, so its size is the depth.
|
||||||
|
// A table millions of levels deep overflows the Go stack, which is a fatal
|
||||||
|
// error no pcall can catch, so the descent is bounded well before that.
|
||||||
|
if len(visiting) >= luaJSONMaxDepth {
|
||||||
|
return nil, types.ErrScriptJSONDepth
|
||||||
|
}
|
||||||
visiting[v] = true
|
visiting[v] = true
|
||||||
defer delete(visiting, v)
|
defer delete(visiting, v)
|
||||||
|
|
||||||
|
|||||||
@@ -171,10 +171,18 @@ func fetchURL(ctx context.Context, url string) (string, error) {
|
|||||||
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
// A declared size above the limit is refused before anything is read.
|
||||||
|
if resp.ContentLength > types.RemoteFetchLimit {
|
||||||
|
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", types.NewHTTPFetchError(url, err)
|
return "", types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
if len(data) > types.RemoteFetchLimit {
|
||||||
|
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ByteSizeLimit is the largest size any option may ask for. A limit is also the most
|
||||||
|
// memory one response may take, so it stays well under what a machine can hold.
|
||||||
|
const ByteSizeLimit = 1 << 30 // 1 GiB
|
||||||
|
|
||||||
|
// RemoteFetchLimit bounds a file sarin downloads for itself, such as a config file or a script.
|
||||||
|
const RemoteFetchLimit = 64 << 20 // 64 MiB
|
||||||
|
|
||||||
|
// byteSizeUnits run from the largest scale down, so formatting picks the largest unit
|
||||||
|
// that fits. A bare "K" is out: the common libraries disagree on whether it counts
|
||||||
|
// 1000 or 1024 bytes.
|
||||||
|
var byteSizeUnits = []struct {
|
||||||
|
suffix string
|
||||||
|
scale uint64
|
||||||
|
}{
|
||||||
|
{"TiB", 1 << 40},
|
||||||
|
{"TB", 1000 * 1000 * 1000 * 1000},
|
||||||
|
{"GiB", 1 << 30},
|
||||||
|
{"GB", 1000 * 1000 * 1000},
|
||||||
|
{"MiB", 1 << 20},
|
||||||
|
{"MB", 1000 * 1000},
|
||||||
|
{"KiB", 1 << 10},
|
||||||
|
{"KB", 1000},
|
||||||
|
{"B", 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseByteSize reads a byte count, with or without a unit suffix.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - ErrByteSizeInvalid
|
||||||
|
func ParseByteSize(value string) (uint64, error) {
|
||||||
|
text := strings.TrimSpace(value)
|
||||||
|
|
||||||
|
// The suffix is split off by index, so no case mapping can shift the digits.
|
||||||
|
digitsEnd := len(text)
|
||||||
|
for digitsEnd > 0 && isASCIILetter(text[digitsEnd-1]) {
|
||||||
|
digitsEnd--
|
||||||
|
}
|
||||||
|
|
||||||
|
scale := uint64(1)
|
||||||
|
if suffix := text[digitsEnd:]; suffix != "" {
|
||||||
|
unitScale, ok := byteSizeScale(suffix)
|
||||||
|
if !ok {
|
||||||
|
return 0, ErrByteSizeInvalid
|
||||||
|
}
|
||||||
|
scale = unitScale
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := strconv.ParseUint(strings.TrimSpace(text[:digitsEnd]), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrByteSizeInvalid
|
||||||
|
}
|
||||||
|
if size > math.MaxUint64/scale {
|
||||||
|
return 0, ErrByteSizeInvalid
|
||||||
|
}
|
||||||
|
return size * scale, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatByteSize renders a byte count so that ParseByteSize reads back the same value.
|
||||||
|
func FormatByteSize(size uint64) string {
|
||||||
|
for _, unit := range byteSizeUnits {
|
||||||
|
if unit.scale > 1 && size >= unit.scale && size%unit.scale == 0 {
|
||||||
|
return strconv.FormatUint(size/unit.scale, 10) + unit.suffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(size, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// byteSizeScale looks up a unit suffix, ignoring case.
|
||||||
|
func byteSizeScale(suffix string) (uint64, bool) {
|
||||||
|
for _, unit := range byteSizeUnits {
|
||||||
|
if strings.EqualFold(suffix, unit.suffix) {
|
||||||
|
return unit.scale, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIILetter(char byte) bool {
|
||||||
|
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
|
||||||
|
}
|
||||||
@@ -10,6 +10,17 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
errNoError = errors.New("no error (internal)")
|
errNoError = errors.New("no error (internal)")
|
||||||
|
|
||||||
|
ErrByteSizeInvalid = errors.New(
|
||||||
|
"invalid value for size, expected a whole number of bytes or a whole number with a unit: " +
|
||||||
|
"B, KB, MB, GB, TB (1000 based) or KiB, MiB, GiB, TiB (1024 based), e.g. '1048576', '10MiB', '2MB'",
|
||||||
|
)
|
||||||
|
|
||||||
|
ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit))
|
||||||
|
|
||||||
|
ErrResponseBodyTooLarge = errors.New("response body exceeds the body limit")
|
||||||
|
|
||||||
|
ErrYAMLMultipleDocuments = errors.New("expected a single YAML document, but found more than one")
|
||||||
)
|
)
|
||||||
|
|
||||||
type FieldParseError struct {
|
type FieldParseError struct {
|
||||||
@@ -297,6 +308,27 @@ func (e CLIUnexpectedArgsError) Error() string {
|
|||||||
|
|
||||||
// ======================================== Config File ========================================
|
// ======================================== Config File ========================================
|
||||||
|
|
||||||
|
// ConfigFileError names the file a parse error came from, which nested configFile chains lose.
|
||||||
|
type ConfigFileError struct {
|
||||||
|
Path string
|
||||||
|
error error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigFileError(path string, err error) ConfigFileError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ConfigFileError{path, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ConfigFileError) Error() string {
|
||||||
|
return e.error.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ConfigFileError) Unwrap() error {
|
||||||
|
return e.error
|
||||||
|
}
|
||||||
|
|
||||||
type ConfigFileReadError struct {
|
type ConfigFileReadError struct {
|
||||||
error error
|
error error
|
||||||
}
|
}
|
||||||
@@ -402,6 +434,7 @@ var (
|
|||||||
ErrScriptSourceEmpty = errors.New("script source cannot be empty after @")
|
ErrScriptSourceEmpty = errors.New("script source cannot be empty after @")
|
||||||
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
|
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
|
||||||
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
|
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
|
||||||
|
ErrScriptTransformAsync = errors.New("transform function must be synchronous, async and generator functions are not supported")
|
||||||
ErrScriptURLNoHost = errors.New("script URL must have a host")
|
ErrScriptURLNoHost = errors.New("script URL must have a host")
|
||||||
ErrScriptHTTPOutsideTransform = errors.New("http functions can only be called while transform is running")
|
ErrScriptHTTPOutsideTransform = errors.New("http functions can only be called while transform is running")
|
||||||
ErrScriptHTTPUnavailable = errors.New("http client is not available")
|
ErrScriptHTTPUnavailable = errors.New("http client is not available")
|
||||||
@@ -409,6 +442,7 @@ var (
|
|||||||
ErrScriptHTTPURLInvalid = errors.New("URL must be an absolute http or https URL")
|
ErrScriptHTTPURLInvalid = errors.New("URL must be an absolute http or https URL")
|
||||||
ErrScriptHTTPUnknownOption = errors.New("unknown option")
|
ErrScriptHTTPUnknownOption = errors.New("unknown option")
|
||||||
ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself")
|
ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself")
|
||||||
|
ErrScriptJSONDepth = errors.New("cannot encode a table nested too deeply")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ScriptTypeError struct {
|
type ScriptTypeError struct {
|
||||||
|
|||||||
+2
-2
@@ -8,11 +8,11 @@
|
|||||||
|
|
||||||
(buildGoModule.override { go = go_1_27; }) (finalAttrs: {
|
(buildGoModule.override { go = go_1_27; }) (finalAttrs: {
|
||||||
pname = "sarin";
|
pname = "sarin";
|
||||||
version = "1.4.4"; # bump per release
|
version = "1.5.0"; # bump per release
|
||||||
|
|
||||||
src = lib.cleanSource ../.;
|
src = lib.cleanSource ../.;
|
||||||
|
|
||||||
vendorHash = "sha256-KUxzFDxYbKvKASMze+f7jxfpCQbs01ezeG6a1VboHxw=";
|
vendorHash = "sha256-IlswpF2Qmgi62timxd/xppg3tAyoQFdzkbda4nBFwE0=";
|
||||||
|
|
||||||
subPackages = [ "cmd/cli" ];
|
subPackages = [ "cmd/cli" ];
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user