28 Commits

Author SHA1 Message Date
a7b5f73069 Add benchmark suite comparing sarin, wrk, and bombardier 2026-03-22 22:30:47 +04:00
304fb160f8 Merge pull request #181 from aykhans/perf/reduce-request-memory
perf: reduce memory allocations in request generator
2026-03-22 22:28:32 +04:00
44c35e6681 perf: reduce memory allocations in request generator 2026-03-22 22:26:03 +04:00
9215fd8767 Merge pull request #179 from aykhans/dependabot/go_modules/golang.org/x/net-0.52.0
Bump golang.org/x/net from 0.51.0 to 0.52.0
2026-03-13 11:27:39 +04:00
dependabot[bot]
8879a59159 Bump golang.org/x/net from 0.51.0 to 0.52.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.51.0 to 0.52.0.
- [Commits](https://github.com/golang/net/compare/v0.51.0...v0.52.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 00:14:29 +00:00
705f6263fe Merge pull request #177 from aykhans/chore/bump-go-lint-versions-fix-typos
chore: bump Go to 1.26.1 and golangci-lint to v2.11.2; fix typos and lint nolints
2026-03-10 02:37:14 +04:00
9c5b998cda chore: remove Coinbase funding link from FUNDING.yml 2026-03-10 02:36:35 +04:00
026d05f1bf chore: bump Go to 1.26.1 and golangci-lint to v2.11.2; fix typos and lint nolints 2026-03-10 02:32:40 +04:00
844f139a10 Merge pull request #176 from aykhans/dependabot/go_modules/github.com/brianvoe/gofakeit/v7-7.14.1
Bump github.com/brianvoe/gofakeit/v7 from 7.14.0 to 7.14.1
2026-03-04 15:17:23 +04:00
dependabot[bot]
d767ac6f37 Bump github.com/brianvoe/gofakeit/v7 from 7.14.0 to 7.14.1
Bumps [github.com/brianvoe/gofakeit/v7](https://github.com/brianvoe/gofakeit) from 7.14.0 to 7.14.1.
- [Release notes](https://github.com/brianvoe/gofakeit/releases)
- [Commits](https://github.com/brianvoe/gofakeit/compare/v7.14.0...v7.14.1)

---
updated-dependencies:
- dependency-name: github.com/brianvoe/gofakeit/v7
  dependency-version: 7.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-04 00:15:25 +00:00
c299fda79d Merge pull request #175 from aykhans/feat/template-time-crypto-file-read
feat(template): add time/crypto helpers and file_Read function; document new template funcs
2026-02-26 21:53:40 +04:00
1f06b43b06 feat(template): add time/crypto helpers and file_Read function; document new template funcs 2026-02-26 21:50:36 +04:00
e031c8e7a5 Merge pull request #174 from aykhans/dependabot/go_modules/golang.org/x/net-0.51.0
Bump golang.org/x/net from 0.50.0 to 0.51.0
2026-02-26 12:50:22 +04:00
dependabot[bot]
de24f9d4a4 Bump golang.org/x/net from 0.50.0 to 0.51.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.50.0 to 0.51.0.
- [Commits](https://github.com/golang/net/compare/v0.50.0...v0.51.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-26 00:13:56 +00:00
d197e90103 Merge pull request #172 from aykhans/refactor/minor-improvements
Rename Append to Merge, replace strings_Join with slice_Join, and auto-detect non-TTY output
2026-02-15 16:55:40 +04:00
ae054bb3d6 update docs 2026-02-15 16:52:52 +04:00
61af28a3d3 Override Methods and Bodies instead of appending in Config.Merge 2026-02-15 16:27:36 +04:00
665be5d98a update docs 2026-02-15 03:05:03 +04:00
d346067e8a Rename Append to Merge, replace strings_Join with slice_Join, and auto-detect non-TTY output
- Rename Append to Merge on Cookies, Headers, and Params types and simplify Parse to use direct append
- Replace strings_Join(sep, ...values) with slice_Join(slice, sep) for consistency with slice-based template functions
- Auto-enable quiet mode when stdout is not a terminal
- Remove ErrCLINoArgs check to allow running sarin without arguments
- Add -it flag to Docker examples in docs
2026-02-15 02:56:32 +04:00
a3e20cd3d3 Merge pull request #171 from aykhans/dependabot/go_modules/github.com/charmbracelet/bubbles-1.0.0
Bump github.com/charmbracelet/bubbles from 0.21.1 to 1.0.0
2026-02-15 00:23:51 +04:00
6d921cf8e3 Merge pull request #170 from aykhans/dependabot/go_modules/golang.org/x/net-0.50.0
Bump golang.org/x/net from 0.49.0 to 0.50.0
2026-02-15 00:23:34 +04:00
dependabot[bot]
d8b0a1e6a3 Bump golang.org/x/net from 0.49.0 to 0.50.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.49.0 to 0.50.0.
- [Commits](https://github.com/golang/net/compare/v0.49.0...v0.50.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-14 20:20:57 +00:00
b21d97192c Merge pull request #169 from aykhans/feat/add-scripting
Add scripting
2026-02-15 00:19:58 +04:00
f0606a0f82 Add Lua and JavaScript scripting documentation 2026-02-14 03:21:52 +04:00
3be8ff218c Replace common.ToPtr with Go 1.26 builtin new and add go fix to CI 2026-02-13 18:56:10 +04:00
7cb49195f8 Bump Go to 1.26.0 and golangci-lint to v2.9.0
Drop GOEXPERIMENT=greenteagc flag as the green tea GC is now the default in Go 1.26.
2026-02-11 22:07:52 +04:00
dependabot[bot]
a154215495 Bump github.com/charmbracelet/bubbles from 0.21.1 to 1.0.0
Bumps [github.com/charmbracelet/bubbles](https://github.com/charmbracelet/bubbles) from 0.21.1 to 1.0.0.
- [Release notes](https://github.com/charmbracelet/bubbles/releases)
- [Commits](https://github.com/charmbracelet/bubbles/compare/v0.21.1...v1.0.0)

---
updated-dependencies:
- dependency-name: github.com/charmbracelet/bubbles
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-11 00:14:01 +00:00
c1584eb47b Remove unused functions and unexport internal sentinel error 2026-02-09 00:06:01 +04:00
30 changed files with 1085 additions and 255 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1,2 +1 @@
buy_me_a_coffee: aykhan
custom: https://commerce.coinbase.com/checkout/0f33d2fb-54a6-44f5-8783-006ebf70d1a0

View File

@@ -16,8 +16,12 @@ jobs:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: 1.25.7
go-version: 1.26.1
- name: go fix
run: |
go fix ./...
git diff --exit-code
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.8.0
version: v2.11.2

View File

@@ -35,7 +35,7 @@ jobs:
run: |
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "GO_VERSION=1.25.7" >> $GITHUB_ENV
echo "GO_VERSION=1.26.1" >> $GITHUB_ENV
- name: Set up Go
if: github.event_name == 'release' || inputs.build_binaries
@@ -53,12 +53,12 @@ jobs:
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)' \
-s -w"
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.exe ./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=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=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.exe ./cmd/cli/main.go
- name: Upload Release Assets
if: github.event_name == 'release' || inputs.build_binaries

View File

@@ -1,7 +1,7 @@
version: "2"
run:
go: "1.25"
go: "1.26"
concurrency: 12
linters:

View File

@@ -1,4 +1,4 @@
ARG GO_VERSION=1.25.7
ARG GO_VERSION=1.26.1
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
@@ -12,7 +12,7 @@ RUN --mount=type=bind,source=./go.mod,target=./go.mod \
go mod download
RUN --mount=type=bind,source=./,target=./ \
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build \
CGO_ENABLED=0 go build \
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=${VERSION}' \
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${GIT_COMMIT}' \
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \

View File

@@ -23,12 +23,13 @@
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused.
| ✅ Supported | ❌ Not Supported |
| ---------------------------------------------------------- | --------------------------------- |
| ---------------------------------------------------------- | ------------------------------- |
| High-performance with low memory footprint | Detailed response body analysis |
| Long-running duration/count based tests | Extensive response statistics |
| Dynamic requests via 320+ template functions | Web UI or complex TUI |
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | Scripting or multi-step scenarios |
| Flexible config (CLI, ENV, YAML) | HTTP/2, HTTP/3, WebSocket, gRPC |
| Request scripting with Lua and JavaScript | Distributed load testing |
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
| Flexible config (CLI, ENV, YAML) | Plugins / extensions ecosystem |
## Installation
@@ -56,12 +57,12 @@ Download the latest binaries from the [releases](https://github.com/aykhans/sari
### Building from Source
Requires [Go 1.25+](https://golang.org/dl/).
Requires [Go 1.26+](https://golang.org/dl/).
```sh
git clone https://github.com/aykhans/sarin.git && cd sarin
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build \
CGO_ENABLED=0 go build \
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=dev' \
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)' \
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \

View File

@@ -3,7 +3,7 @@ version: "3"
vars:
BIN_DIR: ./bin
GOLANGCI_LINT_VERSION: v2.8.0
GOLANGCI_LINT_VERSION: v2.11.2
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
tasks:
@@ -11,16 +11,22 @@ tasks:
desc: Run fmt, tidy, and lint.
cmds:
- task: fmt
- task: fix
- task: tidy
- task: lint
fmt:
desc: Run linters
desc: Run format
deps:
- install-golangci-lint
cmds:
- "{{.GOLANGCI}} fmt"
fix:
desc: Run go fix
cmds:
- go fix ./...
tidy:
desc: Run go mod tidy.
cmds:
@@ -52,7 +58,7 @@ tasks:
cmds:
- rm -f {{.OUTPUT}}
- >-
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build
CGO_ENABLED=0 go build
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=$(git describe --tags --always)'
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)'
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)'

42
benchmark/README.md Normal file
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
benchmark/run.sh Executable file
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
benchmark/server/server.c Normal file
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;
}

View File

@@ -14,7 +14,7 @@ import (
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is called in listenForTermination goroutine
go listenForTermination(func() { cancel() })
combinedConfig := config.ReadAllConfigs()

View File

@@ -36,6 +36,8 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
| [Lua](#lua) | `lua`<br>(string / []string) | `-lua`<br>(string / []string) | `SARIN_LUA`<br>(string) | - | Lua script(s) |
| [Js](#js) | `js`<br>(string / []string) | `-js`<br>(string / []string) | `SARIN_JS`<br>(string) | - | JavaScript script(s) |
---
@@ -103,6 +105,12 @@ SARIN_CONFIG_FILE=/config1.yaml sarin -f /config2.yaml -f https://example.com/co
If all four files define `url`, the value from `config3.yaml` wins.
**Merge behavior by field:**
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.) — higher priority overrides lower priority
- **Method and Body** — higher priority overrides lower priority (no merging)
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js** — accumulated across all config files
## URL
Target URL. Must be HTTP or HTTPS. The URL path supports [templating](templating.md), allowing dynamic path generation per request.
@@ -225,26 +233,33 @@ SARIN_BODY='{"product": "car"}'
## Params
URL query parameters. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
URL query parameters. Supports [templating](templating.md).
When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them.
**YAML example:**
```yaml
params:
key1: value1
key2: [value2, value3]
key2: [value2, value3] # cycles between value2 and value3
# OR
params:
- key1: value1
- key2: [value2, value3]
- key2: [value2, value3] # cycles between value2 and value3
# To send both values in every request, use separate entries:
params:
- key2: value2
- key2: value3 # both sent in every request
```
**CLI example:**
```sh
-param "key1=value1" -param "key2=value2" -param "key2=value3"
-param "key1=value1" -param "key2=value2" -param "key2=value3" # sends both value2 and value3
```
**ENV example:**
@@ -255,26 +270,33 @@ SARIN_PARAM="key1=value1"
## Headers
HTTP headers. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
HTTP headers. Supports [templating](templating.md).
When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them.
**YAML example:**
```yaml
headers:
key1: value1
key2: [value2, value3]
key2: [value2, value3] # cycles between value2 and value3
# OR
headers:
- key1: value1
- key2: [value2, value3]
- key2: [value2, value3] # cycles between value2 and value3
# To send both values in every request, use separate entries:
headers:
- key2: value2
- key2: value3 # both sent in every request
```
**CLI example:**
```sh
-header "key1: value1" -header "key2: value2" -header "key2: value3"
-header "key1: value1" -header "key2: value2" -header "key2: value3" # sends both value2 and value3
```
**ENV example:**
@@ -285,26 +307,33 @@ SARIN_HEADER="key1: value1"
## Cookies
HTTP cookies. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
HTTP cookies. Supports [templating](templating.md).
When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them.
**YAML example:**
```yaml
cookies:
key1: value1
key2: [value2, value3]
key2: [value2, value3] # cycles between value2 and value3
# OR
cookies:
- key1: value1
- key2: [value2, value3]
- key2: [value2, value3] # cycles between value2 and value3
# To send both values in every request, use separate entries:
cookies:
- key2: value2
- key2: value3 # both sent in every request
```
**CLI example:**
```sh
-cookie "key1=value1" -cookie "key2=value2" -cookie "key2=value3"
-cookie "key1=value1" -cookie "key2=value2" -cookie "key2=value3" # sends both value2 and value3
```
**ENV example:**
@@ -374,3 +403,133 @@ values: |
```sh
SARIN_VALUES="key1=value1"
```
## Lua
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.
**Script sources:**
Scripts can be provided as:
- **Inline script:** Direct script code
- **File reference:** `@/path/to/script.lua` or `@./relative/path.lua`
- **URL reference:** `@http://...` or `@https://...`
- **Escaped `@`:** `@@...` for inline scripts that start with a literal `@`
**The `transform` function:**
```lua
function transform(req)
-- req.method (string) - HTTP method (e.g. "GET", "POST")
-- req.path (string) - URL path (e.g. "/api/users")
-- req.body (string) - Request body
-- req.headers (table of string/arrays) - HTTP headers (e.g. {["X-Key"] = "value"})
-- req.params (table of string/arrays) - Query parameters (e.g. {["id"] = "123"})
-- req.cookies (table of string/arrays) - Cookies (e.g. {["session"] = "abc"})
req.headers["X-Custom"] = "my-value"
return req
end
```
> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`).
**YAML example:**
```yaml
lua: |
function transform(req)
req.headers["X-Custom"] = "my-value"
return req
end
# OR
lua:
- "@/path/to/script1.lua"
- "@/path/to/script2.lua"
```
**CLI example:**
```sh
-lua 'function transform(req) req.headers["X-Custom"] = "my-value" return req end'
# OR
-lua @/path/to/script1.lua -lua @/path/to/script2.lua
```
**ENV example:**
```sh
SARIN_LUA='function transform(req) req.headers["X-Custom"] = "my-value" return req end'
```
## Js
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.
**Script sources:**
Scripts can be provided as:
- **Inline script:** Direct script code
- **File reference:** `@/path/to/script.js` or `@./relative/path.js`
- **URL reference:** `@http://...` or `@https://...`
- **Escaped `@`:** `@@...` for inline scripts that start with a literal `@`
**The `transform` function:**
```javascript
function transform(req) {
// req.method (string) - HTTP method (e.g. "GET", "POST")
// req.path (string) - URL path (e.g. "/api/users")
// req.body (string) - Request body
// req.headers (object of string/arrays) - HTTP headers (e.g. {"X-Key": "value"})
// req.params (object of string/arrays) - Query parameters (e.g. {"id": "123"})
// req.cookies (object of string/arrays) - Cookies (e.g. {"session": "abc"})
req.headers["X-Custom"] = "my-value";
return req;
}
```
> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`).
**YAML example:**
```yaml
js: |
function transform(req) {
req.headers["X-Custom"] = "my-value";
return req;
}
# OR
js:
- "@/path/to/script1.js"
- "@/path/to/script2.js"
```
**CLI example:**
```sh
-js 'function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
# OR
-js @/path/to/script1.js -js @/path/to/script2.js
```
**ENV example:**
```sh
SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
```

View File

@@ -15,6 +15,7 @@ This guide provides practical examples for common Sarin use cases.
- [Docker Usage](#docker-usage)
- [Dry Run Mode](#dry-run-mode)
- [Show Configuration](#show-configuration)
- [Scripting](#scripting)
---
@@ -133,20 +134,34 @@ headers:
</details>
**Random headers from multiple values:**
**Multiple values for the same header (all sent in every request):**
> **Note:** When multiple values are provided for the same header, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point. This ensures all values are used while maintaining some randomness.
> **Note:** When the same key appears as separate entries (in CLI or config file), all values are sent in every request.
```sh
sarin -U http://example.com -r 1000 -c 10 \
-H "X-Region: us-east" \
-H "X-Region: us-west" \
-H "X-Region: eu-central"
-H "X-Region: us-west"
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
headers:
- X-Region: us-east
- X-Region: us-west
```
</details>
**Cycling headers from multiple values (config file only):**
> **Note:** When multiple values are specified as an array on a single key, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point.
```yaml
url: http://example.com
requests: 1000
@@ -158,8 +173,6 @@ headers:
- eu-central
```
</details>
**Query parameters:**
```sh
@@ -186,7 +199,7 @@ params:
```sh
sarin -U http://example.com/users -r 1000 -c 10 \
-P "id={{ fakeit_IntRange 1 1000 }}" \
-P "id={{ fakeit_Number 1 1000 }}" \
-P "fields=name,email"
```
@@ -198,7 +211,7 @@ url: http://example.com/users
requests: 1000
concurrency: 10
params:
id: "{{ fakeit_IntRange 1 1000 }}"
id: "{{ fakeit_Number 1 1000 }}"
fields: "name,email"
```
@@ -823,19 +836,19 @@ quiet: true
**Basic Docker usage:**
```sh
docker run --rm aykhans/sarin -U http://example.com -r 1000 -c 10
docker run -it --rm aykhans/sarin -U http://example.com -r 1000 -c 10
```
**With local config file:**
```sh
docker run --rm -v $(pwd)/config.yaml:/config.yaml aykhans/sarin -f /config.yaml
docker run -it --rm -v $(pwd)/config.yaml:/config.yaml aykhans/sarin -f /config.yaml
```
**With remote config file:**
```sh
docker run --rm aykhans/sarin -f https://example.com/config.yaml
docker run -it --rm aykhans/sarin -f https://example.com/config.yaml
```
**Interactive mode with TTY:**
@@ -894,3 +907,124 @@ headers:
```
</details>
## Scripting
Transform requests using Lua or JavaScript scripts. Scripts run after template rendering, before the request is sent.
**Add a custom header with Lua:**
```sh
sarin -U http://example.com/api -r 1000 -c 10 \
-lua 'function transform(req) req.headers["X-Custom"] = "my-value" return req end'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api
requests: 1000
concurrency: 10
lua: |
function transform(req)
req.headers["X-Custom"] = "my-value"
return req
end
```
</details>
**Modify request body with JavaScript:**
```sh
sarin -U http://example.com/api/data -r 1000 -c 10 \
-M POST \
-H "Content-Type: application/json" \
-B '{"name": "test"}' \
-js 'function transform(req) { var body = JSON.parse(req.body); body.timestamp = Date.now(); req.body = JSON.stringify(body); return req; }'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/data
requests: 1000
concurrency: 10
method: POST
headers:
Content-Type: application/json
body: '{"name": "test"}'
js: |
function transform(req) {
var body = JSON.parse(req.body);
body.timestamp = Date.now();
req.body = JSON.stringify(body);
return req;
}
```
</details>
**Load script from a file:**
```sh
sarin -U http://example.com/api -r 1000 -c 10 \
-lua @./scripts/transform.lua
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api
requests: 1000
concurrency: 10
lua: "@./scripts/transform.lua"
```
</details>
**Load script from a URL:**
```sh
sarin -U http://example.com/api -r 1000 -c 10 \
-js @https://example.com/scripts/transform.js
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api
requests: 1000
concurrency: 10
js: "@https://example.com/scripts/transform.js"
```
</details>
**Chain multiple scripts (Lua runs first, then JavaScript):**
```sh
sarin -U http://example.com/api -r 1000 -c 10 \
-lua @./scripts/auth.lua \
-lua @./scripts/headers.lua \
-js @./scripts/body.js
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api
requests: 1000
concurrency: 10
lua:
- "@./scripts/auth.lua"
- "@./scripts/headers.lua"
js: "@./scripts/body.js"
```
</details>

View File

@@ -10,6 +10,8 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
- [General Functions](#general-functions)
- [String Functions](#string-functions)
- [Collection Functions](#collection-functions)
- [Time Functions](#time-functions)
- [Crypto Functions](#crypto-functions)
- [Body Functions](#body-functions)
- [File Functions](#file-functions)
- [Fake Data Functions](#fake-data-functions)
@@ -98,17 +100,35 @@ sarin -U http://example.com/users \
| `strings_Truncate(s string, n int)` | Truncate to `n` characters with ellipsis | `{{ strings_Truncate "hello world" 5 }}``hello...` |
| `strings_TrimPrefix(s string, prefix string)` | Remove prefix from string | `{{ strings_TrimPrefix "hello" "he" }}``llo` |
| `strings_TrimSuffix(s string, suffix string)` | Remove suffix from string | `{{ strings_TrimSuffix "hello" "lo" }}``hel` |
| `strings_Join(sep string, values ...string)` | Join strings with separator | `{{ strings_Join "-" "a" "b" "c" }}``a-b-c` |
### Collection Functions
| Function | Description | Example |
| ----------------------------- | --------------------------------------------- | -------------------------------------------- |
| ---------------------------------------- | --------------------------------------------- | -------------------------------------------------------- |
| `dict_Str(pairs ...string)` | Create string dictionary from key-value pairs | `{{ dict_Str "key1" "val1" "key2" "val2" }}` |
| `slice_Str(values ...string)` | Create string slice | `{{ slice_Str "a" "b" "c" }}` |
| `slice_Join(slice []string, sep string)` | Join string slice with separator | `{{ slice_Join (slice_Str "a" "b" "c") "-" }}``a-b-c` |
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` |
### Time Functions
| Function | Description | Example |
| ------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------- |
| `time_NowUnix` | Current Unix timestamp (seconds) | `{{ time_NowUnix }}``1735689600` |
| `time_NowUnixMilli` | Current Unix timestamp (milliseconds) | `{{ time_NowUnixMilli }}``1735689600123` |
| `time_NowRFC3339` | Current time in RFC3339 format | `{{ time_NowRFC3339 }}``"2026-02-26T21:00:00Z"` |
| `time_Format(layout, t)` | Format a `time.Time` value with a Go layout | `{{ time_Format "2006-01-02" (strings_ToDate "2024-05-10") }}``"2024-05-10"` |
### Crypto Functions
| Function | Description | Example |
| ------------------------------------ | ------------------------------------------ | -------------------------------------------- |
| `crypto_SHA256(s string)` | SHA-256 hash (hex-encoded) | `{{ crypto_SHA256 "hello" }}` |
| `crypto_MD5(s string)` | MD5 hash (hex-encoded) | `{{ crypto_MD5 "hello" }}` |
| `crypto_HMACSHA256(key, msg string)` | HMAC-SHA256 signature (hex-encoded) | `{{ crypto_HMACSHA256 "secret" "payload" }}` |
| `crypto_Base64URL(s string)` | Base64 URL-safe encoding (without padding) | `{{ crypto_Base64URL "hello world" }}` |
### Body Functions
| Function | Description | Example |
@@ -153,11 +173,18 @@ body: '{{ body_FormData "twitter" "@@username" }}'
| Function | Description | Example |
| ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `file_Read(source string)` | Read a file (local path or URL) and return raw content as string. Files are cached after first read. | `{{ file_Read "/path/to/file.txt" }}` |
| `file_Base64(source string)` | Read a file (local path or URL) and return its Base64 encoded content. Files are cached after first read. | `{{ file_Base64 "/path/to/file.pdf" }}` |
**`file_Base64` Details:**
**`file_Read` and `file_Base64` Details:**
```yaml
# Local file as plain text
body: '{{ file_Read "/path/to/template.json" }}'
# Remote text file
body: '{{ file_Read "https://example.com/payload.txt" }}'
# Local file as Base64 in JSON body
body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "document.pdf"}'
@@ -240,7 +267,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Address
| Function | Description | Example Output |
| --------------------------------------------------- | ---------------------------- | --------------------------------------------------- |
| --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- |
| `fakeit_City` | City name | `"Marcelside"` |
| `fakeit_Country` | Country name | `"United States of America"` |
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
@@ -256,7 +283,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_Latitude` | Random latitude | `-73.534056` |
| `fakeit_Longitude` | Random longitude | `-147.068112` |
| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}``22.921026` |
| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}``-8.170450` |
| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}``122.471830` |
### Game
@@ -344,8 +371,8 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Text
| Function | Description | Example |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
| `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------- |
| `fakeit_Sentence(wordCount ...int)` | Random sentence (optional word count) | `{{ fakeit_Sentence }}` or `{{ fakeit_Sentence 10 }}` |
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` |
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |

16
go.mod
View File

@@ -1,10 +1,10 @@
module go.aykhans.me/sarin
go 1.25.7
go 1.26.1
require (
github.com/brianvoe/gofakeit/v7 v7.14.0
github.com/charmbracelet/bubbles v0.21.1
github.com/brianvoe/gofakeit/v7 v7.14.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
@@ -15,7 +15,7 @@ require (
github.com/yuin/gopher-lua v1.1.1
go.aykhans.me/utils v1.0.7
go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/net v0.49.0
golang.org/x/net v0.52.0
)
require (
@@ -25,7 +25,7 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.5 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
@@ -53,7 +53,7 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
)

28
go.sum
View File

@@ -14,10 +14,10 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v
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/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=
github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow=
github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
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=
@@ -28,8 +28,8 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4=
github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
@@ -111,16 +111,16 @@ go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
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/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
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=

View File

@@ -10,7 +10,6 @@ import (
"go.aykhans.me/sarin/internal/types"
versionpkg "go.aykhans.me/sarin/internal/version"
"go.aykhans.me/utils/common"
)
const cliUsageText = `Usage:
@@ -72,7 +71,6 @@ func (arg *stringSliceArg) Set(value string) error {
// Parse parses command-line arguments into a Config object.
// It can return the following errors:
// - types.ErrCLINoArgs
// - types.CLIUnexpectedArgsError
// - types.FieldParseErrors
func (parser ConfigCLIParser) Parse() (*Config, error) {
@@ -179,12 +177,6 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
panic(err)
}
// Check if no flags were set and no non-flag arguments were provided.
// This covers cases where `sarin` is run without any meaningful arguments.
if flagSet.NFlag() == 0 && len(flagSet.Args()) == 0 {
return nil, types.ErrCLINoArgs
}
// Check for any unexpected non-flag arguments remaining after parsing.
if args := flagSet.Args(); len(args) > 0 {
return nil, types.NewCLIUnexpectedArgsError(args)
@@ -202,23 +194,23 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
switch flagVar.Name {
// General config
case "show-config", "s":
config.ShowConfig = common.ToPtr(showConfig)
config.ShowConfig = new(showConfig)
case "config-file", "f":
for _, configFile := range configFiles {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
case "concurrency", "c":
config.Concurrency = common.ToPtr(concurrency)
config.Concurrency = new(concurrency)
case "requests", "r":
config.Requests = common.ToPtr(requestCount)
config.Requests = new(requestCount)
case "duration", "d":
config.Duration = common.ToPtr(duration)
config.Duration = new(duration)
case "quiet", "q":
config.Quiet = common.ToPtr(quiet)
config.Quiet = new(quiet)
case "output", "o":
config.Output = common.ToPtr(ConfigOutputType(output))
config.Output = new(ConfigOutputType(output))
case "dry-run", "z":
config.DryRun = common.ToPtr(dryRun)
config.DryRun = new(dryRun)
// Request config
case "url", "U":
@@ -251,9 +243,9 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
case "values", "V":
config.Values = append(config.Values, values...)
case "timeout", "T":
config.Timeout = common.ToPtr(timeout)
config.Timeout = new(timeout)
case "insecure", "I":
config.Insecure = common.ToPtr(insecure)
config.Insecure = new(insecure)
case "lua":
config.Lua = append(config.Lua, luaScripts...)
case "js":

View File

@@ -93,10 +93,6 @@ type Config struct {
Js []string `yaml:"js,omitempty"`
}
func NewConfig() *Config {
return &Config{}
}
func (config Config) MarshalYAML() (any, error) {
const randomValueComment = "Cycles through all values, with a new random start each round"
@@ -279,7 +275,7 @@ func (config Config) Print() bool {
func (config *Config) Merge(newConfig *Config) {
config.Files = append(config.Files, newConfig.Files...)
if len(newConfig.Methods) > 0 {
config.Methods = append(config.Methods, newConfig.Methods...)
config.Methods = newConfig.Methods
}
if newConfig.URL != nil {
config.URL = newConfig.URL
@@ -321,7 +317,7 @@ func (config *Config) Merge(newConfig *Config) {
config.Cookies = append(config.Cookies, newConfig.Cookies...)
}
if len(newConfig.Bodies) != 0 {
config.Bodies = append(config.Bodies, newConfig.Bodies...)
config.Bodies = newConfig.Bodies
}
if len(newConfig.Proxies) != 0 {
config.Proxies.Append(newConfig.Proxies...)
@@ -360,26 +356,26 @@ func (config *Config) SetDefaults() {
config.Timeout = &Defaults.RequestTimeout
}
if config.Concurrency == nil {
config.Concurrency = common.ToPtr(Defaults.Concurrency)
config.Concurrency = new(Defaults.Concurrency)
}
if config.ShowConfig == nil {
config.ShowConfig = common.ToPtr(Defaults.ShowConfig)
config.ShowConfig = new(Defaults.ShowConfig)
}
if config.Quiet == nil {
config.Quiet = common.ToPtr(Defaults.Quiet)
config.Quiet = new(Defaults.Quiet)
}
if config.Insecure == nil {
config.Insecure = common.ToPtr(Defaults.Insecure)
config.Insecure = new(Defaults.Insecure)
}
if config.DryRun == nil {
config.DryRun = common.ToPtr(Defaults.DryRun)
config.DryRun = new(Defaults.DryRun)
}
if !config.Headers.Has("User-Agent") {
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
}
if config.Output == nil {
config.Output = common.ToPtr(Defaults.Output)
config.Output = new(Defaults.Output)
}
}
@@ -422,7 +418,7 @@ func (config Config) Validate() error {
validationErrors = append(validationErrors, types.NewFieldValidationError("Duration", "0", errors.New("duration must be greater than 0")))
}
if *config.Timeout < 1 {
if config.Timeout == nil || *config.Timeout < 1 {
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
}
@@ -540,12 +536,6 @@ func ReadAllConfigs() *Config {
cliParser := NewConfigCLIParser(os.Args)
cliConf, err := cliParser.Parse()
_ = utilsErr.MustHandle(err,
utilsErr.OnSentinel(types.ErrCLINoArgs, func(err error) error {
cliParser.PrintHelp()
fmt.Fprintln(os.Stderr, StyleYellow.Render("\nNo arguments provided."))
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
cliParser.PrintHelp()
fmt.Fprintln(os.Stderr,

View File

@@ -7,7 +7,6 @@ import (
"time"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/utils/common"
utilsParse "go.aykhans.me/utils/parser"
)
@@ -67,7 +66,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
}
if output := parser.getEnv("OUTPUT"); output != "" {
config.Output = common.ToPtr(ConfigOutputType(output))
config.Output = new(ConfigOutputType(output))
}
if insecure := parser.getEnv("INSECURE"); insecure != "" {
@@ -158,7 +157,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
types.NewFieldParseError(
parser.getFullEnvName("DURATION"),
duration,
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
),
)
} else {
@@ -174,7 +173,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
types.NewFieldParseError(
parser.getFullEnvName("TIMEOUT"),
timeout,
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
),
)
} else {

View File

@@ -12,7 +12,6 @@ import (
"time"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/utils/common"
"go.yaml.in/yaml/v4"
)
@@ -241,7 +240,7 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
config.Quiet = parsedData.Quiet
if parsedData.Output != nil {
config.Output = common.ToPtr(ConfigOutputType(*parsedData.Output))
config.Output = new(ConfigOutputType(*parsedData.Output))
}
config.Insecure = parsedData.Insecure

View File

@@ -172,7 +172,6 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
return nil, types.NewProxyDialError(proxyStr, err)
}
// Cap DNS resolution to half the timeout to reserve time for dial
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
dnsCancel()
@@ -244,7 +243,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
}
// Upgrade to TLS
tlsConn := tls.Client(conn, &tls.Config{ //nolint:gosec
tlsConn := tls.Client(conn, &tls.Config{
ServerName: proxyURL.Hostname(),
})
if err := tlsConn.Handshake(); err != nil {

View File

@@ -8,7 +8,7 @@ import (
func NewDefaultRandSource() rand.Source {
now := time.Now().UnixNano()
return rand.NewPCG(
uint64(now), //nolint:gosec // G115: Safe conversion; UnixNano timestamp used as random seed, bit pattern is intentional
uint64(now>>32), //nolint:gosec // G115: Safe conversion; right-shifted timestamp for seed entropy, overflow is acceptable
uint64(now),
uint64(now>>32),
)
}

View File

@@ -43,19 +43,34 @@ func NewRequestGenerator(
randSource := NewDefaultRandSource()
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
localRand := rand.New(randSource)
templateFuncMap := NewDefaultTemplateFuncMap(randSource, fileCache)
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap)
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, templateFuncMap)
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, templateFuncMap)
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap)
// Funcs() is only called if a value actually contains template syntax.
// The root template is shared across all createTemplateFunc calls so Funcs() is called at most once.
var templateRoot *template.Template
lazyTemplateRoot := func() *template.Template {
if templateRoot == nil {
templateRoot = template.New("").Funcs(NewDefaultTemplateFuncMap(randSource, fileCache))
}
return templateRoot
}
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, lazyTemplateRoot)
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, lazyTemplateRoot)
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache)
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap)
var bodyTemplateRoot *template.Template
lazyBodyTemplateRoot := func() *template.Template {
if bodyTemplateRoot == nil {
bodyTemplateRoot = template.New("").Funcs(NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache))
}
return bodyTemplateRoot
}
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, lazyBodyTemplateRoot)
valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)
valuesGenerator := NewValuesGeneratorFunc(values, lazyTemplateRoot)
hasScripts := scriptTransformer != nil && !scriptTransformer.IsEmpty()
@@ -91,7 +106,7 @@ func NewRequestGenerator(
return err
}
bodyTemplateFuncMapData.ClearFormDataContenType()
bodyTemplateFuncMapData.ClearFormDataContentType()
if err = bodyGenerator(reqData, data); err != nil {
return err
}
@@ -99,8 +114,8 @@ func NewRequestGenerator(
if err = headersGenerator(reqData, data); err != nil {
return err
}
if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
reqData.Headers["Content-Type"] = append(reqData.Headers["Content-Type"], bodyTemplateFuncMapData.GetFormDataContenType())
if bodyTemplateFuncMapData.GetFormDataContentType() != "" {
reqData.Headers["Content-Type"] = append(reqData.Headers["Content-Type"], bodyTemplateFuncMapData.GetFormDataContentType())
}
if err = paramsGenerator(reqData, data); err != nil {
@@ -170,8 +185,8 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
}
}
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, templateFunctions)
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot)
var (
method string
@@ -188,8 +203,8 @@ func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunc
}, isDynamic
}
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, templateFunctions)
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, lazyRoot)
var (
body string
@@ -206,8 +221,8 @@ func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctio
}, isDynamic
}
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, params, templateFunctions)
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, params, lazyRoot)
var (
key, value string
@@ -231,8 +246,8 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateF
}, isDynamic
}
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, headers, templateFunctions)
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, headers, lazyRoot)
var (
key, value string
@@ -256,8 +271,8 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templa
}, isDynamic
}
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, templateFunctions)
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
var (
key, value string
@@ -281,11 +296,11 @@ func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templa
}, isDynamic
}
func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap) func() (valuesData, error) {
func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) {
generators := make([]func(_ any) (string, error), len(values))
for i, v := range values {
generators[i], _ = createTemplateFunc(v, templateFunctions)
generators[i], _ = createTemplateFunc(v, lazyRoot)
}
var (
@@ -313,8 +328,12 @@ func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap)
}
}
func createTemplateFunc(value string, templateFunctions template.FuncMap) (func(data any) (string, error), bool) {
tmpl, err := template.New("").Funcs(templateFunctions).Parse(value)
func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) {
if !strings.Contains(value, "{{") {
return func(_ any) (string, error) { return value, nil }, false
}
tmpl, err := lazyRoot().New("").Parse(value)
if err == nil && hasTemplateActions(tmpl) {
var err error
return func(data any) (string, error) {
@@ -340,7 +359,7 @@ type keyValueItem interface {
func buildKeyValueGenerators[T keyValueItem](
localRand *rand.Rand,
items []T,
templateFunctions template.FuncMap,
lazyRoot func() *template.Template,
) ([]keyValueGenerator, bool) {
isDynamic := false
generators := make([]keyValueGenerator, len(items))
@@ -350,7 +369,7 @@ func buildKeyValueGenerators[T keyValueItem](
keyValue := types.KeyValue[string, []string](item)
// Generate key function
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, templateFunctions)
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot)
if keyIsDynamic {
isDynamic = true
}
@@ -358,7 +377,7 @@ func buildKeyValueGenerators[T keyValueItem](
// Generate value functions
valueFuncs := make([]func(data any) (string, error), len(keyValue.Value))
for j, v := range keyValue.Value {
valueFunc, valueIsDynamic := createTemplateFunc(v, templateFunctions)
valueFunc, valueIsDynamic := createTemplateFunc(v, lazyRoot)
if valueIsDynamic {
isDynamic = true
}
@@ -381,7 +400,7 @@ func buildKeyValueGenerators[T keyValueItem](
func buildStringSliceGenerator(
localRand *rand.Rand,
values []string,
templateFunctions template.FuncMap,
lazyRoot func() *template.Template,
) (func() func(data any) (string, error), bool) {
// Return a function that returns an empty string generator if values is empty
if len(values) == 0 {
@@ -393,7 +412,7 @@ func buildStringSliceGenerator(
valueFuncs := make([]func(data any) (string, error), len(values))
for i, value := range values {
valueFunc, valueIsDynamic := createTemplateFunc(value, templateFunctions)
valueFunc, valueIsDynamic := createTemplateFunc(value, lazyRoot)
if valueIsDynamic {
isDynamic = true
}

View File

@@ -3,6 +3,7 @@ package sarin
import (
"context"
"net/url"
"os"
"strconv"
"strings"
"sync"
@@ -13,6 +14,7 @@ import (
"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"
@@ -155,6 +157,10 @@ func (q sarin) Start(ctx context.Context) {
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 {
@@ -478,13 +484,11 @@ func newHostClients(
proxiesRaw[i] = url.URL(proxy)
}
maxConns := max(fasthttp.DefaultMaxConnsPerHost, workers)
maxConns = ((maxConns * 50 / 100) + maxConns)
return NewHostClients(
ctx,
timeout,
proxiesRaw,
maxConns,
workers,
requestURL,
skipCertVerify,
)

View File

@@ -2,7 +2,11 @@ package sarin
import (
"bytes"
"crypto/hmac"
"crypto/md5" // #nosec G501 -- exposed intentionally as a template utility helper
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"math/rand/v2"
"mime/multipart"
"strings"
@@ -62,10 +66,6 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
},
"strings_TrimPrefix": strings.TrimPrefix,
"strings_TrimSuffix": strings.TrimSuffix,
"strings_Join": func(sep string, values ...string) string {
return strings.Join(values, sep)
},
// Dict
"dict_Str": func(values ...string) map[string]string {
dict := make(map[string]string)
@@ -83,8 +83,49 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"slice_Str": func(values ...string) []string { return values },
"slice_Int": func(values ...int) []int { return values },
"slice_Uint": func(values ...uint) []uint { return values },
"slice_Join": strings.Join,
// Time
"time_NowUnix": func() int64 { return time.Now().Unix() },
"time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() },
"time_NowRFC3339": func() string { return time.Now().Format(time.RFC3339) },
"time_Format": func(layout string, t time.Time) string {
return t.Format(layout)
},
// Crypto
"crypto_SHA256": func(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
},
"crypto_MD5": func(s string) string {
sum := md5.Sum([]byte(s)) // #nosec G401 -- MD5 is intentionally provided as a non-security template helper
return hex.EncodeToString(sum[:])
},
"crypto_HMACSHA256": func(key string, msg string) string {
mac := hmac.New(sha256.New, []byte(key))
_, _ = mac.Write([]byte(msg))
return hex.EncodeToString(mac.Sum(nil))
},
"crypto_Base64URL": func(s string) string {
return base64.RawURLEncoding.EncodeToString([]byte(s))
},
// File
// file_Read reads a file (local or remote URL) and returns its content as a string.
// Usage: {{ file_Read "/path/to/file.txt" }}
// {{ file_Read "https://example.com/data.txt" }}
"file_Read": func(source string) (string, error) {
if fileCache == nil {
return "", types.ErrFileCacheNotInitialized
}
cached, err := fileCache.GetOrLoad(source)
if err != nil {
return "", err
}
return string(cached.Content), nil
},
// file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content.
// Usage: {{ file_Base64 "/path/to/file.pdf" }}
// {{ file_Base64 "https://example.com/image.png" }}
@@ -245,7 +286,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_AdverbFrequencyDefinite": fakeit.AdverbFrequencyDefinite,
"fakeit_AdverbFrequencyIndefinite": fakeit.AdverbFrequencyIndefinite,
// Propositions
// Prepositions
"fakeit_Preposition": fakeit.Preposition,
"fakeit_PrepositionSimple": fakeit.PrepositionSimple,
"fakeit_PrepositionDouble": fakeit.PrepositionDouble,
@@ -548,15 +589,15 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
}
type BodyTemplateFuncMapData struct {
formDataContenType string
formDataContentType string
}
func (data BodyTemplateFuncMapData) GetFormDataContenType() string {
return data.formDataContenType
func (data BodyTemplateFuncMapData) GetFormDataContentType() string {
return data.formDataContentType
}
func (data *BodyTemplateFuncMapData) ClearFormDataContenType() {
data.formDataContenType = ""
func (data *BodyTemplateFuncMapData) ClearFormDataContentType() {
data.formDataContentType = ""
}
func NewDefaultBodyTemplateFuncMap(
@@ -587,7 +628,7 @@ func NewDefaultBodyTemplateFuncMap(
var multipartData bytes.Buffer
writer := multipart.NewWriter(&multipartData)
data.formDataContenType = writer.FormDataContentType()
data.formDataContentType = writer.FormDataContentType()
for i := 0; i < len(pairs); i += 2 {
key := pairs[i]

View File

@@ -145,22 +145,6 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
return nil
}
// ValidateScripts validates multiple script sources.
// It can return the following errors:
// - types.ErrScriptEmpty
// - types.ErrScriptTransformMissing
// - types.ScriptLoadError
// - types.ScriptExecutionError
// - types.ScriptUnknownEngineError
func ValidateScripts(ctx context.Context, sources []string, engineType EngineType) error {
for _, src := range sources {
if err := ValidateScript(ctx, src, engineType); err != nil {
return err
}
}
return nil
}
// fetchURL downloads content from an HTTP/HTTPS URL.
// It can return the following errors:
// - types.HTTPFetchError

View File

@@ -15,7 +15,7 @@ func (cookies Cookies) GetValue(key string) *[]string {
return nil
}
func (cookies *Cookies) Append(cookie ...Cookie) {
func (cookies *Cookies) Merge(cookie ...Cookie) {
for _, c := range cookie {
if item := cookies.GetValue(c.Key); item != nil {
*item = append(*item, c.Value...)
@@ -27,7 +27,7 @@ func (cookies *Cookies) Append(cookie ...Cookie) {
func (cookies *Cookies) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
cookies.Append(*ParseCookie(rawValue))
*cookies = append(*cookies, *ParseCookie(rawValue))
}
}

View File

@@ -9,7 +9,7 @@ import (
// ======================================== General ========================================
var (
ErrNoError = errors.New("no error (internal)")
errNoError = errors.New("no error (internal)")
)
type FieldParseError struct {
@@ -20,7 +20,7 @@ type FieldParseError struct {
func NewFieldParseError(field string, value string, err error) FieldParseError {
if err == nil {
err = ErrNoError
err = errNoError
}
return FieldParseError{field, value, err}
}
@@ -68,7 +68,7 @@ type FieldValidationError struct {
func NewFieldValidationError(field string, value string, err error) FieldValidationError {
if err == nil {
err = ErrNoError
err = errNoError
}
return FieldValidationError{field, value, err}
}
@@ -114,7 +114,7 @@ type UnmarshalError struct {
func NewUnmarshalError(err error) UnmarshalError {
if err == nil {
err = ErrNoError
err = errNoError
}
return UnmarshalError{err}
}
@@ -136,7 +136,7 @@ type FileReadError struct {
func NewFileReadError(path string, err error) FileReadError {
if err == nil {
err = ErrNoError
err = errNoError
}
return FileReadError{path, err}
}
@@ -156,7 +156,7 @@ type HTTPFetchError struct {
func NewHTTPFetchError(url string, err error) HTTPFetchError {
if err == nil {
err = ErrNoError
err = errNoError
}
return HTTPFetchError{url, err}
}
@@ -190,7 +190,7 @@ type URLParseError struct {
func NewURLParseError(url string, err error) URLParseError {
if err == nil {
err = ErrNoError
err = errNoError
}
return URLParseError{url, err}
}
@@ -216,7 +216,7 @@ type TemplateParseError struct {
func NewTemplateParseError(err error) TemplateParseError {
if err == nil {
err = ErrNoError
err = errNoError
}
return TemplateParseError{err}
}
@@ -235,7 +235,7 @@ type TemplateRenderError struct {
func NewTemplateRenderError(err error) TemplateRenderError {
if err == nil {
err = ErrNoError
err = errNoError
}
return TemplateRenderError{err}
}
@@ -248,26 +248,8 @@ func (e TemplateRenderError) Unwrap() error {
return e.Err
}
// ======================================== YAML ========================================
type YAMLFormatError struct {
Detail string
}
func NewYAMLFormatError(detail string) YAMLFormatError {
return YAMLFormatError{detail}
}
func (e YAMLFormatError) Error() string {
return e.Detail
}
// ======================================== CLI ========================================
var (
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
)
type CLIUnexpectedArgsError struct {
Args []string
}
@@ -288,7 +270,7 @@ type ConfigFileReadError struct {
func NewConfigFileReadError(err error) ConfigFileReadError {
if err == nil {
err = ErrNoError
err = errNoError
}
return ConfigFileReadError{err}
}
@@ -321,7 +303,7 @@ type ProxyParseError struct {
func NewProxyParseError(err error) ProxyParseError {
if err == nil {
err = ErrNoError
err = errNoError
}
return ProxyParseError{err}
}
@@ -365,7 +347,7 @@ type ProxyDialError struct {
func NewProxyDialError(proxy string, err error) ProxyDialError {
if err == nil {
err = ErrNoError
err = errNoError
}
return ProxyDialError{proxy, err}
}
@@ -395,7 +377,7 @@ type ScriptLoadError struct {
func NewScriptLoadError(source string, err error) ScriptLoadError {
if err == nil {
err = ErrNoError
err = errNoError
}
return ScriptLoadError{source, err}
}
@@ -415,7 +397,7 @@ type ScriptExecutionError struct {
func NewScriptExecutionError(engineType string, err error) ScriptExecutionError {
if err == nil {
err = ErrNoError
err = errNoError
}
return ScriptExecutionError{engineType, err}
}
@@ -436,7 +418,7 @@ type ScriptChainError struct {
func NewScriptChainError(engineType string, index int, err error) ScriptChainError {
if err == nil {
err = ErrNoError
err = errNoError
}
return ScriptChainError{engineType, index, err}
}

View File

@@ -24,7 +24,7 @@ func (headers Headers) GetValue(key string) *[]string {
return nil
}
func (headers *Headers) Append(header ...Header) {
func (headers *Headers) Merge(header ...Header) {
for _, h := range header {
if item := headers.GetValue(h.Key); item != nil {
*item = append(*item, h.Value...)
@@ -36,7 +36,7 @@ func (headers *Headers) Append(header ...Header) {
func (headers *Headers) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
headers.Append(*ParseHeader(rawValue))
*headers = append(*headers, *ParseHeader(rawValue))
}
}

View File

@@ -15,7 +15,7 @@ func (params Params) GetValue(key string) *[]string {
return nil
}
func (params *Params) Append(param ...Param) {
func (params *Params) Merge(param ...Param) {
for _, p := range param {
if item := params.GetValue(p.Key); item != nil {
*item = append(*item, p.Value...)
@@ -27,7 +27,7 @@ func (params *Params) Append(param ...Param) {
func (params *Params) Parse(rawValues ...string) {
for _, rawValue := range rawValues {
params.Append(*ParseParam(rawValue))
*params = append(*params, *ParseParam(rawValue))
}
}