Compare commits

Author SHA1 Message Date
aykhans a7b5f73069 Add benchmark suite comparing sarin, wrk, and bombardier 2026-03-22 22:30:47 +04:00
35 changed files with 1753 additions and 2982 deletions
+4 -4
View File
@@ -13,10 +13,10 @@ jobs:
name: lint name: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v5
- uses: actions/setup-go@v7 - uses: actions/setup-go@v6
with: with:
go-version: 1.26.5 go-version: 1.26.1
- name: go fix - name: go fix
run: | run: |
go fix ./... go fix ./...
@@ -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.12.2 version: v2.11.2
-172
View File
@@ -1,172 +0,0 @@
name: nix
on:
push:
branches:
- main
pull_request:
paths:
- "go.mod"
- "go.sum"
- "nix/**"
- "flake.nix"
- "flake.lock"
workflow_call:
inputs:
ref:
description: "Ref to check out. Defaults to the triggering ref."
type: string
required: false
permissions:
contents: read
concurrency:
group: nix-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
# Keyed on the PR author, which is stable across the fix-up push, so the run
# started by that push cannot cancel the run that made it.
cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' }}
env:
SELF_HOSTED_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
IS_DEPENDABOT: ${{ github.actor == 'dependabot[bot]' }}
jobs:
build:
name: build
runs-on: ubuntu-latest
permissions:
# The only write is git push, which authenticates with the PAT that
# checkout persists, not with GITHUB_TOKEN.
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
# head.sha, not the merge ref: the hash is computed for the tree that
# gets pushed, and the run that verifies it must build that same tree.
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }}
token: ${{ (env.IS_DEPENDABOT == 'true' && secrets.DEPENDABOT) || secrets.GITHUB_TOKEN }}
- uses: cachix/install-nix-action@v31
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: nix build
id: build
continue-on-error: ${{ env.SELF_HOSTED_PR == 'true' }}
run: nix build .#default --no-link --print-build-logs
- name: Recompute vendorHash
id: hash
if: steps.build.outcome == 'failure' && env.SELF_HOSTED_PR == 'true'
run: |
nix run nixpkgs#nix-update -- --flake --version=skip --no-src default
# An unchanged hash means the build broke for some other reason. Say
# nothing and let the job fail on the real error.
if git diff --quiet -- nix/package.nix; then
echo "stale=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "stale=true" >> "$GITHUB_OUTPUT"
sed -n 's/^[[:space:]]*vendorHash = "\([^"]*\)".*/hash=\1/p' nix/package.nix >> "$GITHUB_OUTPUT"
- name: Push vendorHash fix
if: steps.hash.outputs.stale == 'true' && env.IS_DEPENDABOT == 'true'
env:
HEAD_REF: ${{ github.head_ref }}
PUSH_TOKEN: ${{ secrets.DEPENDABOT }}
run: |
if [ -z "$PUSH_TOKEN" ]; then
echo "::error::the DEPENDABOT push token is missing from the Dependabot secret store"
exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add nix/package.nix
git commit -m "fix(nix): update vendorHash [dependabot skip]"
git push origin "HEAD:refs/heads/$HEAD_REF"
echo "::notice::vendorHash updated and pushed; the run on the new commit decides this PR"
- name: Comment stale vendorHash
if: steps.hash.outputs.stale == 'true' && env.IS_DEPENDABOT != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
HASH: ${{ steps.hash.outputs.hash }}
run: |
marker='<!-- vendorhash-bot -->'
body=$(cat <<EOF
$marker
### \`nix/package.nix\` vendorHash is stale
This branch changes the Go dependencies, so the vendor derivation no longer matches the pinned hash and \`nix build\` fails.
\`\`\`nix
vendorHash = "$HASH";
\`\`\`
Run \`task nix-hash\` locally and commit the result, or paste the hash above.
EOF
)
# Keep one sticky comment instead of a new one on every push. Without
# the explicit check a failed lookup reads as "no comment yet" and
# posts a duplicate; piping into head would hide the exit status.
if ! matches=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments?per_page=100" \
--jq ".[] | select(.user.login == \"github-actions[bot]\" and ((.body // \"\") | contains(\"$marker\"))) | .id"); then
echo "::error::could not list the pull request comments"
exit 1
fi
id=$(printf '%s\n' "$matches" | head -n 1)
if [ -n "$id" ]; then
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
else
gh pr comment "$PR" --body "$body"
fi
# Once the hash is fixed the build passes and the step above stops running,
# so without this the comment keeps claiming the branch is broken under a
# green check.
- name: Resolve stale vendorHash comment
# Gated on the hash rather than the build: a later push can fix the hash
# while the build still fails for an unrelated reason, and the comment
# must stop blaming the vendor derivation either way. Skipped when the
# recompute itself errored, since then we do not know.
if: ${{ !cancelled() && env.SELF_HOSTED_PR == 'true' && steps.hash.outputs.stale != 'true' && steps.hash.outcome != 'failure' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
BUILD: ${{ steps.build.outcome }}
run: |
marker='<!-- vendorhash-bot -->'
if ! matches=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments?per_page=100" \
--jq ".[] | select(.user.login == \"github-actions[bot]\" and ((.body // \"\") | contains(\"$marker\"))) | .id"); then
echo "::error::could not list the pull request comments"
exit 1
fi
id=$(printf '%s\n' "$matches" | head -n 1)
[ -n "$id" ] || exit 0
if [ "$BUILD" = "success" ]; then
detail='`nix build` passes on this branch.'
else
detail='The build is failing for another reason; see the workflow log.'
fi
body=$(cat <<EOF
$marker
### \`nix/package.nix\` vendorHash is up to date
$detail
EOF
)
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
- name: Report build failure
if: ${{ !cancelled() && steps.build.outcome == 'failure' && !(env.IS_DEPENDABOT == 'true' && steps.hash.outputs.stale == 'true') }}
run: |
echo "::error file=nix/package.nix::nix build failed; see the log above"
exit 1
+8 -49
View File
@@ -21,52 +21,13 @@ permissions:
contents: write contents: write
jobs: jobs:
nix:
name: Verify Nix package
uses: ./.github/workflows/nix.yaml
permissions:
contents: read
pull-requests: write
with:
ref: ${{ inputs.tag || github.ref }}
# nix/package.nix carries its own version string that has to be bumped by
# hand; catch a forgotten bump before the tag ships.
version:
name: Verify package version
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ inputs.tag || github.ref }}
- name: Check nix/package.nix matches the release tag
env:
TAG: ${{ inputs.tag || github.ref_name }}
run: |
expected="${TAG#v}"
actual="$(sed -n 's/^[[:space:]]*version = "\([^"]*\)".*/\1/p' nix/package.nix | head -1)"
if [ -z "$actual" ]; then
echo "::error file=nix/package.nix::could not read the version string"
exit 1
fi
if [ "$actual" != "$expected" ]; then
echo "::error file=nix/package.nix::version is '$actual' but the release tag is '$TAG' (expected '$expected')"
exit 1
fi
echo "nix/package.nix version '$actual' matches tag '$TAG'"
build: build:
name: Build binaries name: Build binaries
needs: [nix, version]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v7 uses: actions/checkout@v6
with: with:
ref: ${{ inputs.tag || github.ref }} ref: ${{ inputs.tag || github.ref }}
@@ -74,11 +35,11 @@ jobs:
run: | run: |
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "GO_VERSION=1.26.5" >> $GITHUB_ENV echo "GO_VERSION=1.26.1" >> $GITHUB_ENV
- name: Set up Go - name: Set up Go
if: github.event_name == 'release' || inputs.build_binaries if: github.event_name == 'release' || inputs.build_binaries
uses: actions/setup-go@v7 uses: actions/setup-go@v6
with: with:
go-version: ${{ env.GO_VERSION }} go-version: ${{ env.GO_VERSION }}
cache: true cache: true
@@ -94,8 +55,6 @@ jobs:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-freebsd-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-freebsd-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
@@ -103,29 +62,29 @@ jobs:
- name: Upload Release Assets - name: Upload Release Assets
if: github.event_name == 'release' || inputs.build_binaries if: github.event_name == 'release' || inputs.build_binaries
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ inputs.tag || github.ref_name }} tag_name: ${{ inputs.tag || github.ref_name }}
files: ./sarin-* files: ./sarin-*
- name: Set up QEMU - name: Set up QEMU
if: github.event_name == 'release' || inputs.build_docker if: github.event_name == 'release' || inputs.build_docker
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
if: github.event_name == 'release' || inputs.build_docker if: github.event_name == 'release' || inputs.build_docker
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
if: github.event_name == 'release' || inputs.build_docker if: github.event_name == 'release' || inputs.build_docker
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
if: github.event_name == 'release' || inputs.build_docker if: github.event_name == 'release' || inputs.build_docker
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
-4
View File
@@ -1,5 +1 @@
bin/* bin/*
# Nix build artifacts
result
result-*
-5
View File
@@ -81,11 +81,6 @@ linters:
- staticcheck - staticcheck
text: "SA5011" text: "SA5011"
- linters:
- errcheck
- gosec
source: "lipgloss\\.Println\\("
formatters: formatters:
enable: enable:
- gofmt - gofmt
+1 -1
View File
@@ -1,4 +1,4 @@
ARG GO_VERSION=1.26.5 ARG GO_VERSION=1.26.1
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
+11 -50
View File
@@ -3,9 +3,7 @@
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp. ## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
[![Go Reference](https://pkg.go.dev/badge/go.aykhans.me/sarin.svg)](https://pkg.go.dev/go.aykhans.me/sarin) [![Go Reference](https://pkg.go.dev/badge/go.aykhans.me/sarin.svg)](https://pkg.go.dev/go.aykhans.me/sarin)
[![Docker Pulls](https://img.shields.io/docker/pulls/aykhans/sarin)](https://hub.docker.com/r/aykhans/sarin) [![Go Report Card](https://goreportcard.com/badge/go.aykhans.me/sarin)](https://goreportcard.com/report/go.aykhans.me/sarin)
[![Downloads](https://img.shields.io/github/downloads/aykhans/sarin/total?cacheSeconds=3600)](https://github.com/aykhans/sarin/releases)
[![Lint](https://img.shields.io/github/actions/workflow/status/aykhans/sarin/lint.yaml?branch=main&label=lint)](https://github.com/aykhans/sarin/actions/workflows/lint.yaml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
</div> </div>
@@ -22,23 +20,23 @@
## Overview ## Overview
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity and features like templating add zero overhead when unused. Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicityfeatures like templating add zero overhead when unused.
| ✅ Supported | ❌ Not Supported | | ✅ Supported | ❌ Not Supported |
| ---------------------------------------------------------- | ------------------------------- | | ---------------------------------------------------------- | ------------------------------- |
| High-performance with low memory footprint | Web UI or complex TUI | | High-performance with low memory footprint | Detailed response body analysis |
| Dynamic requests via 340+ template functions | Detailed response body analysis | | Long-running duration/count based tests | Extensive response statistics |
| Dynamic requests via 320+ template functions | Web UI or complex TUI |
| Request scripting with Lua and JavaScript | Distributed load testing | | Request scripting with Lua and JavaScript | Distributed load testing |
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC | | Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
| Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem | | Flexible config (CLI, ENV, YAML) | Plugins / extensions ecosystem |
## Installation ## Installation
<details open> ### Docker (Recommended)
<summary><b>Docker</b></summary>
```sh ```sh
docker run --rm -it aykhans/sarin:latest --version docker pull aykhans/sarin:latest
``` ```
With a local config file: With a local config file:
@@ -53,46 +51,11 @@ With a remote config file:
docker run --rm -it aykhans/sarin -f https://example.com/config.yaml docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
``` ```
</details> ### Pre-built Binaries
<details>
<summary><b>Nix</b></summary>
Run directly without installing (requires flakes enabled):
```sh
nix run github:aykhans/sarin -- -U http://example.com -r 100 -c 10
```
Install into your profile:
```sh
nix profile install github:aykhans/sarin
```
Or add it to your own flake via the overlay:
```nix
{
inputs.sarin.url = "github:aykhans/sarin";
# In your outputs, apply the overlay to nixpkgs:
# nixpkgs.overlays = [ inputs.sarin.overlays.default ];
# then reference pkgs.sarin
}
```
</details>
<details>
<summary><b>Pre-built Binaries</b></summary>
Download the latest binaries from the [releases](https://github.com/aykhans/sarin/releases) page. Download the latest binaries from the [releases](https://github.com/aykhans/sarin/releases) page.
</details> ### Building from Source
<details>
<summary><b>Building from Source</b></summary>
Requires [Go 1.26+](https://golang.org/dl/). Requires [Go 1.26+](https://golang.org/dl/).
@@ -108,8 +71,6 @@ CGO_ENABLED=0 go build \
-o sarin ./cmd/cli/main.go -o sarin ./cmd/cli/main.go
``` ```
</details>
## Quick Start ## Quick Start
Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request: Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request:
@@ -144,7 +105,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c
## Templating ## Templating
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 340+ built-in functions to generate dynamic data for each request. Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request.
**Example:** **Example:**
+2 -7
View File
@@ -3,7 +3,7 @@ version: "3"
vars: vars:
BIN_DIR: ./bin BIN_DIR: ./bin
GOLANGCI_LINT_VERSION: v2.12.2 GOLANGCI_LINT_VERSION: v2.11.2
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}" GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
tasks: tasks:
@@ -39,11 +39,6 @@ tasks:
cmds: cmds:
- "{{.GOLANGCI}} run" - "{{.GOLANGCI}} run"
nix-hash:
desc: Recompute nix/package.nix vendorHash after a dependency change.
cmds:
- nix run nixpkgs#nix-update -- --flake --version=skip --no-src default
test: test:
desc: Run Go tests. desc: Run Go tests.
cmds: cmds:
@@ -79,7 +74,7 @@ tasks:
- test -f {{.GOLANGCI}} - test -f {{.GOLANGCI}}
cmds: cmds:
- rm -f {{.GOLANGCI}} - rm -f {{.GOLANGCI}}
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/main/install.sh | sh -s -- -b {{.BIN_DIR}} {{.GOLANGCI_LINT_VERSION}} - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b {{.BIN_DIR}} {{.GOLANGCI_LINT_VERSION}}
- mv {{.BIN_DIR}}/golangci-lint {{.GOLANGCI}} - mv {{.BIN_DIR}}/golangci-lint {{.GOLANGCI}}
docker-build: docker-build:
+42
View File
@@ -0,0 +1,42 @@
# Benchmark
Compares [sarin](https://github.com/aykhans/sarin), [wrk](https://github.com/wg/wrk), and [bombardier](https://github.com/codesenberg/bombardier) against a minimal C HTTP server using epoll.
## Requirements
- `sarin`, `wrk`, `bombardier` in PATH
- `gcc`
## Usage
```bash
./benchmark/run.sh
```
Configuration is at the top of `run.sh`:
```bash
DURATION="30s"
CONNECTIONS=(50 100 200)
ITERATIONS=3
```
## Structure
```
benchmark/
run.sh - benchmark script
server/
server.c - C epoll HTTP server (returns "ok")
results/ - output directory (auto-created)
```
## Output
Each run produces per-tool files:
- `*.out` - tool stdout (throughput, latency)
- `*.time` - `/usr/bin/time -v` output (peak memory, CPU time)
- `*_resources.csv` - sampled CPU/memory during run
A summary table is printed at the end with requests/sec, total requests, elapsed time, and peak memory.
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env bash
set -euo pipefail
# ─── Configuration ───────────────────────────────────────────────────────────
SERVER_PORT=8080
SERVER_URL="http://127.0.0.1:${SERVER_PORT}/"
DURATION="30s"
CONNECTIONS=(50 100 200)
ITERATIONS=3
WARMUP_DURATION="5s"
RESULTS_DIR="benchmark/results/$(date +%Y%m%d_%H%M%S)"
# ─── Colors ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[✗]${NC} $*" >&2; }
header() { echo -e "\n${BOLD}${CYAN}═══ $* ═══${NC}\n"; }
# ─── Dependency checks ──────────────────────────────────────────────────────
check_deps() {
local missing=()
for cmd in wrk bombardier sarin gcc; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("$cmd")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
err "Missing dependencies: ${missing[*]}"
echo "Install them before running this benchmark."
exit 1
fi
log "All dependencies found"
}
# ─── Build & manage the C server ────────────────────────────────────────────
build_server() {
header "Building C HTTP server"
gcc -O3 -o benchmark/server/bench-server benchmark/server/server.c
log "Server built successfully"
}
start_server() {
log "Starting server on port ${SERVER_PORT}..."
benchmark/server/bench-server &
SERVER_PID=$!
sleep 1
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
err "Server failed to start"
exit 1
fi
log "Server running (PID: ${SERVER_PID})"
}
stop_server() {
if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
log "Server stopped"
fi
}
trap stop_server EXIT
# ─── Resource monitoring ────────────────────────────────────────────────────
start_monitor() {
local tool_name=$1
local conns=$2
local iter=$3
local monitor_file="${RESULTS_DIR}/${tool_name}_c${conns}_i${iter}_resources.csv"
echo "timestamp,cpu%,mem_kb" > "$monitor_file"
(
while true; do
# Find the PID of the tool by name (exclude monitor itself)
local pid
pid=$(pgrep -x "$tool_name" 2>/dev/null | head -1) || true
if [[ -n "$pid" ]]; then
local stats
stats=$(ps -p "$pid" -o %cpu=,%mem=,rss= 2>/dev/null) || true
if [[ -n "$stats" ]]; then
local cpu mem_kb
cpu=$(echo "$stats" | awk '{print $1}')
mem_kb=$(echo "$stats" | awk '{print $3}')
echo "$(date +%s),$cpu,$mem_kb" >> "$monitor_file"
fi
fi
sleep 0.5
done
) &
MONITOR_PID=$!
}
stop_monitor() {
if [[ -n "${MONITOR_PID:-}" ]] && kill -0 "$MONITOR_PID" 2>/dev/null; then
kill "$MONITOR_PID" 2>/dev/null || true
wait "$MONITOR_PID" 2>/dev/null || true
fi
}
# ─── Benchmark runners ─────────────────────────────────────────────────────
run_wrk() {
local conns=$1
local dur=$2
local out_file=$3
local threads=$((conns < 10 ? conns : 10))
/usr/bin/time -v wrk -t"${threads}" -c"${conns}" -d"${dur}" "${SERVER_URL}" \
2>"${out_file}.time" | tee "${out_file}.out"
}
run_bombardier() {
local conns=$1
local dur=$2
local out_file=$3
/usr/bin/time -v bombardier -c "${conns}" -d "${dur}" --print result "${SERVER_URL}" \
2>"${out_file}.time" | tee "${out_file}.out"
}
run_sarin() {
local conns=$1
local dur=$2
local out_file=$3
/usr/bin/time -v sarin -U "${SERVER_URL}" -c "${conns}" -d "${dur}" -q \
2>"${out_file}.time" | tee "${out_file}.out"
}
# ─── Warmup ──────────────────────────────────────────────────────────────────
warmup() {
header "Warming up server"
wrk -t4 -c50 -d"${WARMUP_DURATION}" "${SERVER_URL}" > /dev/null 2>&1
log "Warmup complete"
sleep 2
}
# ─── Extract peak memory from /usr/bin/time -v output ────────────────────────
extract_peak_mem() {
local time_file=$1
grep "Maximum resident set size" "$time_file" 2>/dev/null | awk '{print $NF}' || echo "N/A"
}
# ─── Extract total requests from tool output ─────────────────────────────────
extract_requests() {
local tool=$1
local out_file=$2
case "$tool" in
wrk)
# wrk: "312513 requests in 2.10s, ..."
grep "requests in" "$out_file" 2>/dev/null | awk '{print $1}' || echo "N/A"
;;
bombardier)
# bombardier: "1xx - 0, 2xx - 100000, 3xx - 0, 4xx - 0, 5xx - 0"
# Sum all HTTP code counts
grep -E "^\s+1xx" "$out_file" 2>/dev/null | \
awk -F'[,-]' '{sum=0; for(i=1;i<=NF;i++){gsub(/[^0-9]/,"",$i); if($i+0>0) sum+=$i} print sum}' || echo "N/A"
;;
sarin)
# sarin table: "│ Total │ 1556177 │ ..."
grep -i "total" "$out_file" 2>/dev/null | awk -F'│' '{gsub(/[[:space:]]/, "", $3); print $3}' || echo "N/A"
;;
esac
}
extract_elapsed() {
local time_file=$1
grep "wall clock" "$time_file" 2>/dev/null | awk '{print $NF}' || echo "N/A"
}
extract_rps() {
local tool=$1
local out_file=$2
case "$tool" in
wrk)
# wrk: "Requests/sec: 12345.67"
grep "Requests/sec" "$out_file" 2>/dev/null | awk '{print $2}' || echo "N/A"
;;
bombardier)
# bombardier: "Reqs/sec 12345.67 ..."
grep -i "reqs/sec" "$out_file" 2>/dev/null | awk '{print $2}' || echo "N/A"
;;
sarin)
# sarin doesn't output rps - calculate from total requests and duration
local total
total=$(extract_requests "sarin" "$out_file")
if [[ "$total" != "N/A" && -n "$total" ]]; then
local dur_secs
dur_secs=$(echo "$DURATION" | sed 's/s$//')
awk "BEGIN {printf \"%.2f\", $total / $dur_secs}"
else
echo "N/A"
fi
;;
esac
}
# ─── Print comparison table ──────────────────────────────────────────────────
print_table() {
local title=$1
local extract_fn=$2
shift 2
local columns=("$@")
echo -e "${BOLD}${title}:${NC}"
printf "%-12s" ""
for col in "${columns[@]}"; do
printf "%-18s" "$col"
done
echo ""
local tools=("wrk" "bombardier" "sarin")
for tool in "${tools[@]}"; do
printf "%-12s" "$tool"
for col in "${columns[@]}"; do
local val
val=$($extract_fn "$tool" "$col")
printf "%-18s" "${val}"
done
echo ""
done
echo ""
}
# ─── Main ────────────────────────────────────────────────────────────────────
main() {
header "HTTP Load Testing Tool Benchmark"
echo "Tools: wrk, bombardier, sarin"
echo "Duration: ${DURATION} per run"
echo "Connections: ${CONNECTIONS[*]}"
echo "Iterations: ${ITERATIONS} per configuration"
echo ""
check_deps
mkdir -p "${RESULTS_DIR}"
log "Results will be saved to ${RESULTS_DIR}/"
build_server
start_server
warmup
local tools=("wrk" "bombardier" "sarin")
for conns in "${CONNECTIONS[@]}"; do
header "Testing with ${conns} connections"
for tool in "${tools[@]}"; do
echo -e "${BOLD}--- ${tool} (${conns} connections) ---${NC}"
for iter in $(seq 1 "$ITERATIONS"); do
local out_file="${RESULTS_DIR}/${tool}_c${conns}_i${iter}"
echo -n " Run ${iter}/${ITERATIONS}... "
start_monitor "$tool" "$conns" "$iter"
case "$tool" in
wrk) run_wrk "$conns" "$DURATION" "$out_file" > /dev/null 2>&1 ;;
bombardier) run_bombardier "$conns" "$DURATION" "$out_file" > /dev/null 2>&1 ;;
sarin) run_sarin "$conns" "$DURATION" "$out_file" > /dev/null 2>&1 ;;
esac
stop_monitor
local peak_mem rps elapsed
peak_mem=$(extract_peak_mem "${out_file}.time")
rps=$(extract_rps "$tool" "${out_file}.out")
elapsed=$(extract_elapsed "${out_file}.time")
echo -e "done (elapsed: ${elapsed}, rps: ${rps}, peak mem: ${peak_mem} KB)"
sleep 2
done
echo ""
done
done
# ─── Summary ─────────────────────────────────────────────────────────
header "Summary"
echo "Raw results saved to: ${RESULTS_DIR}/"
echo ""
echo "Files per run:"
echo " *.out - tool stdout (throughput, latency stats)"
echo " *.time - /usr/bin/time output (peak memory, CPU time)"
echo " *_resources.csv - sampled CPU/memory during run"
echo ""
local columns=()
for conns in "${CONNECTIONS[@]}"; do
columns+=("c=${conns}")
done
_get_rps() {
local c=${2#c=}
extract_rps "$1" "${RESULTS_DIR}/${1}_c${c}_i${ITERATIONS}.out"
}
_get_total() {
local c=${2#c=}
extract_requests "$1" "${RESULTS_DIR}/${1}_c${c}_i${ITERATIONS}.out"
}
_get_mem() {
local c=${2#c=}
extract_peak_mem "${RESULTS_DIR}/${1}_c${c}_i${ITERATIONS}.time"
}
_get_elapsed() {
local c=${2#c=}
extract_elapsed "${RESULTS_DIR}/${1}_c${c}_i${ITERATIONS}.time"
}
print_table "Requests/sec" _get_rps "${columns[@]}"
print_table "Total Requests" _get_total "${columns[@]}"
print_table "Elapsed Time" _get_elapsed "${columns[@]}"
print_table "Peak Memory (KB)" _get_mem "${columns[@]}"
log "Benchmark complete!"
echo ""
echo "To inspect individual results:"
echo " cat ${RESULTS_DIR}/wrk_c200_i1.out"
echo " cat ${RESULTS_DIR}/sarin_c200_i1.out"
echo " cat ${RESULTS_DIR}/bombardier_c200_i1.out"
}
main "$@"
+114
View File
@@ -0,0 +1,114 @@
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#define PORT 8080
#define MAX_EVENTS 1024
#define BUF_SIZE 4096
static const char RESPONSE[] =
"HTTP/1.1 200 OK\r\n"
"Content-Length: 2\r\n"
"Content-Type: text/plain\r\n"
"Connection: keep-alive\r\n"
"\r\n"
"ok";
static const int RESPONSE_LEN = sizeof(RESPONSE) - 1;
static void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(void) {
signal(SIGPIPE, SIG_IGN);
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
setsockopt(server_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
setsockopt(server_fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(PORT),
.sin_addr.s_addr = htonl(INADDR_LOOPBACK),
};
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, SOMAXCONN) < 0) {
perror("listen");
return 1;
}
set_nonblocking(server_fd);
int epoll_fd = epoll_create1(0);
struct epoll_event ev = {.events = EPOLLIN, .data.fd = server_fd};
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);
struct epoll_event events[MAX_EVENTS];
char buf[BUF_SIZE];
fprintf(stderr, "Listening on http://127.0.0.1:%d\n", PORT);
for (;;) {
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == server_fd) {
/* Accept all pending connections */
for (;;) {
int client_fd = accept(server_fd, NULL, NULL);
if (client_fd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
continue;
}
set_nonblocking(client_fd);
int tcp_opt = 1;
setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &tcp_opt, sizeof(tcp_opt));
struct epoll_event cev = {.events = EPOLLIN | EPOLLET, .data.fd = client_fd};
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &cev);
}
} else {
int fd = events[i].data.fd;
/* Read all available data and respond to each request */
for (;;) {
ssize_t nread = read(fd, buf, BUF_SIZE);
if (nread <= 0) {
if (nread == 0 || (errno != EAGAIN && errno != EWOULDBLOCK)) {
close(fd);
}
break;
}
if (write(fd, RESPONSE, RESPONSE_LEN) < 0) {
close(fd);
break;
}
}
}
}
}
close(server_fd);
close(epoll_fd);
return 0;
}
+16 -19
View File
@@ -7,7 +7,6 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
"charm.land/lipgloss/v2"
"go.aykhans.me/sarin/internal/config" "go.aykhans.me/sarin/internal/config"
"go.aykhans.me/sarin/internal/sarin" "go.aykhans.me/sarin/internal/sarin"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
@@ -15,9 +14,8 @@ import (
) )
func main() { func main() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is called in listenForTermination goroutine
stopCtrl := sarin.NewStopController(cancel) go listenForTermination(func() { cancel() })
go listenForTermination(stopCtrl.Stop)
combinedConfig := config.ReadAllConfigs() combinedConfig := config.ReadAllConfigs()
@@ -33,13 +31,13 @@ func main() {
utilsErr.OnType(func(err types.FieldValidationErrors) error { utilsErr.OnType(func(err types.FieldValidationErrors) error {
for _, fieldErr := range err.Errors { for _, fieldErr := range err.Errors {
if fieldErr.Value == "" { if fieldErr.Value == "" {
fmt.Fprint(os.Stderr, lipgloss.Sprintln( fmt.Fprintln(os.Stderr,
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s': ", fieldErr.Field))+fieldErr.Err.Error(), config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s': ", fieldErr.Field))+fieldErr.Err.Error(),
)) )
} else { } else {
fmt.Fprint(os.Stderr, lipgloss.Sprintln( fmt.Fprintln(os.Stderr,
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s' (%s): ", fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(), config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s' (%s): ", fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
)) )
} }
} }
os.Exit(1) os.Exit(1)
@@ -51,31 +49,31 @@ 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.Quiet, *combinedConfig.Insecure, 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.Lua, combinedConfig.Js, combinedConfig.Lua, combinedConfig.Js,
) )
_ = utilsErr.MustHandle(err, _ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.ProxyDialError) error { utilsErr.OnType(func(err types.ProxyDialError) error {
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[PROXY] ")+err.Error())) fmt.Fprintln(os.Stderr, config.StyleRed.Render("[PROXY] ")+err.Error())
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error { utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error {
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error())) fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
utilsErr.OnType(func(err types.ScriptLoadError) error { utilsErr.OnType(func(err types.ScriptLoadError) error {
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error())) fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
) )
srn.Start(ctx, stopCtrl) srn.Start(ctx)
switch *combinedConfig.Output { switch *combinedConfig.Output {
case config.ConfigOutputTypeNone: case config.ConfigOutputTypeNone:
@@ -89,10 +87,9 @@ func main() {
} }
} }
func listenForTermination(stop func()) { func listenForTermination(do func()) {
sigChan := make(chan os.Signal, 4) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
for range sigChan { <-sigChan
stop() do()
}
} }
+15 -36
View File
@@ -1,6 +1,6 @@
# Configuration # Configuration
Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent: YAML files have the most configuration options, followed by CLI flags, and then environment variables. Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalentYAML files have the most configuration options, followed by CLI flags, and then environment variables.
When the same option is specified in multiple sources, the following priority order applies: When the same option is specified in multiple sources, the following priority order applies:
@@ -26,9 +26,7 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers | | [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send | | [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration | | [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
| [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit | | [Quiet](#quiet) | `quiet`<br>(boolean) | `-quiet` / `-q`<br>(boolean) | `SARIN_QUIET`<br>(boolean) | `false` | Hide progress bar and logs |
| [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
| [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats | | [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending | | [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification | | [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
@@ -109,9 +107,9 @@ If all four files define `url`, the value from `config3.yaml` wins.
**Merge behavior by field:** **Merge behavior by field:**
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.): higher priority overrides lower priority - **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.) higher priority overrides lower priority
- **Method and Body**: higher priority overrides lower priority (no merging) - **Method and Body** higher priority overrides lower priority (no merging)
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js**: accumulated across all config files - **Headers, Params, Cookies, Proxies, Values, Lua, and Js** accumulated across all config files
## URL ## URL
@@ -133,7 +131,7 @@ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10
## Method ## Method
HTTP method(s). Defaults to `GET`. If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md). HTTP method(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
**YAML example:** **YAML example:**
@@ -162,7 +160,7 @@ SARIN_METHOD=GET
## Timeout ## Timeout
Request timeout. Must be greater than 0. Defaults to `10s`. Request timeout. Must be greater than 0.
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h` Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
@@ -170,7 +168,7 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
## 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.
## Requests ## Requests
@@ -184,34 +182,15 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
**Examples:** `1m30s`, `25s`, `1h` **Examples:** `1m30s`, `25s`, `1h`
## Log Level ## Quiet
Runtime log levels to emit, comma-separated. Valid levels: `info`, `error`. Defaults to `error`. Hide the progress bar and runtime logs.
- `error`: errors that occur while generating or sending a request
- `info`: every completed response
Leave empty to disable logging entirely.
**Examples:** `error` (only errors), `info` (only responses), `info,error` (both)
## Log File
Write runtime logs to this file instead of the terminal or stderr. The parent directory must exist.
```sh
sarin -U http://example.com -r 1000 --log-file ./run.log
```
## Progress
Progress display. Valid values: `bar` (default), `none`. Use `none` to hide the progress bar.
## Output ## Output
Output format for response statistics. Output format for response statistics.
Valid formats: `table` (default), `json`, `yaml`, `none` Valid formats: `table`, `json`, `yaml`, `none`
Using `none` disables output and reduces memory usage since response statistics are not stored. Using `none` disables output and reduces memory usage since response statistics are not stored.
@@ -225,7 +204,7 @@ Skip TLS certificate verification.
## Body ## Body
Request body. If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md). Request body. If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
**YAML example:** **YAML example:**
@@ -365,7 +344,7 @@ SARIN_COOKIE="key1=value1"
## Proxy ## Proxy
Proxy URL(s). If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Proxy URL(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request.
Supported protocols: `http`, `https`, `socks5`, `socks5h` Supported protocols: `http`, `https`, `socks5`, `socks5h`
@@ -429,7 +408,7 @@ SARIN_VALUES="key1=value1"
Lua script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent. Lua script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
If multiple Lua scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts. If multiple Lua scripts are provided, they are chained in orderthe output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
**Script sources:** **Script sources:**
@@ -494,7 +473,7 @@ SARIN_LUA='function transform(req) req.headers["X-Custom"] = "my-value" return r
JavaScript script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent. JavaScript script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
If multiple JavaScript scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts. If multiple JavaScript scripts are provided, they are chained in orderthe output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
**Script sources:** **Script sources:**
+5 -64
View File
@@ -8,12 +8,10 @@ This guide provides practical examples for common Sarin use cases.
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests) - [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters) - [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
- [Dynamic Requests with Templating](#dynamic-requests-with-templating) - [Dynamic Requests with Templating](#dynamic-requests-with-templating)
- [Solving Captchas](#solving-captchas)
- [Request Bodies](#request-bodies) - [Request Bodies](#request-bodies)
- [File Uploads](#file-uploads) - [File Uploads](#file-uploads)
- [Using Proxies](#using-proxies) - [Using Proxies](#using-proxies)
- [Output Formats](#output-formats) - [Output Formats](#output-formats)
- [Runtime Logging](#runtime-logging)
- [Docker Usage](#docker-usage) - [Docker Usage](#docker-usage)
- [Dry Run Mode](#dry-run-mode) - [Dry Run Mode](#dry-run-mode)
- [Show Configuration](#show-configuration) - [Show Configuration](#show-configuration)
@@ -373,29 +371,7 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
</details> </details>
> For the complete list of 340+ template functions, see the **[Templating Guide](templating.md)**. > For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**.
## Solving Captchas
Sarin can solve captchas through third-party services and embed the resulting token into the request. Three services are supported via dedicated template functions: **2Captcha**, **Anti-Captcha**, and **CapSolver**.
**Solve a reCAPTCHA v2 and submit the token in the request body:**
```sh
sarin -U https://example.com/login -M POST -r 1 \
-B '{"g-recaptcha-response": "{{ twocaptcha_RecaptchaV2 "YOUR_API_KEY" "SITE_KEY" "https://example.com/login" }}"}'
```
**Reuse a single solved token across multiple requests via `values`:**
```sh
sarin -U https://example.com/api -M POST -r 5 \
-V 'TOKEN={{ anticaptcha_Turnstile "YOUR_API_KEY" "SITE_KEY" "https://example.com/api" }}' \
-H "X-Turnstile-Token: {{ .Values.TOKEN }}" \
-B '{"token": "{{ .Values.TOKEN }}"}'
```
> See the **[Templating Guide](templating.md#captcha-functions)** for the full list of captcha functions and per-service support.
## Request Bodies ## Request Bodies
@@ -720,7 +696,7 @@ proxy: socks5://proxy.example.com:1080
</details> </details>
**Multiple proxies (randomly cycled):** **Multiple proxies (load balanced):**
```sh ```sh
sarin -U http://example.com -r 1000 -c 10 \ sarin -U http://example.com -r 1000 -c 10 \
@@ -837,10 +813,10 @@ output: none
</details> </details>
**Hide the progress bar:** **Quiet mode (hide progress bar):**
```sh ```sh
sarin -U http://example.com -r 1000 -c 10 -p none sarin -U http://example.com -r 1000 -c 10 -q
``` ```
<details> <details>
@@ -850,42 +826,7 @@ sarin -U http://example.com -r 1000 -c 10 -p none
url: http://example.com url: http://example.com
requests: 1000 requests: 1000
concurrency: 10 concurrency: 10
progress: none quiet: true
```
</details>
## 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 responses and errors:**
```sh
sarin -U http://example.com -r 1000 -c 10 -l info,error
```
**Write logs to a file (the progress bar stays on screen):**
```sh
sarin -U http://example.com -r 1000 -c 10 -l info --log-file ./run.log
```
**Capture logs while keeping results on stdout:**
```sh
sarin -U http://example.com -r 1000 -l info -o json > stats.json 2> run.log
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
logLevel: info,error
logFile: ./run.log
``` ```
</details> </details>
+1 -124
View File
@@ -4,23 +4,16 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
> **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 portion 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.
## Table of Contents ## Table of Contents
- [Using Values](#using-values) - [Using Values](#using-values)
- [General Functions](#general-functions) - [General Functions](#general-functions)
- [String Functions](#string-functions) - [String Functions](#string-functions)
- [Collection Functions](#collection-functions) - [Collection Functions](#collection-functions)
- [JSON Functions](#json-functions)
- [Time Functions](#time-functions) - [Time Functions](#time-functions)
- [Crypto Functions](#crypto-functions) - [Crypto Functions](#crypto-functions)
- [Body Functions](#body-functions) - [Body Functions](#body-functions)
- [File Functions](#file-functions) - [File Functions](#file-functions)
- [Captcha Functions](#captcha-functions)
- [2Captcha](#2captcha)
- [Anti-Captcha](#anti-captcha)
- [CapSolver](#capsolver)
- [Fake Data Functions](#fake-data-functions) - [Fake Data Functions](#fake-data-functions)
- [File](#file) - [File](#file)
- [ID](#id) - [ID](#id)
@@ -118,33 +111,6 @@ sarin -U http://example.com/users \
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` | | `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` | | `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` |
### JSON Functions
Build JSON payloads programmatically without manual quoting or escaping. `json_Object` is the ergonomic shortcut for flat objects; `json_Encode` marshals any value (slice, map, etc.) to a JSON string.
| Function | Description | Example |
| --------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| `json_Object(pairs ...any)` | Build an object from interleaved key-value pairs and return it as a JSON string. Keys must be strings. | `{{ json_Object "name" "Alice" "age" 30 }}` |
| `json_Encode(v any)` | Marshal any value (slice, map, etc.) to a JSON string. | `{{ json_Encode (slice_Str "a" "b") }}``["a","b"]` |
**Examples:**
```yaml
# Flat object with fake data
body: '{{ json_Object "name" (fakeit_FirstName) "email" (fakeit_Email) }}'
# Embed a solved captcha token
body: '{{ json_Object "g-recaptcha-response" (twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com") }}'
# Encode a slice as a JSON array
body: '{{ json_Encode (slice_Str "a" "b" "c") }}'
# Encode a string dictionary (map[string]string)
body: '{{ json_Encode (dict_Str "key1" "value1" "key2" "value2") }}'
```
> **Note:** Object keys are serialized in alphabetical order (Go's `encoding/json` default), not insertion order. For API payloads this is almost always fine because JSON key order is semantically irrelevant.
### Time Functions ### Time Functions
| Function | Description | Example | | Function | Description | Example |
@@ -226,99 +192,10 @@ body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "docume
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}' body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}'
# Combined with values for reuse # Combined with values for reuse
values: 'FILE_DATA={{ file_Base64 "/path/to/file.bin" }}' values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}"
body: '{"data": "{{ .Values.FILE_DATA }}"}' body: '{"data": "{{ .Values.FILE_DATA }}"}'
``` ```
## Captcha Functions
Captcha functions solve a captcha challenge through a third-party solving service and return the resulting token, which can then be embedded directly into a request. They are intended for load testing endpoints protected by reCAPTCHA, hCaptcha, or Cloudflare Turnstile.
The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required. Bring your own key and use any of the supported services per template.
> **Important: performance and cost:**
>
> - **Each call is slow.** Solving typically takes ~560 seconds because the function blocks the template render until the third-party service returns a token. Internally the solver polls every 1s and gives up after 120s.
> - **Each call costs money.** Every successful solve is billed by the captcha service (typically $0.001$0.003 per solve). For high-volume tests, your captcha bill grows linearly with request count.
**Common parameters across all captcha functions:**
- `apiKey` - Your API key for the chosen captcha solving service
- `siteKey` - The captcha sitekey extracted from the target page (e.g. the `data-sitekey` attribute on a reCAPTCHA, hCaptcha, or Turnstile element)
- `pageURL` - The URL of the page where the captcha is hosted
### 2Captcha
Functions for the [2Captcha](https://2captcha.com) service. Note: 2Captcha **does not currently support hCaptcha** through their API.
| Function | Description |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| `twocaptcha_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
| `twocaptcha_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. Pass `""` for `pageAction` to omit |
| `twocaptcha_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
### Anti-Captcha
Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is currently the only service that supports all four captcha types end-to-end.
| Function | Description |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `anticaptcha_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
| `anticaptcha_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. `minScore` is hardcoded to `0.3` (Anti-Captcha rejects the request without it) |
| `anticaptcha_HCaptcha(apiKey, siteKey, pageURL string)` | Solve an hCaptcha challenge |
| `anticaptcha_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
### CapSolver
Functions for the [CapSolver](https://capsolver.com) service. Note: CapSolver no longer supports hCaptcha.
| Function | Description |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `capsolver_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
| `capsolver_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. Pass `""` for `pageAction` to omit |
| `capsolver_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
**Examples:**
```yaml
# reCAPTCHA v2 in a JSON body via 2Captcha
method: POST
url: https://example.com/login
body: |
{
"username": "test",
"g-recaptcha-response": "{{ twocaptcha_RecaptchaV2 "YOUR_API_KEY" "6LfD3PIb..." "https://example.com/login" }}"
}
```
```yaml
# Turnstile via Anti-Captcha with cData
method: POST
url: https://example.com/submit
body: |
{
"cf-turnstile-response": "{{ anticaptcha_Turnstile "YOUR_API_KEY" "0x4AAAAAAA..." "https://example.com/submit" "session-cdata" }}"
}
```
```yaml
# hCaptcha via Anti-Captcha (the only service that still supports it)
method: POST
url: https://example.com/protected
body: |
{
"h-captcha-response": "{{ anticaptcha_HCaptcha "YOUR_API_KEY" "338af34c-..." "https://example.com/protected" }}"
}
```
```yaml
# Share a single solved token across body and headers via values
values: 'TOKEN={{ capsolver_Turnstile "YOUR_API_KEY" "0x4AAAAAAA..." "https://example.com" }}'
headers:
X-Turnstile-Token: "{{ .Values.TOKEN }}"
body: '{"token": "{{ .Values.TOKEN }}"}'
```
## Fake Data Functions ## Fake Data Functions
These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library. These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library.
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1784796856,
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-55
View File
@@ -1,55 +0,0 @@
{
description = "Sarin - high-performance HTTP load testing tool built with Go and fasthttp";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
inherit (nixpkgs) lib;
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
rev = self.rev or self.dirtyRev or "unknown";
# self.lastModifiedDate is "YYYYMMDDHHMMSS"; reshape to RFC 3339 (UTC).
raw = self.lastModifiedDate or "19700101000000";
s = i: n: lib.substring i n raw;
buildDate = "${s 0 4}-${s 4 2}-${s 6 2}T${s 8 2}:${s 10 2}:${s 12 2}Z";
in
{
packages = forAllSystems (pkgs: {
default = pkgs.callPackage ./nix/package.nix {
inherit rev buildDate;
};
});
# nix run github:aykhans/sarin -- <args>
apps = forAllSystems (pkgs: {
default = {
type = "app";
program = "${self.packages.${pkgs.system}.default}/bin/sarin";
meta.description = "High-performance HTTP load testing tool";
};
});
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
packages = [ pkgs.go_1_26 pkgs.golangci-lint pkgs.go-task ];
};
});
# For downstream flakes: inputs.sarin.overlays.default
overlays.default = final: _prev: {
sarin = final.callPackage ./nix/package.nix { };
};
};
}
+38 -32
View File
@@ -1,53 +1,59 @@
module go.aykhans.me/sarin module go.aykhans.me/sarin
go 1.26.5 go 1.26.1
require ( require (
charm.land/bubbles/v2 v2.2.1 github.com/brianvoe/gofakeit/v7 v7.14.1
charm.land/bubbletea/v2 v2.0.9 github.com/charmbracelet/bubbles v1.0.0
charm.land/glamour/v2 v2.0.1 github.com/charmbracelet/bubbletea v1.3.10
charm.land/lipgloss/v2 v2.0.6 github.com/charmbracelet/glamour v0.10.0
github.com/brianvoe/gofakeit/v7 v7.16.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/term v0.2.2 github.com/charmbracelet/x/term v0.2.2
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/valyala/fasthttp v1.73.0 github.com/valyala/fasthttp v1.69.0
github.com/yuin/gopher-lua v1.1.2 github.com/yuin/gopher-lua v1.1.1
go.aykhans.me/utils v1.0.7 go.aykhans.me/utils v1.0.7
go.yaml.in/yaml/v4 v4.0.0-rc.6 go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/net v0.58.0 golang.org/x/net v0.52.0
) )
require ( require (
github.com/alecthomas/chroma/v2 v2.27.0 // indirect github.com/alecthomas/chroma/v2 v2.21.1 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
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.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.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.7.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/klauspost/compress v1.19.2 // indirect github.com/klauspost/compress v1.18.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // 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 v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.8.5 // indirect github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect
) )
+87 -70
View File
@@ -1,110 +1,127 @@
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
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/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/brianvoe/gofakeit/v7 v7.16.0 h1:LXNcvT4Klw72/hqpLNNdEWFIcP7G0VFPNsqvEIGONBE= github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow=
github.com/brianvoe/gofakeit/v7 v7.16.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
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-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 h1:Z0hBPQ9hslsfpFRRdMn+4cjnb3LK6FQH58hQkXrXv0A= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/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/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
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.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6 h1:Oh2rRG1un7tLlC3/NJDzKppZ4CeZGkVFJCUOTRwLpfw= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
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.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
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/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
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=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw= go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI= go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
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.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+7 -22
View File
@@ -27,9 +27,7 @@ Flags:
-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) -q, -quiet bool Hide the progress bar and runtime logs (default %v)
-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')
-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 requests (default %v)
@@ -90,9 +88,7 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
concurrency uint concurrency uint
requestCount uint64 requestCount uint64
duration time.Duration duration time.Duration
logLevel string quiet bool
logFile string
progress string
output string output string
dryRun bool dryRun bool
@@ -131,14 +127,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test") flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test") flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
flagSet.StringVar(&logLevel, "log-level", "", "Runtime log levels to emit, comma-separated (possible values: info, error)") flagSet.BoolVar(&quiet, "quiet", false, "Hide the progress bar and runtime logs")
flagSet.StringVar(&logLevel, "l", "", "Runtime log levels to emit, comma-separated (possible values: info, error)") flagSet.BoolVar(&quiet, "q", false, "Hide the progress bar and runtime logs")
flagSet.StringVar(&logFile, "log-file", "", "Write runtime logs to this file instead of the terminal/stderr")
flagSet.StringVar(&logFile, "w", "", "Write runtime logs to this file instead of the terminal/stderr")
flagSet.StringVar(&progress, "progress", "", "Progress display (possible values: bar, none)")
flagSet.StringVar(&progress, "p", "", "Progress display (possible values: bar, none)")
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)")
@@ -215,12 +205,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
config.Requests = new(requestCount) config.Requests = new(requestCount)
case "duration", "d": case "duration", "d":
config.Duration = new(duration) config.Duration = new(duration)
case "log-level", "l": case "quiet", "q":
config.LogLevel = new(logLevel) config.Quiet = new(quiet)
case "log-file", "w":
config.LogFile = new(logFile)
case "progress", "p":
config.Progress = new(ConfigProgressType(progress))
case "output", "o": case "output", "o":
config.Output = new(ConfigOutputType(output)) config.Output = new(ConfigOutputType(output))
case "dry-run", "z": case "dry-run", "z":
@@ -279,8 +265,7 @@ func (parser ConfigCLIParser) PrintHelp() {
cliUsageText+"\n", cliUsageText+"\n",
Defaults.ShowConfig, Defaults.ShowConfig,
Defaults.Concurrency, Defaults.Concurrency,
Defaults.LogLevel, Defaults.Quiet,
Defaults.Progress,
Defaults.Output, Defaults.Output,
Defaults.DryRun, Defaults.DryRun,
+44 -129
View File
@@ -6,18 +6,17 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"charm.land/bubbles/v2/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "charm.land/bubbletea/v2" tea "github.com/charmbracelet/bubbletea"
"charm.land/glamour/v2" "github.com/charmbracelet/glamour"
"charm.land/glamour/v2/styles" "github.com/charmbracelet/glamour/styles"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
"go.aykhans.me/sarin/internal/sarin" "github.com/charmbracelet/x/term"
"go.aykhans.me/sarin/internal/script" "go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types" "go.aykhans.me/sarin/internal/types"
"go.aykhans.me/sarin/internal/version" "go.aykhans.me/sarin/internal/version"
@@ -32,28 +31,25 @@ var Defaults = struct {
RequestTimeout time.Duration RequestTimeout time.Duration
Concurrency uint Concurrency uint
ShowConfig bool ShowConfig bool
Progress ConfigProgressType Quiet bool
Insecure bool Insecure bool
Output ConfigOutputType Output ConfigOutputType
DryRun bool 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, Concurrency: 1,
ShowConfig: false, ShowConfig: false,
Progress: ConfigProgressTypeBar, Quiet: false,
Insecure: false, Insecure: false,
Output: ConfigOutputTypeTable, Output: ConfigOutputTypeTable,
DryRun: false, DryRun: false,
LogLevel: "error",
} }
var ( var (
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"} ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
ValidRequestURLSchemes = []string{"http", "https"} ValidRequestURLSchemes = []string{"http", "https"}
ValidLogLevels = []string{"info", "error"}
) )
var ( var (
@@ -74,13 +70,6 @@ var (
ConfigOutputTypeNone ConfigOutputType = "none" ConfigOutputTypeNone ConfigOutputType = "none"
) )
type ConfigProgressType string
var (
ConfigProgressTypeBar ConfigProgressType = "bar"
ConfigProgressTypeNone ConfigProgressType = "none"
)
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"`
@@ -90,7 +79,7 @@ type Config struct {
Concurrency *uint `yaml:"concurrency,omitempty"` Concurrency *uint `yaml:"concurrency,omitempty"`
Requests *uint64 `yaml:"requests,omitempty"` Requests *uint64 `yaml:"requests,omitempty"`
Duration *time.Duration `yaml:"duration,omitempty"` Duration *time.Duration `yaml:"duration,omitempty"`
Progress *ConfigProgressType `yaml:"progress,omitempty"` Quiet *bool `yaml:"quiet,omitempty"`
Output *ConfigOutputType `yaml:"output,omitempty"` Output *ConfigOutputType `yaml:"output,omitempty"`
Insecure *bool `yaml:"insecure,omitempty"` Insecure *bool `yaml:"insecure,omitempty"`
DryRun *bool `yaml:"dryRun,omitempty"` DryRun *bool `yaml:"dryRun,omitempty"`
@@ -102,8 +91,6 @@ type Config struct {
Values []string `yaml:"values,omitempty"` Values []string `yaml:"values,omitempty"`
Lua []string `yaml:"lua,omitempty"` Lua []string `yaml:"lua,omitempty"`
Js []string `yaml:"js,omitempty"` Js []string `yaml:"js,omitempty"`
LogLevel *string `yaml:"logLevel,omitempty"`
LogFile *string `yaml:"logFile,omitempty"`
} }
func (config Config) MarshalYAML() (any, error) { func (config Config) MarshalYAML() (any, error) {
@@ -186,8 +173,8 @@ func (config Config) MarshalYAML() (any, error) {
if config.Duration != nil { if config.Duration != nil {
addField(content, "duration", toNode(*config.Duration), "") addField(content, "duration", toNode(*config.Duration), "")
} }
if config.Progress != nil { if config.Quiet != nil {
addField(content, "progress", toNode(string(*config.Progress)), "") addField(content, "quiet", toNode(*config.Quiet), "")
} }
if config.Output != nil { if config.Output != nil {
addField(content, "output", toNode(string(*config.Output)), "") addField(content, "output", toNode(string(*config.Output)), "")
@@ -234,13 +221,6 @@ func (config Config) MarshalYAML() (any, error) {
addStringSlice(content, "values", config.Values, false) addStringSlice(content, "values", config.Values, false)
addStringSlice(content, "lua", config.Lua, false) addStringSlice(content, "lua", config.Lua, false)
addStringSlice(content, "js", config.Js, false) addStringSlice(content, "js", config.Js, false)
if config.LogLevel != nil {
addField(content, "logLevel", toNode(*config.LogLevel), "")
}
if config.LogFile != nil {
addField(content, "logFile", toNode(*config.LogFile), "")
}
return root, nil return root, nil
} }
@@ -248,12 +228,12 @@ func (config Config) MarshalYAML() (any, error) {
func (config Config) Print() bool { func (config Config) Print() bool {
configYAML, err := yaml.Marshal(config) configYAML, err := yaml.Marshal(config)
if err != nil { if err != nil {
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render("Error marshaling config to yaml: "+err.Error()))) fmt.Fprintln(os.Stderr, StyleRed.Render("Error marshaling config to yaml: "+err.Error()))
os.Exit(1) os.Exit(1)
} }
// Pipe mode: output raw content directly // Pipe mode: output raw content directly
if !sarin.IsInteractiveTerminal(os.Stdout.Fd()) { if !term.IsTerminal(os.Stdout.Fd()) {
fmt.Println(string(configYAML)) fmt.Println(string(configYAML))
os.Exit(0) os.Exit(0)
} }
@@ -267,23 +247,25 @@ func (config Config) Print() bool {
glamour.WithWordWrap(0), glamour.WithWordWrap(0),
) )
if err != nil { if err != nil {
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error()))) fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1) os.Exit(1)
} }
content, err := renderer.Render("```yaml\n" + string(configYAML) + "```") content, err := renderer.Render("```yaml\n" + string(configYAML) + "```")
if err != nil { if err != nil {
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error()))) fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1) os.Exit(1)
} }
p := tea.NewProgram( p := tea.NewProgram(
printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML}, printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML},
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
) )
m, err := p.Run() m, err := p.Run()
if err != nil { if err != nil {
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error()))) fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
os.Exit(1) os.Exit(1)
} }
@@ -313,8 +295,8 @@ func (config *Config) Merge(newConfig *Config) {
if newConfig.ShowConfig != nil { if newConfig.ShowConfig != nil {
config.ShowConfig = newConfig.ShowConfig config.ShowConfig = newConfig.ShowConfig
} }
if newConfig.Progress != nil { if newConfig.Quiet != nil {
config.Progress = newConfig.Progress config.Quiet = newConfig.Quiet
} }
if newConfig.Output != nil { if newConfig.Output != nil {
config.Output = newConfig.Output config.Output = newConfig.Output
@@ -349,12 +331,6 @@ func (config *Config) Merge(newConfig *Config) {
if len(newConfig.Js) != 0 { if len(newConfig.Js) != 0 {
config.Js = append(config.Js, newConfig.Js...) config.Js = append(config.Js, newConfig.Js...)
} }
if newConfig.LogLevel != nil {
config.LogLevel = newConfig.LogLevel
}
if newConfig.LogFile != nil {
config.LogFile = newConfig.LogFile
}
} }
func (config *Config) SetDefaults() { func (config *Config) SetDefaults() {
@@ -385,8 +361,8 @@ func (config *Config) SetDefaults() {
if config.ShowConfig == nil { if config.ShowConfig == nil {
config.ShowConfig = new(Defaults.ShowConfig) config.ShowConfig = new(Defaults.ShowConfig)
} }
if config.Progress == nil { if config.Quiet == nil {
config.Progress = new(Defaults.Progress) config.Quiet = new(Defaults.Quiet)
} }
if config.Insecure == nil { if config.Insecure == nil {
config.Insecure = new(Defaults.Insecure) config.Insecure = new(Defaults.Insecure)
@@ -401,14 +377,6 @@ func (config *Config) SetDefaults() {
if config.Output == nil { if config.Output == nil {
config.Output = new(Defaults.Output) config.Output = new(Defaults.Output)
} }
if config.LogLevel == nil {
config.LogLevel = new(Defaults.LogLevel)
}
if config.LogFile == nil {
config.LogFile = new("")
}
} }
// Validate validates the config fields. // Validate validates the config fields.
@@ -458,21 +426,8 @@ func (config Config) Validate() error {
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required"))) validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
} }
if config.Progress == nil { if config.Quiet == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Progress", "", errors.New("progress field is required"))) validationErrors = append(validationErrors, types.NewFieldValidationError("Quiet", "", errors.New("quiet field is required")))
} else {
switch *config.Progress {
case ConfigProgressTypeBar, ConfigProgressTypeNone:
default:
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
"Progress",
string(*config.Progress),
fmt.Errorf("progress must be one of: %s, %s", ConfigProgressTypeBar, ConfigProgressTypeNone),
),
)
}
} }
if config.Output == nil { if config.Output == nil {
@@ -505,36 +460,6 @@ func (config Config) Validate() error {
validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required"))) validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required")))
} }
if config.LogLevel != nil {
for i, level := range sarin.SplitLogLevels(*config.LogLevel) {
if !slices.Contains(ValidLogLevels, level) {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("LogLevel[%d]", i),
level,
fmt.Errorf("log level must be one of: %s", strings.Join(ValidLogLevels, ", ")),
),
)
}
}
}
if config.LogFile != nil && *config.LogFile != "" {
dir := filepath.Dir(*config.LogFile)
if info, err := os.Stat(dir); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent directory %q is not accessible", dir)),
)
} else if !info.IsDir() {
validationErrors = append(
validationErrors,
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent path %q is not a directory", dir)),
)
}
}
for i, proxy := range config.Proxies { for i, proxy := range config.Proxies {
if !slices.Contains(ValidProxySchemes, proxy.Scheme) { if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
validationErrors = append( validationErrors = append(
@@ -613,11 +538,11 @@ func ReadAllConfigs() *Config {
_ = utilsErr.MustHandle(err, _ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error { utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
cliParser.PrintHelp() cliParser.PrintHelp()
fmt.Fprint(os.Stderr, lipgloss.Sprintln( fmt.Fprintln(os.Stderr,
StyleYellow.Render( StyleYellow.Render(
"\nUnexpected CLI arguments provided: ", "\nUnexpected CLI arguments provided: ",
)+strings.Join(err.Args, ", "), )+strings.Join(err.Args, ", "),
)) )
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
@@ -635,20 +560,20 @@ func ReadAllConfigs() *Config {
_ = 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.Fprintln(os.Stderr,
StyleYellow.Render( StyleYellow.Render(
fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(), fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(),
), ),
)) )
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
utilsErr.OnType(func(err types.UnmarshalError) error { utilsErr.OnType(func(err types.UnmarshalError) error {
fmt.Fprint(os.Stderr, lipgloss.Sprintln( fmt.Fprintln(os.Stderr,
StyleYellow.Render( StyleYellow.Render(
fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(), fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(),
), ),
)) )
os.Exit(1) os.Exit(1)
return nil return nil
}), }),
@@ -751,13 +676,13 @@ func validateScriptSource(script string) error {
func printParseErrors(parserName string, errors ...types.FieldParseError) { func printParseErrors(parserName string, errors ...types.FieldParseError) {
for _, fieldErr := range errors { for _, fieldErr := range errors {
if fieldErr.Value == "" { if fieldErr.Value == "" {
fmt.Fprint(os.Stderr, lipgloss.Sprintln( fmt.Fprintln(os.Stderr,
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(), StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(),
)) )
} else { } else {
fmt.Fprint(os.Stderr, lipgloss.Sprintln( fmt.Fprintln(os.Stderr,
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s' (%s): ", parserName, fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(), StyleYellow.Render(fmt.Sprintf("[%s] Field '%s' (%s): ", parserName, fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
)) )
} }
} }
} }
@@ -798,7 +723,7 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyPressMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "ctrl+c", "esc": case "ctrl+c", "esc":
return m, tea.Quit return m, tea.Quit
@@ -821,22 +746,13 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd return m, cmd
} }
func (m printConfigModel) View() tea.View { func (m printConfigModel) View() string {
// AltScreen and MouseMode were program options in bubbletea v1; in v2 they
// are view properties, so every return path has to declare them.
newView := func(s string) tea.View {
v := tea.NewView(s)
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
if !m.ready { if !m.ready {
return newView("\n Initializing...") return "\n Initializing..."
} }
content := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbar()) content := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbar())
return newView(fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView())) return fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView())
} }
func (m *printConfigModel) saveContent() (printConfigModel, tea.Cmd) { func (m *printConfigModel) saveContent() (printConfigModel, tea.Cmd) {
@@ -856,13 +772,12 @@ func (m *printConfigModel) handleResize(msg tea.WindowSizeMsg) {
width := msg.Width - scrollbarWidth width := msg.Width - scrollbarWidth
if !m.ready { if !m.ready {
m.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) m.viewport = viewport.New(width, height)
m.viewport.SetHorizontalStep(0)
m.viewport.SetContent(m.contentWithLineNumbers()) m.viewport.SetContent(m.contentWithLineNumbers())
m.ready = true m.ready = true
} else { } else {
m.viewport.SetWidth(width) m.viewport.Width = width
m.viewport.SetHeight(height) m.viewport.Height = height
} }
} }
@@ -877,12 +792,12 @@ func (m printConfigModel) headerView() string {
printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit") printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit")
title = printConfigHelpStyle.Render(help) title = printConfigHelpStyle.Render(help)
} }
line := strings.Repeat("─", max(0, m.viewport.Width()+scrollbarWidth-lipgloss.Width(title))) line := strings.Repeat("─", max(0, m.viewport.Width+scrollbarWidth-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, line) return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
} }
func (m printConfigModel) footerView() string { func (m printConfigModel) footerView() string {
return strings.Repeat("─", m.viewport.Width()+scrollbarWidth) return strings.Repeat("─", m.viewport.Width+scrollbarWidth)
} }
func (m printConfigModel) contentWithLineNumbers() string { func (m printConfigModel) contentWithLineNumbers() string {
@@ -904,7 +819,7 @@ func (m printConfigModel) contentWithLineNumbers() string {
} }
func (m printConfigModel) scrollbar() string { func (m printConfigModel) scrollbar() string {
height := m.viewport.Height() height := m.viewport.Height
trackHeight := height - scrollbarBottomSpace trackHeight := height - scrollbarBottomSpace
totalLines := m.viewport.TotalLineCount() totalLines := m.viewport.TotalLineCount()
+78 -74
View File
@@ -49,6 +49,74 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
config.Files = append(config.Files, *types.ParseConfigFile(configFile)) config.Files = append(config.Files, *types.ParseConfigFile(configFile))
} }
if quiet := parser.getEnv("QUIET"); quiet != "" {
quietParsed, err := utilsParse.ParseString[bool](quiet)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("QUIET"),
quiet,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Quiet = &quietParsed
}
}
if output := parser.getEnv("OUTPUT"); output != "" {
config.Output = new(ConfigOutputType(output))
}
if insecure := parser.getEnv("INSECURE"); insecure != "" {
insecureParsed, err := utilsParse.ParseString[bool](insecure)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("INSECURE"),
insecure,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Insecure = &insecureParsed
}
}
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("DRY_RUN"),
dryRun,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.DryRun = &dryRunParsed
}
}
if method := parser.getEnv("METHOD"); method != "" {
config.Methods = []string{method}
}
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
urlEnvParsed, err := url.Parse(urlEnv)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
)
} else {
config.URL = urlEnvParsed
}
}
if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" { if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" {
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency) concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
if err != nil { if err != nil {
@@ -97,58 +165,22 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
} }
} }
if logLevel := parser.getEnv("LOG_LEVEL"); logLevel != "" { if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
config.LogLevel = new(logLevel) timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
}
if logFile := parser.getEnv("LOG_FILE"); logFile != "" {
config.LogFile = new(logFile)
}
if progress := parser.getEnv("PROGRESS"); progress != "" {
config.Progress = new(ConfigProgressType(progress))
}
if output := parser.getEnv("OUTPUT"); output != "" {
config.Output = new(ConfigOutputType(output))
}
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
if err != nil { if err != nil {
fieldParseErrors = append( fieldParseErrors = append(
fieldParseErrors, fieldParseErrors,
types.NewFieldParseError( types.NewFieldParseError(
parser.getFullEnvName("DRY_RUN"), parser.getFullEnvName("TIMEOUT"),
dryRun, timeout,
errors.New("invalid value for boolean, expected 'true' or 'false'"), errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
), ),
) )
} else { } else {
config.DryRun = &dryRunParsed config.Timeout = &timeoutParsed
} }
} }
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
urlEnvParsed, err := url.Parse(urlEnv)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
)
} else {
config.URL = urlEnvParsed
}
}
if method := parser.getEnv("METHOD"); method != "" {
config.Methods = []string{method}
}
if body := parser.getEnv("BODY"); body != "" {
config.Bodies = []string{body}
}
if param := parser.getEnv("PARAM"); param != "" { if param := parser.getEnv("PARAM"); param != "" {
config.Params.Parse(param) config.Params.Parse(param)
} }
@@ -161,6 +193,10 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
config.Cookies.Parse(cookie) config.Cookies.Parse(cookie)
} }
if body := parser.getEnv("BODY"); body != "" {
config.Bodies = []string{body}
}
if proxy := parser.getEnv("PROXY"); proxy != "" { if proxy := parser.getEnv("PROXY"); proxy != "" {
err := config.Proxies.Parse(proxy) err := config.Proxies.Parse(proxy)
if err != nil { if err != nil {
@@ -179,38 +215,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
config.Values = []string{values} config.Values = []string{values}
} }
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("TIMEOUT"),
timeout,
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
),
)
} else {
config.Timeout = &timeoutParsed
}
}
if insecure := parser.getEnv("INSECURE"); insecure != "" {
insecureParsed, err := utilsParse.ParseString[bool](insecure)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("INSECURE"),
insecure,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Insecure = &insecureParsed
}
}
if lua := parser.getEnv("LUA"); lua != "" { if lua := parser.getEnv("LUA"); lua != "" {
config.Lua = []string{lua} config.Lua = []string{lua}
} }
+31 -41
View File
@@ -192,26 +192,24 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
} }
type configYAML struct { type configYAML struct {
ShowConfig *bool `yaml:"showConfig"`
ConfigFiles stringOrSliceField `yaml:"configFile"` ConfigFiles stringOrSliceField `yaml:"configFile"`
Method stringOrSliceField `yaml:"method"`
URL *string `yaml:"url"`
Timeout *time.Duration `yaml:"timeout"`
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"` Quiet *bool `yaml:"quiet"`
LogFile *string `yaml:"logFile"`
Progress *string `yaml:"progress"`
Output *string `yaml:"output"` Output *string `yaml:"output"`
Insecure *bool `yaml:"insecure"`
ShowConfig *bool `yaml:"showConfig"`
DryRun *bool `yaml:"dryRun"` DryRun *bool `yaml:"dryRun"`
URL *string `yaml:"url"`
Method stringOrSliceField `yaml:"method"`
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"`
Bodies stringOrSliceField `yaml:"body"`
Proxies stringOrSliceField `yaml:"proxy"` Proxies stringOrSliceField `yaml:"proxy"`
Values stringOrSliceField `yaml:"values"` Values stringOrSliceField `yaml:"values"`
Timeout *time.Duration `yaml:"timeout"`
Insecure *bool `yaml:"insecure"`
Lua stringOrSliceField `yaml:"lua"` Lua stringOrSliceField `yaml:"lua"`
Js stringOrSliceField `yaml:"js"` Js stringOrSliceField `yaml:"js"`
} }
@@ -233,41 +231,20 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
var fieldParseErrors []types.FieldParseError var fieldParseErrors []types.FieldParseError
config.ShowConfig = parsedData.ShowConfig config.Methods = append(config.Methods, parsedData.Method...)
config.Timeout = parsedData.Timeout
if len(parsedData.ConfigFiles) > 0 {
for _, configFile := range parsedData.ConfigFiles {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
}
config.Concurrency = parsedData.Concurrency config.Concurrency = parsedData.Concurrency
config.Requests = parsedData.RequestCount config.Requests = parsedData.RequestCount
config.Duration = parsedData.Duration config.Duration = parsedData.Duration
config.LogLevel = parsedData.LogLevel config.ShowConfig = parsedData.ShowConfig
config.LogFile = parsedData.LogFile config.Quiet = parsedData.Quiet
if parsedData.Progress != nil {
config.Progress = new(ConfigProgressType(*parsedData.Progress))
}
if parsedData.Output != nil { if parsedData.Output != nil {
config.Output = new(ConfigOutputType(*parsedData.Output)) config.Output = new(ConfigOutputType(*parsedData.Output))
} }
config.Insecure = parsedData.Insecure
config.DryRun = parsedData.DryRun config.DryRun = parsedData.DryRun
if parsedData.URL != nil {
urlParsed, err := url.Parse(*parsedData.URL)
if err != nil {
fieldParseErrors = append(fieldParseErrors, types.NewFieldParseError("url", *parsedData.URL, err))
} else {
config.URL = urlParsed
}
}
config.Methods = append(config.Methods, parsedData.Method...)
config.Bodies = append(config.Bodies, parsedData.Bodies...)
for _, kv := range parsedData.Params { for _, kv := range parsedData.Params {
config.Params = append(config.Params, types.Param(kv)) config.Params = append(config.Params, types.Param(kv))
} }
@@ -277,6 +254,25 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
for _, kv := range parsedData.Cookies { for _, kv := range parsedData.Cookies {
config.Cookies = append(config.Cookies, types.Cookie(kv)) config.Cookies = append(config.Cookies, types.Cookie(kv))
} }
config.Bodies = append(config.Bodies, parsedData.Bodies...)
config.Values = append(config.Values, parsedData.Values...)
config.Lua = append(config.Lua, parsedData.Lua...)
config.Js = append(config.Js, parsedData.Js...)
if len(parsedData.ConfigFiles) > 0 {
for _, configFile := range parsedData.ConfigFiles {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
}
if parsedData.URL != nil {
urlParsed, err := url.Parse(*parsedData.URL)
if err != nil {
fieldParseErrors = append(fieldParseErrors, types.NewFieldParseError("url", *parsedData.URL, err))
} else {
config.URL = urlParsed
}
}
for i, proxy := range parsedData.Proxies { for i, proxy := range parsedData.Proxies {
err := config.Proxies.Parse(proxy) err := config.Proxies.Parse(proxy)
@@ -288,12 +284,6 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
} }
} }
config.Values = append(config.Values, parsedData.Values...)
config.Timeout = parsedData.Timeout
config.Insecure = parsedData.Insecure
config.Lua = append(config.Lua, parsedData.Lua...)
config.Js = append(config.Js, parsedData.Js...)
if len(fieldParseErrors) > 0 { if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors) return nil, types.NewFieldParseErrors(fieldParseErrors)
} }
-415
View File
@@ -1,415 +0,0 @@
package sarin
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
)
const (
captchaPollInterval = 1 * time.Second
captchaPollTimeout = 120 * time.Second
)
var captchaHTTPClient = &http.Client{Timeout: 5 * time.Second}
// solveCaptcha creates a task on the given captcha service and polls until it is solved,
// returning the extracted token from the solution object.
//
// baseURL is the service API base (e.g. "https://api.2captcha.com").
// task is the task payload the service expects (type + service-specific fields).
// solutionKey is the field name in the solution object that holds the token.
// taskIDIsString controls whether taskId is sent back as a string (CapSolver UUIDs)
// or a JSON number (2Captcha, Anti-Captcha).
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey string, taskIDIsString bool) (string, error) {
if apiKey == "" {
return "", types.ErrCaptchaKeyEmpty
}
taskID, err := captchaCreateTask(baseURL, apiKey, task)
if err != nil {
return "", err
}
return captchaPollResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString)
}
// captchaCreateTask submits a task to the captcha service and returns the assigned taskId.
// The taskId is normalized to a string: numeric IDs are preserved via json.RawMessage,
// and quoted string IDs (CapSolver UUIDs) have their surrounding quotes stripped.
//
// It can return the following errors:
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, error) {
body := map[string]any{
"clientKey": apiKey,
"task": task,
}
data, err := json.Marshal(body)
if err != nil {
return "", types.NewCaptchaDecodeError("createTask", err)
}
resp, err := captchaHTTPClient.Post(
baseURL+"/createTask",
"application/json",
bytes.NewReader(data),
)
if err != nil {
return "", types.NewCaptchaRequestError("createTask", err)
}
defer resp.Body.Close() //nolint:errcheck
var result struct {
ErrorID int `json:"errorId"`
ErrorCode string `json:"errorCode"`
ErrorDescription string `json:"errorDescription"`
TaskID json.RawMessage `json:"taskId"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", types.NewCaptchaDecodeError("createTask", err)
}
if result.ErrorID != 0 {
return "", types.NewCaptchaAPIError("createTask", result.ErrorCode, result.ErrorDescription)
}
// taskId may be a JSON number (2captcha, anti-captcha) or a quoted string (capsolver UUIDs).
// Strip surrounding quotes if present so we always work with the underlying value.
taskID := strings.Trim(string(result.TaskID), `"`)
if taskID == "" {
return "", types.NewCaptchaAPIError("createTask", "EMPTY_TASK_ID", "service returned a successful response with no taskId")
}
return taskID, nil
}
// captchaPollResult polls the getTaskResult endpoint at captchaPollInterval until the task
// is solved, an error is returned by the service, or the overall captchaPollTimeout is hit.
//
// It can return the following errors:
// - types.CaptchaPollTimeoutError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaSolutionKeyError
func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), captchaPollTimeout)
defer cancel()
ticker := time.NewTicker(captchaPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return "", types.NewCaptchaPollTimeoutError(taskID)
case <-ticker.C:
token, err := captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString)
if errors.Is(err, types.ErrCaptchaProcessing) {
continue
}
// Retry on transient HTTP errors (timeouts, connection resets, etc.)
// instead of failing the entire solve. The poll loop timeout will
// eventually catch permanently unreachable services.
if _, ok := errors.AsType[types.CaptchaRequestError](err); ok {
continue
}
if err != nil {
return "", err
}
return token, nil
}
}
}
// captchaGetTaskResult fetches a single task result from the captcha service.
//
// It can return the following errors:
// - types.ErrCaptchaProcessing
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaSolutionKeyError
func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) {
var bodyMap map[string]any
if taskIDIsString {
bodyMap = map[string]any{"clientKey": apiKey, "taskId": taskID}
} else {
bodyMap = map[string]any{"clientKey": apiKey, "taskId": json.Number(taskID)}
}
data, err := json.Marshal(bodyMap)
if err != nil {
return "", types.NewCaptchaDecodeError("getTaskResult", err)
}
resp, err := captchaHTTPClient.Post(
baseURL+"/getTaskResult",
"application/json",
bytes.NewReader(data),
)
if err != nil {
return "", types.NewCaptchaRequestError("getTaskResult", err)
}
defer resp.Body.Close() //nolint:errcheck
var result struct {
ErrorID int `json:"errorId"`
ErrorCode string `json:"errorCode"`
ErrorDescription string `json:"errorDescription"`
Status string `json:"status"`
Solution map[string]any `json:"solution"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", types.NewCaptchaDecodeError("getTaskResult", err)
}
if result.ErrorID != 0 {
return "", types.NewCaptchaAPIError("getTaskResult", result.ErrorCode, result.ErrorDescription)
}
if result.Status == "processing" || result.Status == "idle" {
return "", types.ErrCaptchaProcessing
}
token, ok := result.Solution[solutionKey]
if !ok {
return "", types.NewCaptchaSolutionKeyError(solutionKey)
}
tokenStr, ok := token.(string)
if !ok {
return "", types.NewCaptchaSolutionKeyError(solutionKey)
}
return tokenStr, nil
}
// ======================================== 2Captcha ========================================
const twoCaptchaBaseURL = "https://api.2captcha.com"
// twoCaptchaSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via 2Captcha.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
return solveCaptcha(twoCaptchaBaseURL, apiKey, map[string]any{
"type": "RecaptchaV2TaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}, "gRecaptchaResponse", false)
}
// twoCaptchaSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via 2Captcha.
// pageAction may be empty.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
task := map[string]any{
"type": "RecaptchaV3TaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}
if pageAction != "" {
task["pageAction"] = pageAction
}
return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false)
}
// twoCaptchaSolveTurnstile solves a Cloudflare Turnstile challenge via 2Captcha.
// cData may be empty.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
task := map[string]any{
"type": "TurnstileTaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}
if cData != "" {
task["data"] = cData
}
return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "token", false)
}
// ======================================== Anti-Captcha ========================================
const antiCaptchaBaseURL = "https://api.anti-captcha.com"
// antiCaptchaSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via Anti-Captcha.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
"type": "RecaptchaV2TaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}, "gRecaptchaResponse", false)
}
// antiCaptchaSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via Anti-Captcha.
// pageAction may be empty. minScore is hardcoded to 0.3 (the loosest threshold) because
// Anti-Captcha rejects the request without it.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
task := map[string]any{
"type": "RecaptchaV3TaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
"minScore": 0.3,
}
if pageAction != "" {
task["pageAction"] = pageAction
}
return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false)
}
// antiCaptchaSolveHCaptcha solves an hCaptcha challenge via Anti-Captcha.
// Anti-Captcha returns hCaptcha tokens under "gRecaptchaResponse" (not "token").
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, error) {
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
"type": "HCaptchaTaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}, "gRecaptchaResponse", false)
}
// antiCaptchaSolveTurnstile solves a Cloudflare Turnstile challenge via Anti-Captcha.
// cData may be empty.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
task := map[string]any{
"type": "TurnstileTaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}
if cData != "" {
task["cData"] = cData
}
return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "token", false)
}
// ======================================== CapSolver ========================================
const capSolverBaseURL = "https://api.capsolver.com"
// capSolverSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via CapSolver.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
return solveCaptcha(capSolverBaseURL, apiKey, map[string]any{
"type": "ReCaptchaV2TaskProxyLess",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}, "gRecaptchaResponse", true)
}
// capSolverSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via CapSolver.
// pageAction may be empty.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
task := map[string]any{
"type": "ReCaptchaV3TaskProxyLess",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}
if pageAction != "" {
task["pageAction"] = pageAction
}
return solveCaptcha(capSolverBaseURL, apiKey, task, "gRecaptchaResponse", true)
}
// capSolverSolveTurnstile solves a Cloudflare Turnstile challenge via CapSolver.
// cData may be empty. CapSolver nests cData under a "metadata" object.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
task := map[string]any{
"type": "AntiTurnstileTaskProxyLess",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}
if cData != "" {
task["metadata"] = map[string]any{"cdata": cData}
}
return solveCaptcha(capSolverBaseURL, apiKey, task, "token", true)
}
-7
View File
@@ -12,10 +12,3 @@ func NewDefaultRandSource() rand.Source {
uint64(now>>32), uint64(now>>32),
) )
} }
func firstOrEmpty(values []string) string {
if len(values) == 0 {
return ""
}
return values[0]
}
+31 -83
View File
@@ -56,9 +56,9 @@ func NewRequestGenerator(
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, lazyTemplateRoot) pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, lazyTemplateRoot)
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, lazyTemplateRoot) methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, lazyTemplateRoot)
paramsGenerator, isParamsGeneratorDynamic, paramKeysAreStatic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot) paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
headersGenerator, isHeadersGeneratorDynamic, headerKeysAreStatic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot) headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
cookiesGenerator, isCookiesGeneratorDynamic, cookieKeysAreStatic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot) cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{} bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
var bodyTemplateRoot *template.Template var bodyTemplateRoot *template.Template
@@ -83,26 +83,13 @@ func NewRequestGenerator(
Cookies: make(map[string][]string), Cookies: make(map[string][]string),
} }
// When a map's key set is fixed, every request refills exactly the same keys, so
// the value slices can be truncated and reused instead of being reallocated after
// a clear(). Scripts are excluded: they may swap the maps out or add keys of their
// own, and they would observe a leftover empty slice where a key used to be absent.
reuseParamSlices := paramKeysAreStatic && !hasScripts
reuseHeaderSlices := headerKeysAreStatic && !hasScripts
reuseCookieSlices := cookieKeysAreStatic && !hasScripts
var ( var (
data valuesData data valuesData
path string path string
err error err error
) )
return func(req *fasthttp.Request) error { return func(req *fasthttp.Request) error {
resetStringSliceMap(reqData.Headers, reuseHeaderSlices) resetRequestData(reqData)
resetStringSliceMap(reqData.Params, reuseParamSlices)
resetStringSliceMap(reqData.Cookies, reuseCookieSlices)
reqData.Method = ""
reqData.Path = ""
reqData.Body = ""
data, err = valuesGenerator() data, err = valuesGenerator()
if err != nil { if err != nil {
@@ -156,24 +143,20 @@ func NewRequestGenerator(
hasScripts hasScripts
} }
// resetStringSliceMap empties m for the next render. With reuse it truncates the func resetRequestData(reqData *script.RequestData) {
// value slices in place so the following appends land in the existing backing reqData.Method = ""
// arrays, without it the map is cleared so no stale key can survive. reqData.Path = ""
func resetStringSliceMap(m map[string][]string, reuse bool) { reqData.Body = ""
if !reuse { clear(reqData.Headers)
clear(m) clear(reqData.Params)
return clear(reqData.Cookies)
}
for k, v := range m {
m[k] = v[:0]
}
} }
func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) { func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) {
req.Header.SetHost(host) req.Header.SetHost(host)
req.SetRequestURI(reqData.Path) req.SetRequestURI(reqData.Path)
req.Header.SetMethod(reqData.Method) req.Header.SetMethod(reqData.Method)
req.SetBodyString(reqData.Body) req.SetBody([]byte(reqData.Body))
for k, values := range reqData.Headers { for k, values := range reqData.Headers {
for _, v := range values { for _, v := range values {
@@ -181,28 +164,20 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
} }
} }
if len(reqData.Params) > 0 {
args := req.URI().QueryArgs()
for k, values := range reqData.Params { for k, values := range reqData.Params {
for _, v := range values { for _, v := range values {
args.Add(k, v) req.URI().QueryArgs().Add(k, v)
}
} }
} }
if len(reqData.Cookies) > 0 { if len(reqData.Cookies) > 0 {
var sb strings.Builder cookieStrings := make([]string, 0, len(reqData.Cookies))
for k, values := range reqData.Cookies { for k, values := range reqData.Cookies {
for _, v := range values { for _, v := range values {
if sb.Len() > 0 { cookieStrings = append(cookieStrings, k+"="+v)
sb.WriteString("; ")
}
sb.WriteString(k)
sb.WriteByte('=')
sb.WriteString(v)
} }
} }
req.Header.Add("Cookie", sb.String()) req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
} }
if scheme == "https" { if scheme == "https" {
@@ -246,8 +221,8 @@ func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func()
}, isDynamic }, isDynamic
} }
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) { func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, params, lazyRoot) generators, isDynamic := buildKeyValueGenerators(localRand, params, lazyRoot)
var ( var (
key, value string key, value string
@@ -268,11 +243,11 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot
reqData.Params[key] = append(reqData.Params[key], value) reqData.Params[key] = append(reqData.Params[key], value)
} }
return nil return nil
}, isDynamic, keysAreStatic }, isDynamic
} }
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) { func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, headers, lazyRoot) generators, isDynamic := buildKeyValueGenerators(localRand, headers, lazyRoot)
var ( var (
key, value string key, value string
@@ -293,11 +268,11 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRo
reqData.Headers[key] = append(reqData.Headers[key], value) reqData.Headers[key] = append(reqData.Headers[key], value)
} }
return nil return nil
}, isDynamic, keysAreStatic }, isDynamic
} }
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) { func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, cookies, lazyRoot) generators, isDynamic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
var ( var (
key, value string key, value string
@@ -318,26 +293,14 @@ func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRo
reqData.Cookies[key] = append(reqData.Cookies[key], value) reqData.Cookies[key] = append(reqData.Cookies[key], value)
} }
return nil return nil
}, isDynamic, keysAreStatic }, isDynamic
} }
func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) { func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) {
// No values configured: hand back one shared empty map instead of allocating a
// fresh one for every request. Nothing ever writes to it.
if len(values) == 0 {
empty := valuesData{Values: map[string]string{}}
return func() (valuesData, error) { return empty, nil }
}
generators := make([]func(_ any) (string, error), len(values)) generators := make([]func(_ any) (string, error), len(values))
isDynamic := false
for i, v := range values { for i, v := range values {
var valueIsDynamic bool generators[i], _ = createTemplateFunc(v, lazyRoot)
generators[i], valueIsDynamic = createTemplateFunc(v, lazyRoot)
if valueIsDynamic {
isDynamic = true
}
} }
var ( var (
@@ -345,7 +308,7 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
data map[string]string data map[string]string
err error err error
) )
generate := func() (valuesData, error) { return func() (valuesData, error) {
result := make(map[string]string) result := make(map[string]string)
for _, generator := range generators { for _, generator := range generators {
rendered, err = generator(nil) rendered, err = generator(nil)
@@ -363,15 +326,6 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
return valuesData{Values: result}, nil return valuesData{Values: result}, nil
} }
// Every value is a literal, so the parsed result is identical on every request:
// render and parse once instead of re-running godotenv per request.
if !isDynamic {
staticResult, staticErr := generate()
return func() (valuesData, error) { return staticResult, staticErr }
}
return generate
} }
func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) { func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) {
@@ -381,19 +335,15 @@ func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(
tmpl, err := lazyRoot().New("").Parse(value) tmpl, err := lazyRoot().New("").Parse(value)
if err == nil && hasTemplateActions(tmpl) { if err == nil && hasTemplateActions(tmpl) {
var ( var err error
buf bytes.Buffer
err error
)
return func(data any) (string, error) { return func(data any) (string, error) {
buf.Reset() var buf bytes.Buffer
if err = tmpl.Execute(&buf, data); err != nil { if err = tmpl.Execute(&buf, data); err != nil {
return "", types.NewTemplateRenderError(err) return "", types.NewTemplateRenderError(err)
} }
return buf.String(), nil return buf.String(), nil
}, true }, true
} }
return func(_ any) (string, error) { return value, nil }, false return func(_ any) (string, error) { return value, nil }, false
} }
@@ -410,9 +360,8 @@ func buildKeyValueGenerators[T keyValueItem](
localRand *rand.Rand, localRand *rand.Rand,
items []T, items []T,
lazyRoot func() *template.Template, lazyRoot func() *template.Template,
) ([]keyValueGenerator, bool, bool) { ) ([]keyValueGenerator, bool) {
isDynamic := false isDynamic := false
keysAreStatic := true
generators := make([]keyValueGenerator, len(items)) generators := make([]keyValueGenerator, len(items))
for generatorIndex, item := range items { for generatorIndex, item := range items {
@@ -423,7 +372,6 @@ func buildKeyValueGenerators[T keyValueItem](
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot) keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot)
if keyIsDynamic { if keyIsDynamic {
isDynamic = true isDynamic = true
keysAreStatic = false
} }
// Generate value functions // Generate value functions
@@ -446,7 +394,7 @@ func buildKeyValueGenerators[T keyValueItem](
} }
} }
return generators, isDynamic, keysAreStatic return generators, isDynamic
} }
func buildStringSliceGenerator( func buildStringSliceGenerator(
+4 -3
View File
@@ -2,6 +2,7 @@ package sarin
import ( import (
"encoding/json" "encoding/json"
"fmt"
"math/big" "math/big"
"os" "os"
"slices" "slices"
@@ -9,8 +10,8 @@ import (
"sync" "sync"
"time" "time"
"charm.land/lipgloss/v2" "github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2/table" "github.com/charmbracelet/lipgloss/table"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
@@ -158,7 +159,7 @@ func (data *SarinResponseData) PrintTable() {
return cellStyle return cellStyle
}) })
lipgloss.Println(tbl) fmt.Println(tbl)
} }
func (data *SarinResponseData) PrintJSON() { func (data *SarinResponseData) PrintJSON() {
-497
View File
@@ -1,497 +0,0 @@
package sarin
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/charmbracelet/x/term"
"github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types"
)
type runtimeLogLevel uint8
const (
runtimeLogLevelInfo runtimeLogLevel = iota
runtimeLogLevelError
)
type runtimeLog struct {
timestamp time.Time
level runtimeLogLevel
text string
}
type runtimeLogger func(level runtimeLogLevel, text string)
// SplitLogLevels parses a comma-separated log-level string into a deduplicated
// slice of level tokens (surrounding whitespace trimmed, empties dropped). Level
// names are matched case-sensitively, like the other enum options.
func SplitLogLevels(levels string) []string {
var out []string
seen := make(map[string]bool)
for part := range strings.SplitSeq(levels, ",") {
token := strings.TrimSpace(part)
if token == "" || seen[token] {
continue
}
seen[token] = true
out = append(out, token)
}
return out
}
// IsInteractiveTerminal reports whether fd is a terminal that can actually be
// drawn on. A pty reports as a terminal even while its size is still zero, and
// Bubble Tea paints into a width x height cell buffer, so a zero dimension
// renders nothing at all.
func IsInteractiveTerminal(fd uintptr) bool {
if !term.IsTerminal(fd) {
return false
}
width, height, err := term.GetSize(fd)
return err == nil && width > 0 && height > 0
}
// respLogger logs a single completed response.
type respLogger func(duration time.Duration, resp *fasthttp.Response)
func noopLog(runtimeLogLevel, string) {}
func noopRespLog(time.Duration, *fasthttp.Response) {}
// gateSendLog wraps emit with level filtering decided once from the enabled
// levels. It stays general (any level filters correctly) while avoiding a
// per-log check when both levels are on and any work at all when both are off.
func gateSendLog(logInfo, logError bool, emit func(level runtimeLogLevel, text string)) runtimeLogger {
switch {
case logInfo && logError:
return emit
case logInfo:
return func(level runtimeLogLevel, text string) {
if level == runtimeLogLevelInfo {
emit(level, text)
}
}
case logError:
return func(level runtimeLogLevel, text string) {
if level == runtimeLogLevelError {
emit(level, text)
}
}
default:
return noopLog
}
}
// respBodySnippetLen bounds how many bytes of the body the compact (TUI)
// rendering shows.
const respBodySnippetLen = 100
// formatRuntimeLogLine renders a runtime log as a plain (unstyled) line for
// stderr output. ANSI styling is left to the TUI.
func formatRuntimeLogLine(timestamp time.Time, level runtimeLogLevel, text string) string {
levelStr := "ERROR"
if level == runtimeLogLevelInfo {
levelStr = "INFO"
}
return "[" + timestamp.Format("15:04:05") + "] " + levelStr + ": " + text
}
// respToLog renders a response as a compact one-line summary for the TUI log box
// (the "[time] INFO:" prefix is added by the TUI).
func respToLog(duration time.Duration, resp *fasthttp.Response) string {
var sb strings.Builder
sb.WriteString(statusCodeToString(resp.StatusCode()))
sb.WriteString(" ")
sb.WriteString(Duration(duration).String())
if snippet := bodySnippet(resp.Body(), respBodySnippetLen); snippet != "" {
sb.WriteString(" | ")
sb.WriteString(snippet)
}
return sb.String()
}
type respLogEntry struct {
Status int `json:"status"`
Duration string `json:"duration"`
Headers map[string][]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
}
// respToLogJSON renders a response as a single, self-contained JSON line so the
// stderr stream stays valid NDJSON (pipeable to jq or any consumer).
func respToLogJSON(duration time.Duration, resp *fasthttp.Response) string {
entry := respLogEntry{
Status: resp.StatusCode(),
Duration: Duration(duration).String(),
Headers: collectRespHeaders(resp),
Body: string(resp.Body()),
}
data, err := json.Marshal(entry)
if err != nil {
return `{"error":"failed to marshal response log"}`
}
return string(data)
}
func collectRespHeaders(resp *fasthttp.Response) map[string][]string {
headers := make(map[string][]string)
for key, value := range resp.Header.All() {
k := string(key)
headers[k] = append(headers[k], string(value))
}
return headers
}
// bodySnippet returns the first maxLen bytes of body collapsed onto a single
// line (control characters replaced with spaces), with an ellipsis when truncated.
func bodySnippet(body []byte, maxLen int) string {
if len(body) == 0 {
return ""
}
truncated := false
if len(body) > maxLen {
body = body[:maxLen]
truncated = true
}
s := strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
return r
}, string(body))
if truncated {
s += "..."
}
return s
}
type sarin struct {
workers uint
requestURL *url.URL
methods []string
params types.Params
headers types.Headers
cookies types.Cookies
bodies []string
totalRequests *uint64
totalDuration *time.Duration
timeout time.Duration
showProgress bool
skipCertVerify bool
values []string
collectStats bool
dryRun bool
logInfo bool
logError bool
logFile string
hostClients []*fasthttp.HostClient
responses *SarinResponseData
fileCache *FileCache
scriptChain *script.Chain
}
// NewSarin creates a new sarin instance for load testing.
// It can return the following errors:
// - types.ProxyDialError
// - types.ErrScriptEmpty
// - types.ScriptLoadError
func NewSarin(
ctx context.Context,
methods []string,
requestURL *url.URL,
timeout time.Duration,
workers uint,
totalRequests *uint64,
totalDuration *time.Duration,
showProgress bool,
skipCertVerify bool,
params types.Params,
headers types.Headers,
cookies types.Cookies,
bodies []string,
proxies types.Proxies,
values []string,
collectStats bool,
dryRun bool,
logLevel string,
logFile string,
luaScripts []string,
jsScripts []string,
) (*sarin, error) {
if workers == 0 {
workers = 1
}
// Resolve which log levels are enabled once, up front.
var logInfo, logError bool
for _, level := range SplitLogLevels(logLevel) {
switch level {
case "info":
logInfo = true
case "error":
logError = true
}
}
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
if err != nil {
return nil, err
}
// Load script sources
luaSources, err := script.LoadSources(ctx, luaScripts, script.EngineTypeLua)
if err != nil {
return nil, err
}
jsSources, err := script.LoadSources(ctx, jsScripts, script.EngineTypeJavaScript)
if err != nil {
return nil, err
}
scriptChain := script.NewChain(luaSources, jsSources)
srn := &sarin{
workers: workers,
requestURL: requestURL,
methods: methods,
params: params,
headers: headers,
cookies: cookies,
bodies: bodies,
totalRequests: totalRequests,
totalDuration: totalDuration,
timeout: timeout,
showProgress: showProgress,
skipCertVerify: skipCertVerify,
values: values,
collectStats: collectStats,
dryRun: dryRun,
logInfo: logInfo,
logError: logError,
logFile: logFile,
hostClients: hostClients,
fileCache: NewFileCache(time.Second * 10),
scriptChain: scriptChain,
}
if collectStats {
srn.responses = NewSarinResponseData(uint32(100))
}
return srn, nil
}
func (s sarin) GetResponses() *SarinResponseData {
return s.responses
}
func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
jobsCtx, jobsCancel := context.WithCancel(ctx)
var workersWG sync.WaitGroup
jobsCh := make(chan struct{}, max(s.workers, 1))
var counter atomic.Uint64
totalRequests := uint64(0)
if s.totalRequests != nil {
totalRequests = *s.totalRequests
}
onTerminal := IsInteractiveTerminal(os.Stdout.Fd())
// The progress bar needs an interactive terminal to render.
showProgressBar := s.showProgress && onTerminal
// The bubbletea TUI hosts the bar and/or the live log box, so it runs whenever
// either has something to show: the bar, or logs that would land in the box
// (i.e. logs are enabled and not redirected to a file).
runTUI := showProgressBar || (onTerminal && (s.logInfo || s.logError) && s.logFile == "")
// Open the log file up front, before registering any defer, so a bad path
// exits cleanly.
var logFile *os.File
if s.logFile != "" {
f, err := os.OpenFile(s.logFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
fmt.Fprintln(os.Stderr, "failed to open log file "+s.logFile+": "+err.Error())
os.Exit(1)
}
defer f.Close() //nolint:errcheck
logFile = f
}
var (
streamCtx context.Context
streamCancel context.CancelFunc
streamCh chan struct{}
tuiLogChannel chan runtimeLog
)
if runTUI {
streamCtx, streamCancel = context.WithCancel(context.Background())
defer streamCancel()
streamCh = make(chan struct{})
tuiLogChannel = make(chan runtimeLog, max(s.workers, 1))
}
// Route logs: to the file if given, else to the TUI log box while it runs,
// otherwise to stderr.
var (
sendLog runtimeLogger
sendRespLog respLogger
)
switch {
case logFile != nil:
sendLog, sendRespLog = s.newWriterLog(logFile)
case runTUI:
sendLog, sendRespLog = s.newChannelLog(tuiLogChannel)
default:
sendLog, sendRespLog = s.newWriterLog(os.Stderr)
}
// Start workers
s.startWorkers(&workersWG, jobsCh, s.hostClients, &counter, sendLog, sendRespLog)
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)
}
// Setup duration-based cancellation
s.setupDurationTimeout(ctx, jobsCancel)
// Distribute jobs to workers.
// This blocks until all jobs are sent or the context is canceled.
s.sendJobs(jobsCtx, jobsCh)
// Close the jobs channel so workers stop after completing their current job
close(jobsCh)
// Wait until all workers stopped
workersWG.Wait()
if tuiLogChannel != nil {
close(tuiLogChannel)
}
if runTUI {
// Stop the progress streaming
streamCancel()
// Wait until progress streaming has completely stopped
<-streamCh
}
}
// newWriterLog builds the loggers that write formatted lines to w (a log file or
// stderr). sendLog stays general (it filters by each log's level); sendRespLog
// only ever emits info, so its decision is baked once into a no-op when off.
func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
// log.Logger serializes writes with its own mutex, so concurrent workers
// won't interleave lines.
logger := log.New(w, "", 0)
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
logger.Println(formatRuntimeLogLine(time.Now(), level, text))
})
var sendRespLog respLogger = noopRespLog
if s.logInfo {
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
logger.Println(formatRuntimeLogLine(time.Now(), runtimeLogLevelInfo, respToLogJSON(duration, resp)))
}
}
return sendLog, sendRespLog
}
// newChannelLog builds the loggers that feed the TUI log box through ch, with the
// same gating as newWriterLog.
func (s sarin) newChannelLog(ch chan<- runtimeLog) (runtimeLogger, respLogger) {
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
ch <- runtimeLog{timestamp: time.Now(), level: level, text: text}
})
var sendRespLog respLogger = noopRespLog
if s.logInfo {
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
ch <- runtimeLog{timestamp: time.Now(), level: runtimeLogLevelInfo, text: respToLog(duration, resp)}
}
}
return sendLog, sendRespLog
}
// newHostClients initializes HTTP clients for the given configuration.
// It can return the following errors:
// - types.ProxyDialError
func newHostClients(
ctx context.Context,
timeout time.Duration,
proxies types.Proxies,
workers uint,
requestURL *url.URL,
skipCertVerify bool,
) ([]*fasthttp.HostClient, error) {
proxiesRaw := make([]url.URL, len(proxies))
for i, proxy := range proxies {
proxiesRaw[i] = url.URL(proxy)
}
return NewHostClients(
ctx,
timeout,
proxiesRaw,
workers,
requestURL,
skipCertVerify,
)
}
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
for range max(s.workers, 1) {
wg.Go(func() {
s.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendLog, sendRespLog)
})
}
}
func (s sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
if s.totalDuration != nil {
go func() {
timer := time.NewTimer(*s.totalDuration)
defer timer.Stop()
select {
case <-timer.C:
cancel()
case <-ctx.Done():
// Context cancelled, cleanup
}
}()
}
}
func (s sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
if s.totalRequests != nil && *s.totalRequests > 0 {
for range *s.totalRequests {
if ctx.Err() != nil {
break
}
jobs <- struct{}{}
}
} else {
for ctx.Err() == nil {
jobs <- struct{}{}
}
}
}
+816
View File
@@ -0,0 +1,816 @@
package sarin
import (
"context"
"net/url"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/term"
"github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types"
)
type runtimeMessageLevel uint8
const (
runtimeMessageLevelWarning runtimeMessageLevel = iota
runtimeMessageLevelError
)
type runtimeMessage struct {
timestamp time.Time
level runtimeMessageLevel
text string
}
type messageSender func(level runtimeMessageLevel, text string)
type sarin struct {
workers uint
requestURL *url.URL
methods []string
params types.Params
headers types.Headers
cookies types.Cookies
bodies []string
totalRequests *uint64
totalDuration *time.Duration
timeout time.Duration
quiet bool
skipCertVerify bool
values []string
collectStats bool
dryRun bool
hostClients []*fasthttp.HostClient
responses *SarinResponseData
fileCache *FileCache
scriptChain *script.Chain
}
// NewSarin creates a new sarin instance for load testing.
// It can return the following errors:
// - types.ProxyDialError
// - types.ErrScriptEmpty
// - types.ScriptLoadError
func NewSarin(
ctx context.Context,
methods []string,
requestURL *url.URL,
timeout time.Duration,
workers uint,
totalRequests *uint64,
totalDuration *time.Duration,
quiet bool,
skipCertVerify bool,
params types.Params,
headers types.Headers,
cookies types.Cookies,
bodies []string,
proxies types.Proxies,
values []string,
collectStats bool,
dryRun bool,
luaScripts []string,
jsScripts []string,
) (*sarin, error) {
if workers == 0 {
workers = 1
}
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
if err != nil {
return nil, err
}
// Load script sources
luaSources, err := script.LoadSources(ctx, luaScripts, script.EngineTypeLua)
if err != nil {
return nil, err
}
jsSources, err := script.LoadSources(ctx, jsScripts, script.EngineTypeJavaScript)
if err != nil {
return nil, err
}
scriptChain := script.NewChain(luaSources, jsSources)
srn := &sarin{
workers: workers,
requestURL: requestURL,
methods: methods,
params: params,
headers: headers,
cookies: cookies,
bodies: bodies,
totalRequests: totalRequests,
totalDuration: totalDuration,
timeout: timeout,
quiet: quiet,
skipCertVerify: skipCertVerify,
values: values,
collectStats: collectStats,
dryRun: dryRun,
hostClients: hostClients,
fileCache: NewFileCache(time.Second * 10),
scriptChain: scriptChain,
}
if collectStats {
srn.responses = NewSarinResponseData(uint32(100))
}
return srn, nil
}
func (q sarin) GetResponses() *SarinResponseData {
return q.responses
}
func (q sarin) Start(ctx context.Context) {
jobsCtx, jobsCancel := context.WithCancel(ctx)
var workersWG sync.WaitGroup
jobsCh := make(chan struct{}, max(q.workers, 1))
var counter atomic.Uint64
totalRequests := uint64(0)
if q.totalRequests != nil {
totalRequests = *q.totalRequests
}
var streamCtx context.Context
var streamCancel context.CancelFunc
var streamCh chan struct{}
var messageChannel chan runtimeMessage
var sendMessage messageSender
if !q.quiet && !term.IsTerminal(os.Stdout.Fd()) {
q.quiet = true
}
if q.quiet {
sendMessage = func(level runtimeMessageLevel, text string) {}
} else {
streamCtx, streamCancel = context.WithCancel(context.Background())
defer streamCancel()
streamCh = make(chan struct{})
messageChannel = make(chan runtimeMessage, max(q.workers, 1))
sendMessage = func(level runtimeMessageLevel, text string) {
messageChannel <- runtimeMessage{
timestamp: time.Now(),
level: level,
text: text,
}
}
}
// Start workers
q.startWorkers(&workersWG, jobsCh, q.hostClients, &counter, sendMessage)
if !q.quiet {
// Start streaming to terminal
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
go q.streamProgress(streamCtx, jobsCancel, streamCh, totalRequests, &counter, messageChannel)
}
// Setup duration-based cancellation
q.setupDurationTimeout(ctx, jobsCancel)
// Distribute jobs to workers.
// This blocks until all jobs are sent or the context is canceled.
q.sendJobs(jobsCtx, jobsCh)
// Close the jobs channel so workers stop after completing their current job
close(jobsCh)
// Wait until all workers stopped
workersWG.Wait()
if messageChannel != nil {
close(messageChannel)
}
if !q.quiet {
// Stop the progress streaming
streamCancel()
// Wait until progress streaming has completely stopped
<-streamCh
}
}
func (q sarin) Worker(
jobs <-chan struct{},
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
// Create script transformer for this worker (engines are not thread-safe)
// Scripts are pre-validated in NewSarin, so this should not fail
var scriptTransformer *script.Transformer
if !q.scriptChain.IsEmpty() {
var err error
scriptTransformer, err = q.scriptChain.NewTransformer()
if err != nil {
panic(err)
}
defer scriptTransformer.Close()
}
requestGenerator, isDynamic := NewRequestGenerator(
q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache, scriptTransformer,
)
if q.dryRun {
switch {
case q.collectStats && isDynamic:
q.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
case q.collectStats && !isDynamic:
q.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
case !q.collectStats && isDynamic:
q.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
default:
q.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
}
} else {
switch {
case q.collectStats && isDynamic:
q.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
case q.collectStats && !isDynamic:
q.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
case !q.collectStats && isDynamic:
q.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
default:
q.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
}
}
}
func (q sarin) workerStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
resp.Reset()
if err := requestGenerator(req); err != nil {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
continue
}
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
if err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
} else {
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
}
counter.Add(1)
}
}
func (q sarin) workerStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors
for range jobs {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
}
return
}
for range jobs {
resp.Reset()
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
if err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
} else {
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
}
counter.Add(1)
}
}
func (q sarin) workerNoStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
resp.Reset()
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
continue
}
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
counter.Add(1)
}
}
func (q sarin) workerNoStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
// Static request generation failed - just count the jobs without sending
for range jobs {
counter.Add(1)
}
return
}
for range jobs {
resp.Reset()
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
counter.Add(1)
}
}
const dryRunResponseKey = "dry-run"
// statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599.
var statusCodeStrings = func() map[int]string {
m := make(map[int]string, 500)
for i := 100; i < 600; i++ {
m[i] = strconv.Itoa(i)
}
return m
}()
// statusCodeToString returns a string representation of the HTTP status code.
// Uses a pre-computed map for codes 100-599, falls back to strconv.Itoa for others.
func statusCodeToString(code int) string {
if s, ok := statusCodeStrings[code]; ok {
return s
}
return strconv.Itoa(code)
}
func (q sarin) workerDryRunStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
startTime := time.Now()
if err := requestGenerator(req); err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
continue
}
q.responses.Add(dryRunResponseKey, time.Since(startTime))
counter.Add(1)
}
}
func (q sarin) workerDryRunStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors
for range jobs {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
counter.Add(1)
}
return
}
for range jobs {
q.responses.Add(dryRunResponseKey, 0)
counter.Add(1)
}
}
func (q sarin) workerDryRunNoStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
for range jobs {
req.Reset()
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
}
counter.Add(1)
}
}
func (q sarin) workerDryRunNoStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
) {
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
}
for range jobs {
counter.Add(1)
}
}
// newHostClients initializes HTTP clients for the given configuration.
// It can return the following errors:
// - types.ProxyDialError
func newHostClients(
ctx context.Context,
timeout time.Duration,
proxies types.Proxies,
workers uint,
requestURL *url.URL,
skipCertVerify bool,
) ([]*fasthttp.HostClient, error) {
proxiesRaw := make([]url.URL, len(proxies))
for i, proxy := range proxies {
proxiesRaw[i] = url.URL(proxy)
}
return NewHostClients(
ctx,
timeout,
proxiesRaw,
workers,
requestURL,
skipCertVerify,
)
}
func (q sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendMessage messageSender) {
for range max(q.workers, 1) {
wg.Go(func() {
q.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendMessage)
})
}
}
func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
if q.totalDuration != nil {
go func() {
timer := time.NewTimer(*q.totalDuration)
defer timer.Stop()
select {
case <-timer.C:
cancel()
case <-ctx.Done():
// Context cancelled, cleanup
}
}()
}
}
func (q sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
if q.totalRequests != nil && *q.totalRequests > 0 {
for range *q.totalRequests {
if ctx.Err() != nil {
break
}
jobs <- struct{}{}
}
} else {
for ctx.Err() == nil {
jobs <- struct{}{}
}
}
}
type tickMsg time.Time
var (
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D")).Bold(true)
messageChannelStyle = lipgloss.NewStyle().
Border(lipgloss.ThickBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#757575")).
PaddingLeft(1).
Margin(1, 0, 0, 0).
Foreground(lipgloss.Color("#888888"))
)
type progressModel struct {
progress progress.Model
startTime time.Time
messages []string
counter *atomic.Uint64
current uint64
maxValue uint64
ctx context.Context //nolint:containedctx
cancel context.CancelFunc
cancelling bool
}
func (m progressModel) Init() tea.Cmd {
return tea.Batch(progressTickCmd())
}
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.cancelling = true
m.cancel()
}
return m, nil
case tea.WindowSizeMsg:
m.progress.Width = max(10, msg.Width-1)
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
case runtimeMessage:
var msgBuilder strings.Builder
msgBuilder.WriteString("[")
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
msgBuilder.WriteString("] ")
switch msg.level {
case runtimeMessageLevelError:
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
case runtimeMessageLevelWarning:
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
}
msgBuilder.WriteString(msg.text)
m.messages = append(m.messages[1:], msgBuilder.String())
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
case tickMsg:
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, progressTickCmd()
default:
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
}
}
func (m progressModel) View() string {
var messagesBuilder strings.Builder
for i, msg := range m.messages {
if len(msg) > 0 {
messagesBuilder.WriteString(msg)
if i < len(m.messages)-1 {
messagesBuilder.WriteString("\n")
}
}
}
var finalBuilder strings.Builder
if messagesBuilder.Len() > 0 {
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
finalBuilder.WriteString("\n")
}
m.current = m.counter.Load()
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.current, 10))
finalBuilder.WriteString("/")
finalBuilder.WriteString(strconv.FormatUint(m.maxValue, 10))
finalBuilder.WriteString(" - ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(m.progress.ViewAs(float64(m.current) / float64(m.maxValue)))
finalBuilder.WriteString("\n\n ")
if m.cancelling {
finalBuilder.WriteString(helpStyle.Render("Stopping..."))
} else {
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
}
return finalBuilder.String()
}
func progressTickCmd() tea.Cmd {
return tea.Tick(time.Millisecond*250, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
var infiniteProgressStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00D4FF"))
type infiniteProgressModel struct {
spinner spinner.Model
startTime time.Time
counter *atomic.Uint64
messages []string
ctx context.Context //nolint:containedctx
quit bool
cancel context.CancelFunc
cancelling bool
}
func (m infiniteProgressModel) Init() tea.Cmd {
return m.spinner.Tick
}
func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.cancelling = true
m.cancel()
}
return m, nil
case runtimeMessage:
var msgBuilder strings.Builder
msgBuilder.WriteString("[")
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
msgBuilder.WriteString("] ")
switch msg.level {
case runtimeMessageLevelError:
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
case runtimeMessageLevelWarning:
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
}
msgBuilder.WriteString(msg.text)
m.messages = append(m.messages[1:], msgBuilder.String())
if m.ctx.Err() != nil {
m.quit = true
return m, tea.Quit
}
return m, nil
default:
if m.ctx.Err() != nil {
m.quit = true
return m, tea.Quit
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
}
func (m infiniteProgressModel) View() string {
var messagesBuilder strings.Builder
for i, msg := range m.messages {
if len(msg) > 0 {
messagesBuilder.WriteString(msg)
if i < len(m.messages)-1 {
messagesBuilder.WriteString("\n")
}
}
}
var finalBuilder strings.Builder
if messagesBuilder.Len() > 0 {
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
finalBuilder.WriteString("\n")
}
if m.quit {
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n\n")
} else {
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(m.spinner.View())
finalBuilder.WriteString(" ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n\n ")
if m.cancelling {
finalBuilder.WriteString(helpStyle.Render("Stopping..."))
} else {
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
}
}
return finalBuilder.String()
}
func (q sarin) streamProgress(
ctx context.Context,
cancel context.CancelFunc,
done chan<- struct{},
total uint64,
counter *atomic.Uint64,
messageChannel <-chan runtimeMessage,
) {
var program *tea.Program
if total > 0 {
model := progressModel{
progress: progress.New(progress.WithGradient("#151594", "#00D4FF")),
startTime: time.Now(),
messages: make([]string, 8),
counter: counter,
current: 0,
maxValue: total,
ctx: ctx,
cancel: cancel,
}
program = tea.NewProgram(model)
} else {
model := infiniteProgressModel{
spinner: spinner.New(
spinner.WithSpinner(
spinner.Spinner{
Frames: []string{
"●∙∙∙∙",
"∙●∙∙∙",
"∙∙●∙∙",
"∙∙∙●∙",
"∙∙∙∙●",
"∙∙∙●∙",
"∙∙●∙∙",
"∙●∙∙∙",
},
FPS: time.Second / 8, //nolint:mnd
},
),
spinner.WithStyle(infiniteProgressStyle),
),
startTime: time.Now(),
counter: counter,
messages: make([]string, 8),
ctx: ctx,
cancel: cancel,
quit: false,
}
program = tea.NewProgram(model)
}
go func() {
for msg := range messageChannel {
program.Send(msg)
}
}()
if _, err := program.Run(); err != nil {
panic(err)
}
done <- struct{}{}
}
-55
View File
@@ -1,55 +0,0 @@
package sarin
import (
"fmt"
"os"
"sync"
"sync/atomic"
tea "charm.land/bubbletea/v2"
)
const forceExitCode = 130
// StopController coordinates a two-stage shutdown.
//
// The first Stop call cancels the supplied context so workers and the job
// loop can drain. The second Stop call restores the terminal (if a bubbletea
// program has been attached) and calls os.Exit(forceExitCode), bypassing any
// in-flight captcha polls, Lua/JS scripts, or HTTP requests that would
// otherwise keep the process alive.
type StopController struct {
count atomic.Int32
cancel func()
mu sync.Mutex
program *tea.Program
}
func NewStopController(cancel func()) *StopController {
return &StopController{cancel: cancel}
}
// AttachProgram registers the active bubbletea program so the terminal state
// can be restored before os.Exit on the forced shutdown path. Pass nil to
// detach once the program has finished.
func (s *StopController) AttachProgram(program *tea.Program) {
s.mu.Lock()
s.program = program
s.mu.Unlock()
}
func (s *StopController) Stop() {
switch s.count.Add(1) {
case 1:
s.cancel()
case 2:
s.mu.Lock()
p := s.program
s.mu.Unlock()
if p != nil {
_ = p.ReleaseTerminal()
}
fmt.Fprintln(os.Stderr, "killing...")
os.Exit(forceExitCode)
}
}
+1 -82
View File
@@ -7,7 +7,6 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json"
"math/rand/v2" "math/rand/v2"
"mime/multipart" "mime/multipart"
"strings" "strings"
@@ -86,38 +85,6 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"slice_Uint": func(values ...uint) []uint { return values }, "slice_Uint": func(values ...uint) []uint { return values },
"slice_Join": strings.Join, "slice_Join": strings.Join,
// JSON
// json_Encode marshals any value to a JSON string.
// Usage: {{ json_Encode (dict_Str "key" "value") }}
"json_Encode": func(v any) (string, error) {
data, err := json.Marshal(v)
if err != nil {
return "", types.NewJSONEncodeError(err)
}
return string(data), nil
},
// json_Object builds a JSON object from interleaved key-value pairs and returns it
// as a JSON string. Keys must be strings; values may be any JSON-encodable type.
// Usage: {{ json_Object "name" "Alice" "age" 30 }}
"json_Object": func(pairs ...any) (string, error) {
if len(pairs)%2 != 0 {
return "", types.ErrJSONObjectOddArgs
}
obj := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
return "", types.NewJSONObjectKeyError(i, pairs[i])
}
obj[key] = pairs[i+1]
}
data, err := json.Marshal(obj)
if err != nil {
return "", types.NewJSONEncodeError(err)
}
return string(data), nil
},
// Time // Time
"time_NowUnix": func() int64 { return time.Now().Unix() }, "time_NowUnix": func() int64 { return time.Now().Unix() },
"time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() }, "time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() },
@@ -607,6 +574,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() }, "fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() }, "fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() }, "fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
// "fakeit_ErrorInput": func() string { return fakeit.ErrorInput().Error() },
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() }, "fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
// Fakeit / School // Fakeit / School
@@ -617,55 +585,6 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_SongName": fakeit.SongName, "fakeit_SongName": fakeit.SongName,
"fakeit_SongArtist": fakeit.SongArtist, "fakeit_SongArtist": fakeit.SongArtist,
"fakeit_SongGenre": fakeit.SongGenre, "fakeit_SongGenre": fakeit.SongGenre,
// Captcha / 2Captcha
// Usage: {{ twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
"twocaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
return twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
},
// Usage: {{ twocaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
"twocaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
return twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
},
// Usage: {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
// {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
"twocaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
return twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
},
// Captcha / Anti-Captcha
// Usage: {{ anticaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
"anticaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
return antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
},
// Usage: {{ anticaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
"anticaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
return antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
},
// Usage: {{ anticaptcha_HCaptcha "API_KEY" "SITE_KEY" "https://example.com" }}
"anticaptcha_HCaptcha": func(apiKey, websiteKey, websiteURL string) (string, error) {
return antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey)
},
// Usage: {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
// {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
"anticaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
return antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
},
// Captcha / CapSolver
// Usage: {{ capsolver_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
"capsolver_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
return capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
},
// Usage: {{ capsolver_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
"capsolver_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
return capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
},
// Usage: {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
// {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
"capsolver_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
return capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
},
} }
} }
-346
View File
@@ -1,346 +0,0 @@
package sarin
import (
"context"
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
"charm.land/bubbles/v2/progress"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
type tickMsg time.Time
// logBatch carries the log lines accumulated since the last repaint.
type logBatch []runtimeLog
const (
// logBoxLines is how many lines the log box shows at once.
logBoxLines = 8
// logBatchInterval is how often accumulated logs are handed to the program.
logBatchInterval = 250 * time.Millisecond
)
var (
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5BC0FC")).Bold(true)
errorLabel = errorStyle.Render("ERROR: ")
infoLabel = infoStyle.Render("INFO: ")
logChannelStyle = lipgloss.NewStyle().
Border(lipgloss.ThickBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#757575")).
PaddingLeft(1).
Margin(1, 0, 0, 0).
Foreground(lipgloss.Color("#888888"))
)
// renderRuntimeLog builds a single styled log line for the TUI log box.
func renderRuntimeLog(log runtimeLog) string {
label := errorLabel
if log.level == runtimeLogLevelInfo {
label = infoLabel
}
return "[" + log.timestamp.Format("15:04:05") + "] " + label + log.text
}
// renderLogBox renders the styled log box, or "" when there are no lines.
func renderLogBox(logs []string) string {
var b strings.Builder
for i, line := range logs {
if len(line) > 0 {
b.WriteString(line)
if i < len(logs)-1 {
b.WriteString("\n")
}
}
}
if b.Len() == 0 {
return ""
}
return logChannelStyle.Render(b.String())
}
func helpLine(cancelling bool) string {
if cancelling {
return helpStyle.Render("Stopping... (Ctrl+C again to force)")
}
return helpStyle.Render("Press Ctrl+C to quit")
}
type progressModel struct {
progress progress.Model
startTime time.Time
logs []string
counter *atomic.Uint64
maxValue uint64
showBar bool
ctx context.Context //nolint:containedctx
stop func()
cancelling bool
}
func (m progressModel) Init() tea.Cmd {
return tea.Batch(progressTickCmd())
}
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
m.cancelling = true
m.stop()
}
return m, nil
case tea.WindowSizeMsg:
m.progress.SetWidth(max(10, msg.Width-1))
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
case logBatch:
for _, entry := range msg {
m.logs = append(m.logs[1:], renderRuntimeLog(entry))
}
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
case tickMsg:
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, progressTickCmd()
default:
if m.ctx.Err() != nil {
return m, tea.Quit
}
return m, nil
}
}
func (m progressModel) View() tea.View {
var b strings.Builder
if box := renderLogBox(m.logs); box != "" {
b.WriteString(box)
b.WriteString("\n")
}
if m.showBar {
current := m.counter.Load()
b.WriteString("\n ")
b.WriteString(strconv.FormatUint(current, 10))
b.WriteString("/")
b.WriteString(strconv.FormatUint(m.maxValue, 10))
b.WriteString(" - ")
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
b.WriteString("\n ")
b.WriteString(m.progress.ViewAs(float64(current) / float64(m.maxValue)))
}
b.WriteString("\n\n ")
b.WriteString(helpLine(m.cancelling))
return tea.NewView(b.String())
}
func progressTickCmd() tea.Cmd {
return tea.Tick(time.Millisecond*250, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
var infiniteProgressStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00D4FF"))
type infiniteProgressModel struct {
spinner spinner.Model
startTime time.Time
counter *atomic.Uint64
logs []string
showBar bool
ctx context.Context //nolint:containedctx
quit bool
stop func()
cancelling bool
}
func (m infiniteProgressModel) Init() tea.Cmd {
return m.spinner.Tick
}
func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
m.cancelling = true
m.stop()
}
return m, nil
case logBatch:
for _, entry := range msg {
m.logs = append(m.logs[1:], renderRuntimeLog(entry))
}
if m.ctx.Err() != nil {
m.quit = true
return m, tea.Quit
}
return m, nil
default:
if m.ctx.Err() != nil {
m.quit = true
return m, tea.Quit
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
}
func (m infiniteProgressModel) View() tea.View {
var b strings.Builder
if box := renderLogBox(m.logs); box != "" {
b.WriteString(box)
b.WriteString("\n")
}
// Without a spinner, the view is just the log box (plus help until quit).
if !m.showBar {
if !m.quit {
b.WriteString("\n\n ")
b.WriteString(helpLine(m.cancelling))
}
return tea.NewView(b.String())
}
if m.quit {
b.WriteString("\n ")
b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
b.WriteString(" ")
b.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
b.WriteString(" ")
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
b.WriteString("\n\n")
} else {
b.WriteString("\n ")
b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
b.WriteString(" ")
b.WriteString(m.spinner.View())
b.WriteString(" ")
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
b.WriteString("\n\n ")
b.WriteString(helpLine(m.cancelling))
}
return tea.NewView(b.String())
}
func (s sarin) streamProgress(
ctx context.Context,
stopCtrl *StopController,
done chan<- struct{},
total uint64,
counter *atomic.Uint64,
logChannel <-chan runtimeLog,
showBar bool,
) {
var program *tea.Program
if total > 0 {
model := progressModel{
progress: progress.New(
progress.WithColors(lipgloss.Color("#151594"), lipgloss.Color("#00D4FF")),
progress.WithFillCharacters(progress.DefaultFullCharFullBlock, progress.DefaultEmptyCharBlock),
),
startTime: time.Now(),
logs: make([]string, logBoxLines),
counter: counter,
maxValue: total,
showBar: showBar,
ctx: ctx,
stop: stopCtrl.Stop,
}
program = tea.NewProgram(model)
} else {
model := infiniteProgressModel{
spinner: spinner.New(
spinner.WithSpinner(
spinner.Spinner{
Frames: []string{
"●∙∙∙∙",
"∙●∙∙∙",
"∙∙●∙∙",
"∙∙∙●∙",
"∙∙∙∙●",
"∙∙∙●∙",
"∙∙●∙∙",
"∙●∙∙∙",
},
FPS: time.Second / 8, //nolint:mnd
},
),
spinner.WithStyle(infiniteProgressStyle),
),
startTime: time.Now(),
counter: counter,
logs: make([]string, logBoxLines),
showBar: showBar,
ctx: ctx,
stop: stopCtrl.Stop,
quit: false,
}
program = tea.NewProgram(model)
}
stopCtrl.AttachProgram(program)
defer stopCtrl.AttachProgram(nil)
// Bubble Tea repaints once per message, so forwarding every log made the run
// wait on the terminal. Send only what the box shows, on a fixed cadence.
go func() {
ticker := time.NewTicker(logBatchInterval)
defer ticker.Stop()
pending := make(logBatch, 0, logBoxLines)
flush := func() {
if len(pending) == 0 {
return
}
program.Send(slices.Clone(pending))
pending = pending[:0]
}
for {
select {
case entry, ok := <-logChannel:
if !ok {
flush()
return
}
if len(pending) == logBoxLines {
pending = append(pending[:0], pending[1:]...)
}
pending = append(pending, entry)
case <-ticker.C:
flush()
}
}
}()
if _, err := program.Run(); err != nil {
panic(err)
}
done <- struct{}{}
}
-283
View File
@@ -1,283 +0,0 @@
package sarin
import (
"strconv"
"sync/atomic"
"time"
"github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script"
)
const dryRunResponseKey = "dry-run"
// statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599.
var statusCodeStrings = func() [500]string {
var codes [500]string
for i := range codes {
codes[i] = strconv.Itoa(i + 100)
}
return codes
}()
// statusCodeToString returns a string representation of the HTTP status code.
// Uses a pre-computed table for codes 100-599, falls back to strconv.Itoa for others.
func statusCodeToString(code int) string {
if i := code - 100; i >= 0 && i < len(statusCodeStrings) {
return statusCodeStrings[i]
}
return strconv.Itoa(code)
}
func (s sarin) Worker(
jobs <-chan struct{},
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
// Create script transformer for this worker (engines are not thread-safe)
// Scripts are pre-validated in NewSarin, so this should not fail
var scriptTransformer *script.Transformer
if !s.scriptChain.IsEmpty() {
var err error
scriptTransformer, err = s.scriptChain.NewTransformer()
if err != nil {
panic(err)
}
defer scriptTransformer.Close()
}
requestGenerator, isDynamic := NewRequestGenerator(
s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer,
)
if s.dryRun {
switch {
case s.collectStats && isDynamic:
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
case s.collectStats && !isDynamic:
s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
case !s.collectStats && isDynamic:
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
default:
s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
}
} else {
switch {
case s.collectStats && isDynamic:
s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
case s.collectStats && !isDynamic:
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
case !s.collectStats && isDynamic:
s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
default:
s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
}
}
}
func (s sarin) workerStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
for range jobs {
req.Reset()
if err := requestGenerator(req); err != nil {
s.responses.Add(err.Error(), 0)
sendLog(runtimeLogLevelError, err.Error())
counter.Add(1)
continue
}
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
respDuration := time.Since(startTime)
if err != nil {
s.responses.Add(err.Error(), respDuration)
} else {
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
sendRespLog(respDuration, resp)
}
counter.Add(1)
}
}
func (s sarin) workerStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors
for range jobs {
s.responses.Add(err.Error(), 0)
sendLog(runtimeLogLevelError, err.Error())
counter.Add(1)
}
return
}
for range jobs {
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
respDuration := time.Since(startTime)
if err != nil {
s.responses.Add(err.Error(), respDuration)
} else {
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
sendRespLog(respDuration, resp)
}
counter.Add(1)
}
}
func (s sarin) workerNoStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
for range jobs {
req.Reset()
if err := requestGenerator(req); err != nil {
sendLog(runtimeLogLevelError, err.Error())
counter.Add(1)
continue
}
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
if err == nil {
sendRespLog(time.Since(startTime), resp)
}
counter.Add(1)
}
}
func (s sarin) workerNoStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
if err := requestGenerator(req); err != nil {
sendLog(runtimeLogLevelError, err.Error())
// Static request generation failed - just count the jobs without sending
for range jobs {
counter.Add(1)
}
return
}
for range jobs {
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
if err == nil {
sendRespLog(time.Since(startTime), resp)
}
counter.Add(1)
}
}
func (s sarin) workerDryRunStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
) {
for range jobs {
req.Reset()
startTime := time.Now()
if err := requestGenerator(req); err != nil {
s.responses.Add(err.Error(), time.Since(startTime))
sendLog(runtimeLogLevelError, err.Error())
counter.Add(1)
continue
}
s.responses.Add(dryRunResponseKey, time.Since(startTime))
counter.Add(1)
}
}
func (s sarin) workerDryRunStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
) {
if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors
for range jobs {
s.responses.Add(err.Error(), 0)
sendLog(runtimeLogLevelError, err.Error())
counter.Add(1)
}
return
}
for range jobs {
s.responses.Add(dryRunResponseKey, 0)
counter.Add(1)
}
}
func (s sarin) workerDryRunNoStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
) {
for range jobs {
req.Reset()
if err := requestGenerator(req); err != nil {
sendLog(runtimeLogLevelError, err.Error())
}
counter.Add(1)
}
}
func (s sarin) workerDryRunNoStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
) {
if err := requestGenerator(req); err != nil {
sendLog(runtimeLogLevelError, err.Error())
}
for range jobs {
counter.Add(1)
}
}
-121
View File
@@ -208,41 +208,8 @@ func (e URLParseError) Unwrap() error {
var ( var (
ErrFileCacheNotInitialized = errors.New("file cache is not initialized") ErrFileCacheNotInitialized = errors.New("file cache is not initialized")
ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)") ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)")
ErrJSONObjectOddArgs = errors.New("json_Object requires an even number of arguments (key-value pairs)")
) )
type JSONObjectKeyError struct {
Index int
Value any
}
func NewJSONObjectKeyError(index int, value any) JSONObjectKeyError {
return JSONObjectKeyError{Index: index, Value: value}
}
func (e JSONObjectKeyError) Error() string {
return fmt.Sprintf("json_Object key at index %d must be a string, got %T", e.Index, e.Value)
}
type JSONEncodeError struct {
Err error
}
func NewJSONEncodeError(err error) JSONEncodeError {
if err == nil {
err = errNoError
}
return JSONEncodeError{Err: err}
}
func (e JSONEncodeError) Error() string {
return "json_Encode failed: " + e.Err.Error()
}
func (e JSONEncodeError) Unwrap() error {
return e.Err
}
type TemplateParseError struct { type TemplateParseError struct {
Err error Err error
} }
@@ -475,91 +442,3 @@ func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError {
func (e ScriptUnknownEngineError) Error() string { func (e ScriptUnknownEngineError) Error() string {
return "unknown engine type: " + e.EngineType return "unknown engine type: " + e.EngineType
} }
// ======================================== Captcha ========================================
var (
ErrCaptchaKeyEmpty = errors.New("captcha API key cannot be empty")
// ErrCaptchaProcessing is an internal sentinel returned by the captcha solver polling
// code to signal that a task is not yet solved and polling should continue.
// It should never be surfaced to callers outside of the captcha poll loop.
ErrCaptchaProcessing = errors.New("captcha task still processing")
)
type CaptchaAPIError struct {
Endpoint string
Code string
Description string
}
func NewCaptchaAPIError(endpoint, code, description string) CaptchaAPIError {
return CaptchaAPIError{Endpoint: endpoint, Code: code, Description: description}
}
func (e CaptchaAPIError) Error() string {
return fmt.Sprintf("captcha %s error: %s (%s)", e.Endpoint, e.Code, e.Description)
}
type CaptchaRequestError struct {
Endpoint string
Err error
}
func NewCaptchaRequestError(endpoint string, err error) CaptchaRequestError {
if err == nil {
err = errNoError
}
return CaptchaRequestError{Endpoint: endpoint, Err: err}
}
func (e CaptchaRequestError) Error() string {
return fmt.Sprintf("captcha %s request failed: %v", e.Endpoint, e.Err)
}
func (e CaptchaRequestError) Unwrap() error {
return e.Err
}
type CaptchaDecodeError struct {
Endpoint string
Err error
}
func NewCaptchaDecodeError(endpoint string, err error) CaptchaDecodeError {
if err == nil {
err = errNoError
}
return CaptchaDecodeError{Endpoint: endpoint, Err: err}
}
func (e CaptchaDecodeError) Error() string {
return fmt.Sprintf("captcha %s decode failed: %v", e.Endpoint, e.Err)
}
func (e CaptchaDecodeError) Unwrap() error {
return e.Err
}
type CaptchaPollTimeoutError struct {
TaskID string
}
func NewCaptchaPollTimeoutError(taskID string) CaptchaPollTimeoutError {
return CaptchaPollTimeoutError{TaskID: taskID}
}
func (e CaptchaPollTimeoutError) Error() string {
return fmt.Sprintf("captcha solving timed out (taskId: %s)", e.TaskID)
}
type CaptchaSolutionKeyError struct {
Key string
}
func NewCaptchaSolutionKeyError(key string) CaptchaSolutionKeyError {
return CaptchaSolutionKeyError{Key: key}
}
func (e CaptchaSolutionKeyError) Error() string {
return fmt.Sprintf("captcha solution missing expected key %q", e.Key)
}
-43
View File
@@ -1,43 +0,0 @@
{ lib
, buildGoModule
, go_1_26
, rev ? "unknown"
, buildDate ? "unknown"
}:
(buildGoModule.override { go = go_1_26; }) (finalAttrs: {
pname = "sarin";
version = "1.4.2"; # bump per release
src = lib.cleanSource ../.;
vendorHash = "sha256-Yn1d2NbPYhlXzoJl4QbmZ/K7/UdwheWLj2mLyegRPvM=";
subPackages = [ "cmd/cli" ];
env.CGO_ENABLED = 0; # fully static binary
ldflags = [
"-s"
"-w"
"-X=go.aykhans.me/sarin/internal/version.Version=v${finalAttrs.version}"
"-X=go.aykhans.me/sarin/internal/version.GitCommit=${rev}"
"-X=go.aykhans.me/sarin/internal/version.BuildDate=${buildDate}"
];
preBuild = ''
ldflags+=("-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)'")
'';
postInstall = ''
mv $out/bin/cli $out/bin/sarin
'';
meta = {
description = "High-performance HTTP load testing tool built with Go and fasthttp";
homepage = "https://github.com/aykhans/sarin";
license = lib.licenses.mit;
mainProgram = "sarin";
maintainers = [ ];
};
})