mirror of
https://github.com/aykhans/sarin.git
synced 2026-10-13 21:28:52 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a58ef60316 | ||
|
|
5b11ef2933
|
||
|
|
3e01ce47cc
|
||
|
|
95f55e1056
|
||
|
|
824f03452e
|
||
|
|
b7c589de53
|
||
|
|
23b3d7b1cc
|
||
|
|
ab3b458f15
|
||
|
|
51e8f2a57f
|
||
|
|
a1e4c15285
|
||
|
|
bea48eeab0
|
||
|
|
cd0fa453c2
|
||
|
|
847c4b39ce
|
||
|
|
9e773a4e6c
|
||
|
|
bcf2ea3446
|
||
|
|
e85a6d4613
|
||
|
|
4a14d57d15
|
||
|
|
633f548e7b
|
||
|
|
68de415645
|
||
|
|
fb8443fd25
|
||
|
|
e0f6d4aeba
|
||
|
|
17dfa16051
|
||
|
|
a327c4978b
|
||
|
|
9d6e4e6bf8
|
||
|
|
9c7b7c26a0
|
||
|
|
ef5907dc0c
|
||
|
|
1956eb1c44
|
||
|
|
f1d91e3d77
|
||
|
|
3d535d89e3 | ||
|
|
3a23b1b370 | ||
|
|
a37885a308 | ||
|
|
412a02fea3
|
||
|
|
8da05bb10c
|
||
|
|
c32949c8e0
|
||
|
|
a30c862936
|
||
|
|
5d91141f80
|
||
|
|
878ca6dfbd
|
||
|
|
9de1d5ac93 | ||
|
|
6fc17d1e10 | ||
|
|
4518895ab2 | ||
|
|
9b0e7c4304
|
||
|
|
d4c7dcc42c
|
||
|
|
00226b0eb7
|
||
|
|
a60287b78b
|
||
|
|
529420ee5a
|
||
|
|
8b437b487c
|
||
|
|
5ca9cda2b9
|
||
|
|
6b03ba9579
|
||
|
|
8b6e88bb74 | ||
|
|
163de9ffc9
|
||
|
|
969337f4ec
|
||
|
|
ce6b60520f
|
||
|
|
ae34ae037a
|
||
|
|
27c00581a9 | ||
|
|
db9a65f35f
|
||
|
|
082851d25e
|
||
|
|
62afa38370 | ||
|
|
373bc39f8e | ||
|
|
2b7c08519e | ||
|
|
f974b5d708 | ||
|
|
74ac84eb31 | ||
|
|
9cd3ec0539 | ||
|
|
42de488430 | ||
|
|
6e984f039c
|
||
|
|
73285b66b5 | ||
|
|
f003bd1061
|
||
|
|
07b2bb706e
|
||
|
|
67db8ac0e6
|
||
|
|
d99c76d175
|
||
|
|
623c1c4d97 | ||
|
|
f5eaf76fc4 | ||
|
|
7ec63a2a43 | ||
|
|
492c3f1449 | ||
|
|
d03d34576e
|
||
|
|
8beff615bb | ||
|
|
a85c82e751
|
||
|
|
ad5cfadaf2 | ||
|
|
8078b2c1fe | ||
|
|
3c4f449c97
|
||
|
|
deba048e3c | ||
|
|
77e8bc1eb8 | ||
|
|
a81d95ef6d
|
||
|
|
8195b92547 | ||
|
|
f42c4c70bf | ||
|
|
f3f25e3ba1
|
||
|
|
8121d75a2c
|
||
|
|
3867140704 | ||
|
|
740b6f6bbb
|
||
|
|
88180ae288
|
||
|
|
0314ea4f3e
|
||
|
|
9602595216
|
||
|
|
c195ca2ab2
|
||
|
|
e13eb9b8a8
|
||
|
|
d68c557dd3
|
||
|
|
d08b6dc222
|
||
|
|
8552aab3b1
|
||
|
|
79143beec9 | ||
|
|
bb1ed1c2ce | ||
|
|
a3434b096a | ||
|
|
e83e9346a3
|
||
|
|
01b5233167
|
||
|
|
e93b729145 | ||
|
|
372d228bb1
|
||
|
|
52a6e78e0c | ||
|
|
b22e3a0d25
|
||
|
|
4b52556b30
|
||
|
|
755545d838
|
||
|
|
dc2cd6cf63 | ||
|
|
838d65d83f | ||
|
|
0fe0587823 | ||
|
|
5b301c42a9
|
||
|
|
d35295c1d9
|
||
|
|
a13d9d11f7
|
||
|
|
3393f9e767 | ||
|
|
2144f8e67d
|
||
|
|
9d1a73dd43 | ||
|
|
f4d13d83aa
|
||
|
|
07e9287c39
|
||
|
|
80a8ba1d59
|
||
|
|
31af2d0d3c | ||
|
|
02b28dba2f | ||
|
|
d7bc0706ca | ||
|
|
6e5c6eb0fe | ||
|
|
a349ffd6dd | ||
|
|
606aec693c | ||
|
|
99e442691d | ||
|
|
11ddabc7d4 | ||
|
|
dd23ea73fd | ||
|
|
c4faac9127 | ||
|
|
7a75a4e096 | ||
|
|
ac6f1a1a65
|
||
|
|
db19bee457 | ||
|
|
89c72e12e2
|
||
|
|
1c36e6fa70 | ||
|
|
b0af20ddf5 | ||
|
|
d23f1a7685 | ||
|
|
4ba612908c
|
||
|
|
f36d52a05d | ||
|
|
ce1f585342 | ||
|
|
a4b1c7c89c | ||
|
|
bdbf3b0b91
|
||
|
|
26f72a53f1 | ||
|
|
b628a999f0 | ||
|
|
b2e6e200bd | ||
|
|
edf82ec7c1
|
||
|
|
f24aea819f | ||
|
|
e0db4df17a | ||
|
|
475dda98ff | ||
|
|
dcb0e3171f | ||
|
|
2eebac68c9 | ||
|
|
e62fd33f9c | ||
|
|
e9b9b8890c | ||
|
|
8577c771e4 | ||
|
|
c839b71c9e | ||
|
|
cea692cf1b | ||
|
|
88f5171132 | ||
|
|
16b0081d3e | ||
|
|
1bd58a02b7 | ||
|
|
006029aad1 | ||
|
|
0e0ef72778 | ||
|
|
8d10198f02 | ||
|
|
cf3c8f4cde | ||
|
|
65ef05f960 | ||
|
|
14280f4e43 | ||
|
|
c95b06b1ad | ||
|
|
88d6a0132e | ||
|
|
e33c549f6d | ||
|
|
4a4feb4570 |
No files matched your search
@@ -13,10 +13,10 @@ jobs:
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.26.1
|
||||
go-version-file: go.mod
|
||||
- name: go fix
|
||||
run: |
|
||||
go fix ./...
|
||||
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.11.2
|
||||
version: v2.14.0
|
||||
@@ -0,0 +1,172 @@
|
||||
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
|
||||
+131
-33
@@ -2,50 +2,101 @@ name: Build and Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag (e.g., v1.0.0)"
|
||||
required: true
|
||||
build_binaries:
|
||||
description: "Build and upload binaries"
|
||||
type: boolean
|
||||
default: true
|
||||
build_docker:
|
||||
description: "Build and push Docker image"
|
||||
type: boolean
|
||||
default: true
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
name: Verify Nix package
|
||||
uses: ./.github/workflows/nix.yaml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
# Gate: the tag must come from main, agree with the pre-release flag, and match
|
||||
# the hand-bumped version in nix/package.nix.
|
||||
version:
|
||||
name: Verify release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
# Full history so the tag can be located on main's first-parent line.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check the tag is on main
|
||||
run: |
|
||||
git fetch --no-tags origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
git rev-list --first-parent refs/remotes/origin/main > "$RUNNER_TEMP/first-parent.txt"
|
||||
if ! grep -qxF "$GITHUB_SHA" "$RUNNER_TEMP/first-parent.txt"; then
|
||||
echo "::error::$TAG ($GITHUB_SHA) is not on main; releases must be cut from main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check the tag matches the pre-release flag
|
||||
env:
|
||||
PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
run: |
|
||||
# The flag comes from the checkbox, not the tag name, so without this a
|
||||
# tag like v1.5.0-rc1 published as stable would move :latest.
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
if [ "$PRERELEASE" != "true" ]; then
|
||||
echo "::error::tag '$TAG' carries a pre-release suffix but the release is not marked as a pre-release"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check nix/package.nix matches the release tag
|
||||
run: |
|
||||
expected="${TAG#v}"
|
||||
expected="${expected%%-*}"
|
||||
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:
|
||||
name: Build binaries
|
||||
needs: [nix, version]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Set build metadata
|
||||
run: |
|
||||
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
|
||||
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||
echo "GO_VERSION=1.26.1" >> $GITHUB_ENV
|
||||
echo "GO_VERSION=$(sed -n 's/^go //p' go.mod)" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Go
|
||||
if: github.event_name == 'release' || inputs.build_binaries
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build binaries
|
||||
if: github.event_name == 'release' || inputs.build_binaries
|
||||
run: |
|
||||
LDFLAGS="-X 'go.aykhans.me/sarin/internal/version.Version=${{ env.VERSION }}' \
|
||||
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${{ env.GIT_COMMIT }}' \
|
||||
@@ -55,36 +106,33 @@ 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=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=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
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ inputs.tag || github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: ./sarin-*
|
||||
|
||||
- name: Set up QEMU
|
||||
if: github.event_name == 'release' || inputs.build_docker
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name == 'release' || inputs.build_docker
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name == 'release' || inputs.build_docker
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: github.event_name == 'release' || inputs.build_docker
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -93,6 +141,56 @@ jobs:
|
||||
VERSION=${{ env.VERSION }}
|
||||
GIT_COMMIT=${{ env.GIT_COMMIT }}
|
||||
GO_VERSION=${{ env.GO_VERSION }}
|
||||
# A pre-release must not take over the tag people get by default.
|
||||
tags: |
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/sarin:${{ env.VERSION }}
|
||||
${{ secrets.DOCKERHUB_USERNAME }}/sarin:latest
|
||||
${{ !github.event.release.prerelease && format('{0}/sarin:latest', secrets.DOCKERHUB_USERNAME) || '' }}
|
||||
|
||||
# Moving pointer at the newest stable release, so flake users can follow
|
||||
# releases instead of main. Fast-forward only.
|
||||
release-branch:
|
||||
name: Advance release branch
|
||||
needs: build
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
# Serialize: concurrent releases would otherwise race the push.
|
||||
concurrency:
|
||||
group: release-branch
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# The checks below compare against $GITHUB_SHA; check out exactly that.
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Point release at the tag
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
# Refresh: checkout fetched this, but as of a few seconds ago.
|
||||
git fetch --no-tags origin "+refs/heads/release:refs/remotes/origin/release" || true
|
||||
|
||||
if git rev-parse --verify --quiet refs/remotes/origin/release >/dev/null; then
|
||||
# At or behind the pointer: a re-run of this or an older release.
|
||||
if git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/release; then
|
||||
if [ "$(git rev-parse refs/remotes/origin/release)" = "$GITHUB_SHA" ]; then
|
||||
echo "::notice::release is already at $TAG"
|
||||
else
|
||||
echo "::warning::release is ahead of $TAG; leaving it alone"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
# The version job put this commit on main, so a pointer that is neither
|
||||
# behind nor ahead is off main history and can never fast-forward again.
|
||||
if ! git merge-base --is-ancestor refs/remotes/origin/release "$GITHUB_SHA"; then
|
||||
echo "::error::release ($(git rev-parse --short refs/remotes/origin/release)) has diverged from main and can no longer fast-forward to $TAG; reset it by hand"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
git push origin "$GITHUB_SHA:refs/heads/release"
|
||||
echo "::notice::release branch now points at $TAG"
|
||||
@@ -1 +1,5 @@
|
||||
bin/*
|
||||
|
||||
# Nix build artifacts
|
||||
result
|
||||
result-*
|
||||
+6
-1
@@ -1,7 +1,7 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
go: "1.26"
|
||||
go: "1.27"
|
||||
concurrency: 12
|
||||
|
||||
linters:
|
||||
@@ -81,6 +81,11 @@ linters:
|
||||
- staticcheck
|
||||
text: "SA5011"
|
||||
|
||||
- linters:
|
||||
- errcheck
|
||||
- gosec
|
||||
source: "lipgloss\\.Println\\("
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
ARG GO_VERSION=1.26.1
|
||||
ARG GO_VERSION=1.27.1
|
||||
|
||||
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
|
||||
|
||||
[](https://pkg.go.dev/go.aykhans.me/sarin)
|
||||
[](https://goreportcard.com/report/go.aykhans.me/sarin)
|
||||
[](https://hub.docker.com/r/aykhans/sarin)
|
||||
[](https://github.com/aykhans/sarin/releases)
|
||||
[](https://github.com/aykhans/sarin/actions/workflows/lint.yaml)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
</div>
|
||||
@@ -20,23 +22,23 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused.
|
||||
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity and 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 |
|
||||
| High-performance with low memory footprint | Web UI or complex TUI |
|
||||
| Dynamic requests via 370+ template functions | Detailed response body analysis |
|
||||
| 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 |
|
||||
| Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem |
|
||||
|
||||
## Installation
|
||||
|
||||
### Docker (Recommended)
|
||||
<details open>
|
||||
<summary><b>Docker</b></summary>
|
||||
|
||||
```sh
|
||||
docker pull aykhans/sarin:latest
|
||||
docker run --rm -it aykhans/sarin:latest --version
|
||||
```
|
||||
|
||||
With a local config file:
|
||||
@@ -51,13 +53,50 @@ With a remote config file:
|
||||
docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
|
||||
```
|
||||
|
||||
### Pre-built Binaries
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Nix</b></summary>
|
||||
|
||||
Run directly without installing (requires flakes enabled):
|
||||
|
||||
```sh
|
||||
nix run github:aykhans/sarin/release -- -U http://example.com -r 100 -c 10
|
||||
```
|
||||
|
||||
Install into your profile:
|
||||
|
||||
```sh
|
||||
nix profile add github:aykhans/sarin/release
|
||||
```
|
||||
|
||||
Or add it to your own flake via the overlay:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.sarin.url = "github:aykhans/sarin/release";
|
||||
|
||||
# In your outputs, apply the overlay to nixpkgs:
|
||||
# nixpkgs.overlays = [ inputs.sarin.overlays.default ];
|
||||
# then reference pkgs.sarin
|
||||
}
|
||||
```
|
||||
|
||||
The `release` branch tracks the newest stable release cut from `main`, so `nix flake update` moves between releases rather than following every commit; pre-releases are excluded. Pin an exact release with `github:aykhans/sarin/v1.4.2`, or track development with `github:aykhans/sarin` (the `main` branch).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Pre-built Binaries</b></summary>
|
||||
|
||||
Download the latest binaries from the [releases](https://github.com/aykhans/sarin/releases) page.
|
||||
|
||||
### Building from Source
|
||||
</details>
|
||||
|
||||
Requires [Go 1.26+](https://golang.org/dl/).
|
||||
<details>
|
||||
<summary><b>Building from Source</b></summary>
|
||||
|
||||
Requires the [Go version declared in `go.mod`](go.mod).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/aykhans/sarin.git && cd sarin
|
||||
@@ -71,6 +110,8 @@ CGO_ENABLED=0 go build \
|
||||
-o sarin ./cmd/cli/main.go
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Quick Start
|
||||
|
||||
Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request:
|
||||
@@ -105,7 +146,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c
|
||||
|
||||
## Templating
|
||||
|
||||
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.
|
||||
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 370+ built-in functions to generate dynamic data for each request.
|
||||
|
||||
**Example:**
|
||||
|
||||
|
||||
+10
-4
@@ -3,7 +3,9 @@ version: "3"
|
||||
|
||||
vars:
|
||||
BIN_DIR: ./bin
|
||||
GOLANGCI_LINT_VERSION: v2.11.2
|
||||
GO_VERSION:
|
||||
sh: sed -n 's/^go //p' go.mod
|
||||
GOLANGCI_LINT_VERSION: v2.14.0
|
||||
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
||||
|
||||
tasks:
|
||||
@@ -39,6 +41,11 @@ tasks:
|
||||
cmds:
|
||||
- "{{.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:
|
||||
desc: Run Go tests.
|
||||
cmds:
|
||||
@@ -74,7 +81,7 @@ tasks:
|
||||
- test -f {{.GOLANGCI}}
|
||||
cmds:
|
||||
- rm -f {{.GOLANGCI}}
|
||||
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b {{.BIN_DIR}} {{.GOLANGCI_LINT_VERSION}}
|
||||
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/main/install.sh | sh -s -- -b {{.BIN_DIR}} {{.GOLANGCI_LINT_VERSION}}
|
||||
- mv {{.BIN_DIR}}/golangci-lint {{.GOLANGCI}}
|
||||
|
||||
docker-build:
|
||||
@@ -82,7 +89,6 @@ tasks:
|
||||
vars:
|
||||
IMAGE_NAME: '{{.IMAGE_NAME | default "sarin"}}'
|
||||
TAG: '{{.TAG | default "latest"}}'
|
||||
GO_VERSION: '{{.GO_VERSION | default ""}}'
|
||||
VERSION:
|
||||
sh: git describe --tags --always
|
||||
GIT_COMMIT:
|
||||
@@ -90,7 +96,7 @@ tasks:
|
||||
cmds:
|
||||
- >-
|
||||
docker build
|
||||
{{if .GO_VERSION}}--build-arg GO_VERSION={{.GO_VERSION}}{{end}}
|
||||
--build-arg GO_VERSION={{.GO_VERSION}}
|
||||
--build-arg VERSION={{.VERSION}}
|
||||
--build-arg GIT_COMMIT={{.GIT_COMMIT}}
|
||||
-t {{.IMAGE_NAME}}:{{.TAG}}
|
||||
|
||||
+20
-16
@@ -7,6 +7,7 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"go.aykhans.me/sarin/internal/config"
|
||||
"go.aykhans.me/sarin/internal/sarin"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
@@ -14,8 +15,9 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is called in listenForTermination goroutine
|
||||
go listenForTermination(func() { cancel() })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
stopCtrl := sarin.NewStopController(cancel)
|
||||
go listenForTermination(stopCtrl.Stop)
|
||||
|
||||
combinedConfig := config.ReadAllConfigs()
|
||||
|
||||
@@ -31,13 +33,13 @@ func main() {
|
||||
utilsErr.OnType(func(err types.FieldValidationErrors) error {
|
||||
for _, fieldErr := range err.Errors {
|
||||
if fieldErr.Value == "" {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s': ", fieldErr.Field))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s' (%s): ", fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
os.Exit(1)
|
||||
@@ -49,31 +51,32 @@ func main() {
|
||||
ctx,
|
||||
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
|
||||
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
|
||||
*combinedConfig.Quiet, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers,
|
||||
*combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, *combinedConfig.MaxResponseBody,
|
||||
combinedConfig.Params, combinedConfig.Headers,
|
||||
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
|
||||
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
||||
*combinedConfig.DryRun,
|
||||
*combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile,
|
||||
combinedConfig.Lua, combinedConfig.Js,
|
||||
)
|
||||
_ = utilsErr.MustHandle(err,
|
||||
utilsErr.OnType(func(err types.ProxyDialError) error {
|
||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[PROXY] ")+err.Error())
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[PROXY] ")+err.Error()))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error {
|
||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error()))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
utilsErr.OnType(func(err types.ScriptLoadError) error {
|
||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error()))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
|
||||
srn.Start(ctx)
|
||||
srn.Start(stopCtrl)
|
||||
|
||||
switch *combinedConfig.Output {
|
||||
case config.ConfigOutputTypeNone:
|
||||
@@ -87,9 +90,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func listenForTermination(do func()) {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
func listenForTermination(stop func()) {
|
||||
sigChan := make(chan os.Signal, 4)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
do()
|
||||
for range sigChan {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
+216
-70
@@ -1,6 +1,6 @@
|
||||
# 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 equivalent: YAML 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:
|
||||
|
||||
@@ -14,30 +14,33 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
|
||||
|
||||
> **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values.
|
||||
|
||||
| Name | YAML | CLI | ENV | Default | Description |
|
||||
| --------------------------- | ----------------------------------- | -------------------------------------------- | -------------------------------- | ------- | ---------------------------- |
|
||||
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
|
||||
| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info |
|
||||
| [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration |
|
||||
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
|
||||
| [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) |
|
||||
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
|
||||
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
|
||||
| [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 |
|
||||
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
|
||||
| [Quiet](#quiet) | `quiet`<br>(boolean) | `-quiet` / `-q`<br>(boolean) | `SARIN_QUIET`<br>(boolean) | `false` | Hide progress bar and logs |
|
||||
| [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 |
|
||||
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
||||
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
|
||||
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
|
||||
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
|
||||
| [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) |
|
||||
| Name | YAML | CLI | ENV | Default | Description |
|
||||
| --------------------------------------- | ----------------------------------- | -------------------------------------------- | ----------------------------------- | ------- | ----------------------------- |
|
||||
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
|
||||
| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info |
|
||||
| [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration |
|
||||
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
|
||||
| [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) |
|
||||
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
|
||||
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
|
||||
| [Max Response Body](#max-response-body) | `maxResponseBody`<br>(size) | `-max-response-body` / `-S`<br>(size) | `SARIN_MAX_RESPONSE_BODY`<br>(size) | `10MiB` | Largest response body to read |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
|
||||
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
|
||||
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
|
||||
| [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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -107,9 +110,9 @@ 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
|
||||
- **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
|
||||
|
||||
@@ -131,7 +134,7 @@ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10
|
||||
|
||||
## Method
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
**YAML example:**
|
||||
|
||||
@@ -141,9 +144,9 @@ method: GET
|
||||
# OR
|
||||
|
||||
method:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -160,15 +163,29 @@ SARIN_METHOD=GET
|
||||
|
||||
## Timeout
|
||||
|
||||
Request timeout. Must be greater than 0.
|
||||
Request timeout. Must be greater than 0. Defaults to `10s`.
|
||||
|
||||
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
||||
|
||||
**Examples:** `5s`, `300ms`, `1m20s`
|
||||
|
||||
## Max Response Body
|
||||
|
||||
Largest response body sarin reads. Defaults to `10MiB`, and `0` removes the limit.
|
||||
|
||||
The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1GiB`, because one response can take a few times the limit in memory.
|
||||
|
||||
**Examples:** `1048576`, `10MiB`, `2MB`, `512KiB`
|
||||
|
||||
A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `response body exceeds the body limit` and is counted like any other error.
|
||||
|
||||
The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. It covers the requests scripts make as well.
|
||||
|
||||
A compressed response in a script is decoded first, so the limit covers the decoded size as well as the bytes that arrived. Decoding holds both at once, so a brotli or zstd body peaks at over ten times the limit. Ratios are unbounded, and 106 bytes of brotli can hold 64MiB, so with `0` a tiny download can still exhaust memory.
|
||||
|
||||
## Concurrency
|
||||
|
||||
Number of concurrent workers. Must be between 1 and 100,000,000.
|
||||
Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`.
|
||||
|
||||
## Requests
|
||||
|
||||
@@ -182,15 +199,34 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
||||
|
||||
**Examples:** `1m30s`, `25s`, `1h`
|
||||
|
||||
## Quiet
|
||||
## Log Level
|
||||
|
||||
Hide the progress bar and runtime logs.
|
||||
Runtime log levels to emit, comma-separated. Valid levels: `info`, `error`. Defaults to `error`.
|
||||
|
||||
- `error`: errors that occur while generating a request. A request that fails to send is counted in the results instead
|
||||
- `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 format for response statistics.
|
||||
|
||||
Valid formats: `table`, `json`, `yaml`, `none`
|
||||
Valid formats: `table` (default), `json`, `yaml`, `none`
|
||||
|
||||
Using `none` disables output and reduces memory usage since response statistics are not stored.
|
||||
|
||||
@@ -198,13 +234,17 @@ Using `none` disables output and reduces memory usage since response statistics
|
||||
|
||||
Generate requests without sending them. Useful for testing templates.
|
||||
|
||||
A script's own `http` calls are still sent, since a script needs them to build the request you are inspecting. Only the requests sarin generates are held back.
|
||||
|
||||
The duration in the results is how long generating each request took, templates and scripts included, so it is a different measurement from the response time a real run reports. A request with neither renders once before the run and reports `0s`.
|
||||
|
||||
## Insecure
|
||||
|
||||
Skip TLS certificate verification.
|
||||
|
||||
## Body
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
**YAML example:**
|
||||
|
||||
@@ -214,9 +254,9 @@ body: '{"product": "car"}'
|
||||
# OR
|
||||
|
||||
body:
|
||||
- '{"product": "car"}'
|
||||
- '{"product": "phone"}'
|
||||
- '{"product": "watch"}'
|
||||
- '{"product": "car"}'
|
||||
- '{"product": "phone"}'
|
||||
- '{"product": "watch"}'
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -241,19 +281,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
||||
|
||||
```yaml
|
||||
params:
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
|
||||
# OR
|
||||
|
||||
params:
|
||||
- key1: value1
|
||||
- key2: [value2, value3] # cycles between value2 and value3
|
||||
- key1: value1
|
||||
- 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
|
||||
- key2: value2
|
||||
- key2: value3 # both sent in every request
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -278,19 +318,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
||||
|
||||
```yaml
|
||||
headers:
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
|
||||
# OR
|
||||
|
||||
headers:
|
||||
- key1: value1
|
||||
- key2: [value2, value3] # cycles between value2 and value3
|
||||
- key1: value1
|
||||
- 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
|
||||
- key2: value2
|
||||
- key2: value3 # both sent in every request
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -315,19 +355,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
||||
|
||||
```yaml
|
||||
cookies:
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
key1: value1
|
||||
key2: [value2, value3] # cycles between value2 and value3
|
||||
|
||||
# OR
|
||||
|
||||
cookies:
|
||||
- key1: value1
|
||||
- key2: [value2, value3] # cycles between value2 and value3
|
||||
- key1: value1
|
||||
- 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
|
||||
- key2: value2
|
||||
- key2: value3 # both sent in every request
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -344,7 +384,7 @@ SARIN_COOKIE="key1=value1"
|
||||
|
||||
## Proxy
|
||||
|
||||
Proxy URL(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request.
|
||||
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.
|
||||
|
||||
Supported protocols: `http`, `https`, `socks5`, `socks5h`
|
||||
|
||||
@@ -356,9 +396,9 @@ proxy: http://proxy1.com
|
||||
# OR
|
||||
|
||||
proxy:
|
||||
- http://proxy1.com
|
||||
- socks5://proxy2.com
|
||||
- socks5h://proxy3.com
|
||||
- http://proxy1.com
|
||||
- socks5://proxy2.com
|
||||
- socks5h://proxy3.com
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -387,9 +427,9 @@ values: "key=value"
|
||||
# OR
|
||||
|
||||
values: |
|
||||
key1=value1
|
||||
key2=value2
|
||||
key3=value3
|
||||
key1=value1
|
||||
key2=value2
|
||||
key3=value3
|
||||
```
|
||||
|
||||
**CLI example:**
|
||||
@@ -408,7 +448,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.
|
||||
|
||||
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 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:**
|
||||
|
||||
@@ -435,7 +475,9 @@ function transform(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"}`).
|
||||
> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`). Numbers and booleans are converted to text, other values are skipped.
|
||||
|
||||
> **Note:** Lua scripts can send their own HTTP requests with `http` and work with JSON through the global `json` table. See [HTTP Requests in Scripts](#http-requests-in-scripts).
|
||||
|
||||
**YAML example:**
|
||||
|
||||
@@ -473,7 +515,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.
|
||||
|
||||
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 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:**
|
||||
|
||||
@@ -500,7 +542,9 @@ function transform(req) {
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`).
|
||||
> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`). Numbers and booleans are converted to text, other values are skipped.
|
||||
|
||||
> **Note:** JavaScript scripts can send their own HTTP requests with `http`. See [HTTP Requests in Scripts](#http-requests-in-scripts).
|
||||
|
||||
**YAML example:**
|
||||
|
||||
@@ -533,3 +577,105 @@ js:
|
||||
```sh
|
||||
SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
|
||||
```
|
||||
|
||||
## HTTP Requests in Scripts
|
||||
|
||||
Lua and JavaScript scripts can send their own HTTP requests and use the response in the main request, for example to fetch a fresh token or CSRF value from another endpoint.
|
||||
|
||||
**Function:**
|
||||
|
||||
```
|
||||
http(url, opts)
|
||||
```
|
||||
|
||||
`url` must be an absolute `http` or `https` URL. `opts` is optional.
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| -------------- | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `method` | string | `GET` | HTTP method, sent as given |
|
||||
| `headers` | table/object | - | Request headers, same shape as `req.headers` |
|
||||
| `params` | table/object | - | Query parameters, added to any already in the URL |
|
||||
| `cookies` | table/object | - | Cookies, sent as a single `Cookie` header |
|
||||
| `body` | string | `""` | Request body |
|
||||
| `timeout` | duration string (`"500ms"`) | `10s` | Request timeout (the `timeout` config does not apply), applied to each hop when redirects are followed |
|
||||
| `insecure` | boolean | `false` | Skip TLS verification (the `insecure` config does not apply) |
|
||||
| `maxRedirects` | number | `0` | How many redirects to follow, `0` follows none, maximum 100 |
|
||||
|
||||
Unknown options and values of the wrong type raise an error.
|
||||
|
||||
**Response:**
|
||||
|
||||
| Field / Method | Description |
|
||||
| -------------- | ------------------------------------------------------------------ |
|
||||
| `status` | Status code (number) |
|
||||
| `headers` | Response headers, each key holding an array of values |
|
||||
| `body` | Response body (string) |
|
||||
| `header(name)` | First value of a header, case-insensitive; `nil`/`null` if missing |
|
||||
| `cookie(name)` | Value of a cookie from `Set-Cookie`; `nil`/`null` if missing |
|
||||
| `json()` | JavaScript only: parses the body as JSON |
|
||||
| `bytes()` | JavaScript only: the body as a `Uint8Array` |
|
||||
|
||||
In Lua, call the helpers as `res:header("name")` or `res.header("name")`.
|
||||
|
||||
> **Note:** JavaScript strings hold UTF-16, so `body` replaces bytes that are not valid UTF-8 and `body.length` counts code units rather than bytes. Use `bytes()` for binary responses and for the real byte count. Lua strings keep the bytes as they arrived.
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- **Runs on every request:** the requests are sent each time `transform` runs, so every main request triggers them. Script globals persist across requests within a worker, so a script can cache a value itself (e.g. `if not token then token = login() end`).
|
||||
- **Same proxy:** when proxies are configured, a script's requests go through the same proxy as the main request they belong to.
|
||||
- **Status codes:** non-2xx responses are returned normally; check `status` in the script.
|
||||
- **Errors:** network failures and timeouts raise a script error. The main request is not sent and the error is counted in the results. Scripts can catch it with `pcall` (Lua) or `try`/`catch` (JavaScript).
|
||||
- **Response bodies:** the [Max Response Body](#max-response-body) config bounds these requests too, and a body above it raises an error the script can catch.
|
||||
- **Compressed responses:** sarin asks for no encoding, so this only applies when a script sends `Accept-Encoding` itself or the server compresses unasked. A `gzip`, `deflate`, `br` or `zstd` body is then decoded before the script sees it, and `Content-Encoding` and `Content-Length` are both dropped from `headers` because neither describes what the script receives. Anything else is left raw with its header intact: an empty body, `identity`, a `Content-Encoding` sarin does not recognise, and two or more codings at once. A body that fails to decode raises an error the script can catch, though `br` carries no checksum, so damage to a brotli body can go unnoticed.
|
||||
- **Only inside `transform`:** `http` works anywhere while `transform` runs, including in helper functions it calls. Calling it at the top level of a script raises an error, because that code also runs during config validation and when each worker starts.
|
||||
- **Dry run:** script requests are still sent in dry-run mode.
|
||||
- **Not measured:** script requests are not included in the results.
|
||||
|
||||
**Lua JSON:**
|
||||
|
||||
Lua has no built-in JSON support, so Lua scripts get a global `json` table:
|
||||
|
||||
- `json.decode(string)`: arrays become 1-indexed tables and `null` becomes `nil` (so `null` array items leave holes).
|
||||
- `json.encode(value)`: a table with keys exactly `1..n` becomes an array, any other table becomes an object, and an empty table becomes `{}`.
|
||||
|
||||
JavaScript scripts use the built-in `JSON` object.
|
||||
|
||||
**Lua example:**
|
||||
|
||||
```lua
|
||||
function transform(req)
|
||||
local res = http("https://api.example.com/login", {
|
||||
method = "POST",
|
||||
headers = { ["Content-Type"] = "application/json" },
|
||||
body = json.encode({ user = "test", password = "secret" }),
|
||||
timeout = "2s",
|
||||
})
|
||||
if res.status ~= 200 then
|
||||
error("login failed: " .. res.status)
|
||||
end
|
||||
|
||||
req.headers["Authorization"] = "Bearer " .. json.decode(res.body).token
|
||||
return req
|
||||
end
|
||||
```
|
||||
|
||||
**JavaScript example:**
|
||||
|
||||
```javascript
|
||||
function transform(req) {
|
||||
const res = http("https://api.example.com/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user: "test", password: "secret" }),
|
||||
timeout: "2s",
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
throw new Error("login failed: " + res.status);
|
||||
}
|
||||
|
||||
req.headers["Authorization"] = "Bearer " + res.json().token;
|
||||
return req;
|
||||
}
|
||||
```
|
||||
+199
-5
@@ -8,10 +8,12 @@ This guide provides practical examples for common Sarin use cases.
|
||||
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
|
||||
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
|
||||
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
|
||||
- [Solving Captchas](#solving-captchas)
|
||||
- [Request Bodies](#request-bodies)
|
||||
- [File Uploads](#file-uploads)
|
||||
- [Using Proxies](#using-proxies)
|
||||
- [Output Formats](#output-formats)
|
||||
- [Runtime Logging](#runtime-logging)
|
||||
- [Docker Usage](#docker-usage)
|
||||
- [Dry Run Mode](#dry-run-mode)
|
||||
- [Show Configuration](#show-configuration)
|
||||
@@ -371,7 +373,29 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
|
||||
|
||||
</details>
|
||||
|
||||
> For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**.
|
||||
> For the complete list of 370+ 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
|
||||
|
||||
@@ -696,7 +720,7 @@ proxy: socks5://proxy.example.com:1080
|
||||
|
||||
</details>
|
||||
|
||||
**Multiple proxies (load balanced):**
|
||||
**Multiple proxies (randomly cycled):**
|
||||
|
||||
```sh
|
||||
sarin -U http://example.com -r 1000 -c 10 \
|
||||
@@ -813,10 +837,10 @@ output: none
|
||||
|
||||
</details>
|
||||
|
||||
**Quiet mode (hide progress bar):**
|
||||
**Hide the progress bar:**
|
||||
|
||||
```sh
|
||||
sarin -U http://example.com -r 1000 -c 10 -q
|
||||
sarin -U http://example.com -r 1000 -c 10 -p none
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -826,7 +850,41 @@ sarin -U http://example.com -r 1000 -c 10 -q
|
||||
url: http://example.com
|
||||
requests: 1000
|
||||
concurrency: 10
|
||||
quiet: true
|
||||
progress: none
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Runtime Logging
|
||||
|
||||
`--log-level` selects which runtime logs Sarin emits (comma-separated `info` and `error`, default `error`). `error` covers errors raised while generating a request, `info` covers every completed response (status, duration, headers, body). A request that fails to send is counted in the results rather than logged. Logs appear in the progress log box on an interactive terminal, go to stderr when piped, or go to a file with `--log-file`.
|
||||
|
||||
**Log responses and errors:**
|
||||
|
||||
```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
|
||||
logLevel: info
|
||||
output: json
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -1028,3 +1086,139 @@ js: "@./scripts/body.js"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Fetch a value from another endpoint before each request:**
|
||||
|
||||
Scripts can send their own requests with `http`. Here each request first fetches a CSRF token and sends it in a header and cookie. See [HTTP Requests in Scripts](configuration.md#http-requests-in-scripts) for all options.
|
||||
|
||||
```sh
|
||||
sarin -U http://example.com/api/orders -r 1000 -c 10 \
|
||||
-M POST \
|
||||
-lua 'function transform(req)
|
||||
local res = http("http://example.com/csrf")
|
||||
req.headers["X-CSRF-Token"] = json.decode(res.body).token
|
||||
req.cookies["session"] = res:cookie("session")
|
||||
return req
|
||||
end'
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>YAML equivalent</summary>
|
||||
|
||||
```yaml
|
||||
url: http://example.com/api/orders
|
||||
requests: 1000
|
||||
concurrency: 10
|
||||
method: POST
|
||||
lua: |
|
||||
function transform(req)
|
||||
local res = http("http://example.com/csrf")
|
||||
req.headers["X-CSRF-Token"] = json.decode(res.body).token
|
||||
req.cookies["session"] = res:cookie("session")
|
||||
return req
|
||||
end
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Log in once per worker and reuse the token (JavaScript):**
|
||||
|
||||
```yaml
|
||||
url: http://example.com/api/profile
|
||||
requests: 1000
|
||||
concurrency: 10
|
||||
js: |
|
||||
let token;
|
||||
|
||||
function login() {
|
||||
const res = http("http://example.com/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user: "test", password: "secret" }),
|
||||
});
|
||||
if (res.status !== 200) throw new Error("login failed: " + res.status);
|
||||
return res.json().token;
|
||||
}
|
||||
|
||||
function transform(req) {
|
||||
if (!token) token = login();
|
||||
req.headers["Authorization"] = "Bearer " + token;
|
||||
return req;
|
||||
}
|
||||
```
|
||||
|
||||
**Chain two calls, then use the result in the URL path (Lua):**
|
||||
|
||||
```yaml
|
||||
url: http://example.com/api/placeholder
|
||||
requests: 500
|
||||
concurrency: 10
|
||||
lua: |
|
||||
function transform(req)
|
||||
local token = json.decode(http("http://example.com/login", {
|
||||
method = "POST",
|
||||
body = json.encode({ user = "test", password = "secret" }),
|
||||
headers = { ["Content-Type"] = "application/json" },
|
||||
}).body).token
|
||||
|
||||
local orders = json.decode(http("http://example.com/api/orders", {
|
||||
headers = { Authorization = "Bearer " .. token },
|
||||
}).body)
|
||||
|
||||
req.path = "/api/orders/" .. orders[1].id
|
||||
req.headers["Authorization"] = "Bearer " .. token
|
||||
return req
|
||||
end
|
||||
```
|
||||
|
||||
**Refresh a token only when it expires (JavaScript):**
|
||||
|
||||
```yaml
|
||||
url: http://example.com/api/profile
|
||||
requests: 5000
|
||||
concurrency: 20
|
||||
js: |
|
||||
let token;
|
||||
let expiresAt = 0;
|
||||
|
||||
function transform(req) {
|
||||
if (Date.now() >= expiresAt) {
|
||||
const res = http("http://example.com/login", { method: "POST", body: "{}" });
|
||||
token = res.json().token;
|
||||
expiresAt = Date.now() + 60000; // refresh once a minute per worker
|
||||
}
|
||||
req.headers["Authorization"] = "Bearer " + token;
|
||||
return req;
|
||||
}
|
||||
```
|
||||
|
||||
**Keep sending the main request even when the extra call fails:**
|
||||
|
||||
A failed `http` call raises an error, which skips the main request and records the failure in the results. Catch it to decide yourself:
|
||||
|
||||
```sh
|
||||
sarin -U http://example.com/api/items -r 1000 -c 10 \
|
||||
-lua 'function transform(req)
|
||||
local ok, res = pcall(http, "http://example.com/api/flags")
|
||||
if ok and res.status == 200 then
|
||||
req.headers["X-Flags"] = res.body
|
||||
else
|
||||
req.headers["X-Flags"] = "default"
|
||||
end
|
||||
return req
|
||||
end'
|
||||
```
|
||||
|
||||
The JavaScript equivalent:
|
||||
|
||||
```sh
|
||||
sarin -U http://example.com/api/items -r 1000 -c 10 \
|
||||
-js 'function transform(req) {
|
||||
try {
|
||||
req.headers["X-Flags"] = http("http://example.com/api/flags").body;
|
||||
} catch (e) {
|
||||
req.headers["X-Flags"] = "default";
|
||||
}
|
||||
return req;
|
||||
}'
|
||||
```
|
||||
+311
-149
@@ -2,7 +2,9 @@
|
||||
|
||||
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values.
|
||||
|
||||
> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates.
|
||||
> **Note:** Templating in URL host and scheme is not supported. Only the path and query string of the URL can contain templates.
|
||||
|
||||
> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take. Downloads have their own limits: 30 seconds and 64MiB per file.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -10,10 +12,15 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
|
||||
- [General Functions](#general-functions)
|
||||
- [String Functions](#string-functions)
|
||||
- [Collection Functions](#collection-functions)
|
||||
- [JSON Functions](#json-functions)
|
||||
- [Time Functions](#time-functions)
|
||||
- [Crypto Functions](#crypto-functions)
|
||||
- [Body Functions](#body-functions)
|
||||
- [File Functions](#file-functions)
|
||||
- [Captcha Functions](#captcha-functions)
|
||||
- [2Captcha](#2captcha)
|
||||
- [Anti-Captcha](#anti-captcha)
|
||||
- [CapSolver](#capsolver)
|
||||
- [Fake Data Functions](#fake-data-functions)
|
||||
- [File](#file)
|
||||
- [ID](#id)
|
||||
@@ -22,6 +29,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
|
||||
- [Generate](#generate)
|
||||
- [Auth](#auth)
|
||||
- [Address](#address)
|
||||
- [Airline](#airline)
|
||||
- [Game](#game)
|
||||
- [Beer](#beer)
|
||||
- [Car](#car)
|
||||
@@ -111,13 +119,40 @@ sarin -U http://example.com/users \
|
||||
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 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
|
||||
|
||||
| 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_NowRFC3339` | Current time in RFC3339 format | `{{ time_NowRFC3339 }}` → `"2026-02-26T21:00:00+04:00"` (local time) |
|
||||
| `time_Format(layout, t)` | Format a `time.Time` value with a Go layout | `{{ time_Format "2006-01-02" (strings_ToDate "2024-05-10") }}` → `"2024-05-10"` |
|
||||
|
||||
### Crypto Functions
|
||||
@@ -131,6 +166,8 @@ sarin -U http://example.com/users \
|
||||
|
||||
### Body Functions
|
||||
|
||||
> **Note:** These are available in `body` only, not in the URL, headers, params, cookies or values.
|
||||
|
||||
| Function | Description | Example |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `body_FormData(pairs ...string)` | Create multipart form data from key-value pairs. Automatically sets the `Content-Type` header. Values starting with `@` are treated as file references (local path or URL). Use `@@` to escape literal `@`. | `{{ body_FormData "field1" "value1" "file" "@/path/to/file.pdf" }}` |
|
||||
@@ -149,19 +186,19 @@ body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
|
||||
|
||||
# Mixed text fields and files
|
||||
body: |
|
||||
{{ body_FormData
|
||||
"title" "My Report"
|
||||
"author" "John Doe"
|
||||
"cover" "@/path/to/cover.jpg"
|
||||
"document" "@/path/to/report.pdf"
|
||||
}}
|
||||
{{ body_FormData
|
||||
"title" "My Report"
|
||||
"author" "John Doe"
|
||||
"cover" "@/path/to/cover.jpg"
|
||||
"document" "@/path/to/report.pdf"
|
||||
}}
|
||||
|
||||
# Multiple files with same field name
|
||||
body: |
|
||||
{{ body_FormData
|
||||
"files" "@/path/to/file1.pdf"
|
||||
"files" "@/path/to/file2.pdf"
|
||||
}}
|
||||
{{ body_FormData
|
||||
"files" "@/path/to/file1.pdf"
|
||||
"files" "@/path/to/file2.pdf"
|
||||
}}
|
||||
|
||||
# Escape @ for literal value (sends "@username")
|
||||
body: '{{ body_FormData "twitter" "@@username" }}'
|
||||
@@ -192,20 +229,109 @@ body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "docume
|
||||
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}'
|
||||
|
||||
# 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 }}"}'
|
||||
```
|
||||
|
||||
## 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 ~5–60 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. Sarin supports reCAPTCHA v2, reCAPTCHA v3, and Cloudflare Turnstile through this integration.
|
||||
|
||||
| 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 the only Sarin captcha integration that exposes an hCaptcha function.
|
||||
|
||||
| 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. Sarin supports reCAPTCHA v2, reCAPTCHA v3, and Cloudflare Turnstile through this integration.
|
||||
|
||||
| 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
|
||||
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
|
||||
|
||||
These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library.
|
||||
|
||||
### File
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| ---------------------- | -------------- | -------------------- |
|
||||
| `fakeit_FileExtension` | File extension | `"nes"` |
|
||||
| `fakeit_FileMimeType` | MIME type | `"application/json"` |
|
||||
| Function | Description | Example Output |
|
||||
| ---------------------- | -------------- | --------------------- |
|
||||
| `fakeit_FileExtension` | File extension | `"nes"` |
|
||||
| `fakeit_FileMimeType` | MIME type | `"application/x-wri"` |
|
||||
|
||||
### ID
|
||||
|
||||
@@ -224,7 +350,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| `fakeit_ProductFeature` | Product feature | `"ultra-lightweight"` |
|
||||
| `fakeit_ProductMaterial` | Product material | `"brass"` |
|
||||
| `fakeit_ProductUPC` | UPC code | `"012780949980"` |
|
||||
| `fakeit_ProductAudience` | Target audience | `["adults"]` |
|
||||
| `fakeit_ProductAudience` | Target audience | `[adults]` |
|
||||
| `fakeit_ProductDimension` | Product dimension | `"medium"` |
|
||||
| `fakeit_ProductUseCase` | Use case | `"home"` |
|
||||
| `fakeit_ProductBenefit` | Product benefit | `"comfort"` |
|
||||
@@ -233,23 +359,25 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
|
||||
### Person
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| ----------------------- | ---------------------- | ------------------------ |
|
||||
| `fakeit_Name` | Full name | `"Markus Moen"` |
|
||||
| `fakeit_NamePrefix` | Name prefix | `"Mr."` |
|
||||
| `fakeit_NameSuffix` | Name suffix | `"Jr."` |
|
||||
| `fakeit_FirstName` | First name | `"Markus"` |
|
||||
| `fakeit_MiddleName` | Middle name | `"Belinda"` |
|
||||
| `fakeit_LastName` | Last name | `"Daniel"` |
|
||||
| `fakeit_Gender` | Gender | `"male"` |
|
||||
| `fakeit_Age` | Age | `40` |
|
||||
| `fakeit_Ethnicity` | Ethnicity | `"German"` |
|
||||
| `fakeit_SSN` | Social Security Number | `"296446360"` |
|
||||
| `fakeit_EIN` | Employer ID Number | `"12-3456789"` |
|
||||
| `fakeit_Hobby` | Hobby | `"Swimming"` |
|
||||
| `fakeit_Email` | Email address | `"markusmoen@pagac.net"` |
|
||||
| `fakeit_Phone` | Phone number | `"6136459948"` |
|
||||
| `fakeit_PhoneFormatted` | Formatted phone | `"136-459-9489"` |
|
||||
| Function | Description | Example Output |
|
||||
| ----------------------- | ---------------------- | ------------------------------------------------------------------- |
|
||||
| `fakeit_Name` | Full name | `"Priscilla Thornton"` |
|
||||
| `fakeit_NamePrefix` | Name prefix | `"Mr."` |
|
||||
| `fakeit_NameSuffix` | Name suffix | `"Jr."` |
|
||||
| `fakeit_FirstName` | First name | `"Priscilla"` |
|
||||
| `fakeit_MiddleName` | Middle name | `"Belinda"` |
|
||||
| `fakeit_LastName` | Last name | `"Daniel"` |
|
||||
| `fakeit_Gender` | Gender | `"male"` |
|
||||
| `fakeit_Age` | Age | `40` |
|
||||
| `fakeit_Ethnicity` | Ethnicity | `"German"` |
|
||||
| `fakeit_SSN` | Social Security Number | `"296446360"` |
|
||||
| `fakeit_EIN` | Employer ID Number | `"12-3456789"` |
|
||||
| `fakeit_Hobby` | Hobby | `"Swimming"` |
|
||||
| `fakeit_SocialMedia` | Social media profile | `"https://twitter.com/ImpossibleTrousers"` |
|
||||
| `fakeit_Bio` | Biography | `"Born in New York, John grew up to become a Software Engineer..."` |
|
||||
| `fakeit_Email` | Email address | `"priscilla.thornton@duncan.biz"` |
|
||||
| `fakeit_Phone` | Phone number | `"6136459948"` |
|
||||
| `fakeit_PhoneFormatted` | Formatted phone | `"136-459-9489"` |
|
||||
|
||||
### Generate
|
||||
|
||||
@@ -262,13 +390,13 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| Function | Description | Example |
|
||||
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `fakeit_Username` | Username | `"Daniel1364"` |
|
||||
| `fakeit_Password(upper bool, lower bool, numeric bool, special bool, space bool, length int)` | Generate password with specified character types and length | `{{ fakeit_Password true true true false false 16 }}` |
|
||||
| `fakeit_Password(lower bool, upper bool, numeric bool, special bool, space bool, length int)` | Generate password with specified character types and length | `{{ fakeit_Password true true true false false 16 }}` |
|
||||
|
||||
### Address
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- |
|
||||
| `fakeit_City` | City name | `"Marcelside"` |
|
||||
| `fakeit_City` | City name | `"Reno"` |
|
||||
| `fakeit_Country` | Country name | `"United States of America"` |
|
||||
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
|
||||
| `fakeit_State` | State name | `"Illinois"` |
|
||||
@@ -277,7 +405,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| `fakeit_StreetName` | Street name | `"View"` |
|
||||
| `fakeit_StreetNumber` | Street number | `"13645"` |
|
||||
| `fakeit_StreetPrefix` | Street prefix | `"East"` |
|
||||
| `fakeit_StreetSuffix` | Street suffix | `"Ave"` |
|
||||
| `fakeit_StreetSuffix` | Street suffix | `"side"` |
|
||||
| `fakeit_Unit` | Unit | `"Apt 123"` |
|
||||
| `fakeit_Zip` | ZIP code | `"13645"` |
|
||||
| `fakeit_Latitude` | Random latitude | `-73.534056` |
|
||||
@@ -285,11 +413,23 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| `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 }}` → `122.471830` |
|
||||
|
||||
### Airline
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| ----------------------------- | ----------------- | ---------------------------------------------------- |
|
||||
| `fakeit_AirlineAircraftType` | Aircraft type | `"narrowbody"` |
|
||||
| `fakeit_AirlineAirplane` | Airplane model | `"Airbus A320"` |
|
||||
| `fakeit_AirlineAirport` | Airport name | `"Hartsfield-Jackson Atlanta International Airport"` |
|
||||
| `fakeit_AirlineAirportIATA` | Airport IATA code | `"ATL"` |
|
||||
| `fakeit_AirlineFlightNumber` | Flight number | `"AA1234"` |
|
||||
| `fakeit_AirlineRecordLocator` | Booking reference | `"ABCDEF"` |
|
||||
| `fakeit_AirlineSeat` | Seat assignment | `"12A"` |
|
||||
|
||||
### Game
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| ----------------- | ----------- | ------------------- |
|
||||
| `fakeit_Gamertag` | Gamer tag | `"footinterpret63"` |
|
||||
| Function | Description | Example Output |
|
||||
| ----------------- | ----------- | ----------------- |
|
||||
| `fakeit_Gamertag` | Gamer tag | `"TurkeyThinker"` |
|
||||
|
||||
### Beer
|
||||
|
||||
@@ -320,18 +460,22 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| ---------------------------------- | --------------------------- | ---------------- |
|
||||
| `fakeit_Word` | Random word | `"example"` |
|
||||
| `fakeit_Noun` | Random noun | `"computer"` |
|
||||
| `fakeit_NounCommon` | Common noun | `"table"` |
|
||||
| `fakeit_NounCommon` | Common noun | `"group"` |
|
||||
| `fakeit_NounConcrete` | Concrete noun | `"chair"` |
|
||||
| `fakeit_NounAbstract` | Abstract noun | `"freedom"` |
|
||||
| `fakeit_NounCollectivePeople` | Collective noun (people) | `"team"` |
|
||||
| `fakeit_NounCollectiveAnimal` | Collective noun (animal) | `"herd"` |
|
||||
| `fakeit_NounCollectiveAnimal` | Collective noun (animal) | `"leap"` |
|
||||
| `fakeit_NounCollectiveThing` | Collective noun (thing) | `"bunch"` |
|
||||
| `fakeit_NounCountable` | Countable noun | `"book"` |
|
||||
| `fakeit_NounUncountable` | Uncountable noun | `"water"` |
|
||||
| `fakeit_Verb` | Random verb | `"run"` |
|
||||
| `fakeit_NounProper` | Proper noun | `"John"` |
|
||||
| `fakeit_NounDeterminer` | Noun determiner | `"your"` |
|
||||
| `fakeit_Verb` | Random verb | `"would"` |
|
||||
| `fakeit_VerbAction` | Action verb | `"jump"` |
|
||||
| `fakeit_VerbLinking` | Linking verb | `"is"` |
|
||||
| `fakeit_VerbHelping` | Helping verb | `"can"` |
|
||||
| `fakeit_VerbTransitive` | Transitive verb | `"follow"` |
|
||||
| `fakeit_VerbIntransitive` | Intransitive verb | `"laugh"` |
|
||||
| `fakeit_Adverb` | Random adverb | `"quickly"` |
|
||||
| `fakeit_AdverbManner` | Manner adverb | `"carefully"` |
|
||||
| `fakeit_AdverbDegree` | Degree adverb | `"very"` |
|
||||
@@ -345,7 +489,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| `fakeit_PrepositionDouble` | Double preposition | `"out of"` |
|
||||
| `fakeit_PrepositionCompound` | Compound preposition | `"according to"` |
|
||||
| `fakeit_Adjective` | Random adjective | `"beautiful"` |
|
||||
| `fakeit_AdjectiveDescriptive` | Descriptive adjective | `"large"` |
|
||||
| `fakeit_AdjectiveDescriptive` | Descriptive adjective | `"tired"` |
|
||||
| `fakeit_AdjectiveQuantitative` | Quantitative adjective | `"many"` |
|
||||
| `fakeit_AdjectiveProper` | Proper adjective | `"American"` |
|
||||
| `fakeit_AdjectiveDemonstrative` | Demonstrative adjective | `"this"` |
|
||||
@@ -360,26 +504,33 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| `fakeit_PronounDemonstrative` | Demonstrative pronoun | `"that"` |
|
||||
| `fakeit_PronounInterrogative` | Interrogative pronoun | `"who"` |
|
||||
| `fakeit_PronounRelative` | Relative pronoun | `"which"` |
|
||||
| `fakeit_PronounIndefinite` | Indefinite pronoun | `"few"` |
|
||||
| `fakeit_Connective` | Random connective | `"however"` |
|
||||
| `fakeit_ConnectiveTime` | Time connective | `"then"` |
|
||||
| `fakeit_ConnectiveComparative` | Comparative connective | `"similarly"` |
|
||||
| `fakeit_ConnectiveComplaint` | Complaint connective | `"although"` |
|
||||
| `fakeit_ConnectiveComparative` | Comparative connective | `"after all"` |
|
||||
| `fakeit_ConnectiveComplaint` | Complaint connective | `"i.e."` |
|
||||
| `fakeit_ConnectiveListing` | Listing connective | `"firstly"` |
|
||||
| `fakeit_ConnectiveCasual` | Casual connective | `"because"` |
|
||||
| `fakeit_ConnectiveExamplify` | Examplify connective | `"for example"` |
|
||||
| `fakeit_Interjection` | Interjection | `"wow"` |
|
||||
|
||||
### Text
|
||||
|
||||
| Function | Description | Example |
|
||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------- |
|
||||
| `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 }}` |
|
||||
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
||||
| `fakeit_Question` | Random question | `"What is your name?"` |
|
||||
| `fakeit_Quote` | Random quote | `"Life is what happens..."` |
|
||||
| `fakeit_Phrase` | Random phrase | `"a piece of cake"` |
|
||||
| Function | Description | Example |
|
||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
|
||||
| `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` |
|
||||
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
|
||||
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"eveniet"` |
|
||||
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
|
||||
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
||||
| `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` |
|
||||
| `fakeit_Quote` | Random quote | `"Energy is contagious so is fear"` |
|
||||
| `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` |
|
||||
| `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` |
|
||||
| `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` |
|
||||
| `fakeit_PhraseAdverb` | Adverb phrase | `"fully gladly"` |
|
||||
| `fakeit_PhrasePreposition` | Preposition phrase | `"out the black thing"` |
|
||||
| `fakeit_Comment` | Comment | `"add some a little bit team"` |
|
||||
|
||||
### Foods
|
||||
|
||||
@@ -392,6 +543,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| `fakeit_Dinner` | Dinner food | `"Wild addicting dip"` |
|
||||
| `fakeit_Snack` | Snack | `"Trail mix"` |
|
||||
| `fakeit_Dessert` | Dessert | `"French napoleons"` |
|
||||
| `fakeit_Drink` | Drink | `"Soda"` |
|
||||
|
||||
### Misc
|
||||
|
||||
@@ -402,20 +554,23 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
|
||||
### Color
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| ------------------- | ------------------ | --------------------------------------------------------- |
|
||||
| `fakeit_Color` | Color name | `"MediumOrchid"` |
|
||||
| `fakeit_HexColor` | Hex color | `"#a99fb4"` |
|
||||
| `fakeit_RGBColor` | RGB color | `[85, 224, 195]` |
|
||||
| `fakeit_SafeColor` | Safe color | `"black"` |
|
||||
| `fakeit_NiceColors` | Nice color palette | `["#cfffdd", "#b4dec1", "#5c5863", "#a85163", "#ff1f4c"]` |
|
||||
| Function | Description | Example Output |
|
||||
| ------------------- | ------------------ | ------------------------------------------- |
|
||||
| `fakeit_Color` | Color name | `"MediumOrchid"` |
|
||||
| `fakeit_HexColor` | Hex color | `"#a99fb4"` |
|
||||
| `fakeit_RGBColor` | RGB color | `[85 224 195]` |
|
||||
| `fakeit_HSLColor` | HSL color | `[210 85 50]` |
|
||||
| `fakeit_SafeColor` | Safe color | `"black"` |
|
||||
| `fakeit_NiceColors` | Nice color palette | `[#cfffdd #b4dec1 #5c5863 #a85163 #ff1f4c]` |
|
||||
|
||||
### Image
|
||||
|
||||
| Function | Description | Example |
|
||||
| ----------------------------------------- | ------------------------- | -------------------------------- |
|
||||
| `fakeit_ImageJpeg(width int, height int)` | Generate JPEG image bytes | `{{ fakeit_ImageJpeg 100 100 }}` |
|
||||
| `fakeit_ImagePng(width int, height int)` | Generate PNG image bytes | `{{ fakeit_ImagePng 100 100 }}` |
|
||||
| Function | Description | Example |
|
||||
| ----------------------------------------- | -------------------------- | -------------------------------- |
|
||||
| `fakeit_ImageJpeg(width int, height int)` | Generate a JPEG byte slice | `{{ fakeit_ImageJpeg 100 100 }}` |
|
||||
| `fakeit_ImagePng(width int, height int)` | Generate a PNG byte slice | `{{ fakeit_ImagePng 100 100 }}` |
|
||||
|
||||
> **Note:** Go templates render byte slices as decimal lists, not raw binary image data. Use `{{ json_Encode (fakeit_ImagePng 100 100) }}` to produce a Base64-encoded JSON string. These functions cannot write raw image bytes directly through body templating.
|
||||
|
||||
### Internet
|
||||
|
||||
@@ -444,16 +599,16 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| ------------------ | --------------- | ------------------ |
|
||||
| `fakeit_InputName` | HTML input name | `"email"` |
|
||||
| `fakeit_InputName` | HTML input name | `"message"` |
|
||||
| `fakeit_Svg` | SVG image | `"<svg>...</svg>"` |
|
||||
|
||||
### Date/Time
|
||||
|
||||
| Function | Description | Example |
|
||||
| -------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `fakeit_Date` | Random date | `2023-06-15 14:30:00` |
|
||||
| `fakeit_PastDate` | Past date | `2022-03-10 09:15:00` |
|
||||
| `fakeit_FutureDate` | Future date | `2025-12-20 18:45:00` |
|
||||
| `fakeit_Date` | Random date | `2013-11-07 04:31:13.726582492 +0000 UTC` |
|
||||
| `fakeit_PastDate` | Date 1–12 hours before now | Current time − 1–12 hours |
|
||||
| `fakeit_FutureDate` | Date 1–12 hours after now | Current time + 1–12 hours |
|
||||
| `fakeit_DateRange(start time.Time, end time.Time)` | Random date between start and end | `{{ fakeit_DateRange (strings_ToDate "2020-01-01") (strings_ToDate "2025-12-31") }}` |
|
||||
| `fakeit_NanoSecond` | Nanosecond | `123456789` |
|
||||
| `fakeit_Second` | Second (0-59) | `45` |
|
||||
@@ -464,11 +619,11 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| `fakeit_Day` | Day (1-31) | `15` |
|
||||
| `fakeit_WeekDay` | Weekday | `"Monday"` |
|
||||
| `fakeit_Year` | Year | `2024` |
|
||||
| `fakeit_TimeZone` | Timezone | `"America/New_York"` |
|
||||
| `fakeit_TimeZone` | Timezone name | `"West Pacific Standard Time"` |
|
||||
| `fakeit_TimeZoneAbv` | Timezone abbreviation | `"EST"` |
|
||||
| `fakeit_TimeZoneFull` | Full timezone | `"Eastern Standard Time"` |
|
||||
| `fakeit_TimeZoneFull` | Full timezone | `"(UTC+10:00) Guam, Port Moresby"` |
|
||||
| `fakeit_TimeZoneOffset` | Timezone offset | `-5` |
|
||||
| `fakeit_TimeZoneRegion` | Timezone region | `"America"` |
|
||||
| `fakeit_TimeZoneRegion` | Timezone region | `"Indian/Chagos"` |
|
||||
|
||||
### Payment
|
||||
|
||||
@@ -484,8 +639,8 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| `fakeit_AchRouting` | ACH routing number | `"513715684"` |
|
||||
| `fakeit_AchAccount` | ACH account number | `"491527954328"` |
|
||||
| `fakeit_BitcoinAddress` | Bitcoin address | `"1BoatSLRHtKNngkdXEeobR76b53LETtpyT"` |
|
||||
| `fakeit_BitcoinPrivateKey` | Bitcoin private key | `"5HueCGU8rMjxEXxiPuD5BDuG6o5xjA7QkbPp"` |
|
||||
| `fakeit_BankName` | Bank name | `"Wells Fargo"` |
|
||||
| `fakeit_BitcoinPrivateKey` | Bitcoin private key | `"5JMZxkQX2PgaasaHc8wnWLNdMu7rxeU7xS64ev7RWNinacicPfm"` |
|
||||
| `fakeit_BankName` | Bank name | `"Toronto Dominion Bank"` |
|
||||
| `fakeit_BankType` | Bank type | `"Investment Bank"` |
|
||||
|
||||
### Finance
|
||||
@@ -497,17 +652,17 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
|
||||
### Company
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| ---------------------- | -------------- | ------------------------------------------ |
|
||||
| `fakeit_BS` | Business speak | `"front-end"` |
|
||||
| `fakeit_Blurb` | Company blurb | `"word"` |
|
||||
| `fakeit_BuzzWord` | Buzzword | `"disintermediate"` |
|
||||
| `fakeit_Company` | Company name | `"Moen, Pagac and Wuckert"` |
|
||||
| `fakeit_CompanySuffix` | Company suffix | `"Inc"` |
|
||||
| `fakeit_JobDescriptor` | Job descriptor | `"Central"` |
|
||||
| `fakeit_JobLevel` | Job level | `"Assurance"` |
|
||||
| `fakeit_JobTitle` | Job title | `"Director"` |
|
||||
| `fakeit_Slogan` | Company slogan | `"Universal seamless Focus, interactive."` |
|
||||
| Function | Description | Example Output |
|
||||
| ---------------------- | -------------- | ---------------------- |
|
||||
| `fakeit_BS` | Business speak | `"front-end"` |
|
||||
| `fakeit_Blurb` | Company blurb | `"Teamwork"` |
|
||||
| `fakeit_BuzzWord` | Buzzword | `"disintermediate"` |
|
||||
| `fakeit_Company` | Company name | `"TransparaGov"` |
|
||||
| `fakeit_CompanySuffix` | Company suffix | `"Inc"` |
|
||||
| `fakeit_JobDescriptor` | Job descriptor | `"Central"` |
|
||||
| `fakeit_JobLevel` | Job level | `"Assurance"` |
|
||||
| `fakeit_JobTitle` | Job title | `"Director"` |
|
||||
| `fakeit_Slogan` | Company slogan | `"Made of styrofoam."` |
|
||||
|
||||
### Hacker
|
||||
|
||||
@@ -572,9 +727,9 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| `fakeit_EmojiTools` | Tools emoji | `"🔧"` |
|
||||
| `fakeit_EmojiWeather` | Weather emoji | `"☀️"` |
|
||||
| `fakeit_EmojiJob` | Job emoji | `"👨💻"` |
|
||||
| `fakeit_EmojiPerson` | Person emoji | `"👤"` |
|
||||
| `fakeit_EmojiGesture` | Gesture emoji | `"🙌"` |
|
||||
| `fakeit_EmojiCostume` | Costume emoji | `"🎃"` |
|
||||
| `fakeit_EmojiPerson` | Person emoji | `"🧓"` |
|
||||
| `fakeit_EmojiGesture` | Gesture emoji | `"🤦♀️"` |
|
||||
| `fakeit_EmojiCostume` | Costume emoji | `"🧞"` |
|
||||
| `fakeit_EmojiSentence` | Emoji sentence with random emojis interspersed | `"Weekends reserve time for 🖼️ Disc 🏨 golf and day."` |
|
||||
|
||||
### Language
|
||||
@@ -583,31 +738,35 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| ----------------------------- | --------------------- | -------------- |
|
||||
| `fakeit_Language` | Language | `"English"` |
|
||||
| `fakeit_LanguageAbbreviation` | Language abbreviation | `"en"` |
|
||||
| `fakeit_LanguageBCP` | BCP language tag | `"en-US"` |
|
||||
| `fakeit_ProgrammingLanguage` | Programming language | `"Go"` |
|
||||
|
||||
### Number
|
||||
|
||||
| Function | Description | Example |
|
||||
| ----------------------------------------------- | ----------------------------------- | ------------------------------------------ |
|
||||
| `fakeit_Number(min int, max int)` | Random number in range | `{{ fakeit_Number 1 100 }}` → `42` |
|
||||
| `fakeit_Int` | Random int | `{{ fakeit_Int }}` |
|
||||
| `fakeit_IntN(n int)` | Random int from 0 to n | `{{ fakeit_IntN 100 }}` |
|
||||
| `fakeit_Int8` | Random int8 | `{{ fakeit_Int8 }}` |
|
||||
| `fakeit_Int16` | Random int16 | `{{ fakeit_Int16 }}` |
|
||||
| `fakeit_Int32` | Random int32 | `{{ fakeit_Int32 }}` |
|
||||
| `fakeit_Int64` | Random int64 | `{{ fakeit_Int64 }}` |
|
||||
| `fakeit_Uint` | Random uint | `{{ fakeit_Uint }}` |
|
||||
| `fakeit_UintN(n uint)` | Random uint from 0 to n | `{{ fakeit_UintN 100 }}` |
|
||||
| `fakeit_Uint8` | Random uint8 | `{{ fakeit_Uint8 }}` |
|
||||
| `fakeit_Uint16` | Random uint16 | `{{ fakeit_Uint16 }}` |
|
||||
| `fakeit_Uint32` | Random uint32 | `{{ fakeit_Uint32 }}` |
|
||||
| `fakeit_Uint64` | Random uint64 | `{{ fakeit_Uint64 }}` |
|
||||
| `fakeit_Float32` | Random float32 | `{{ fakeit_Float32 }}` |
|
||||
| `fakeit_Float32Range(min float32, max float32)` | Random float32 in range | `{{ fakeit_Float32Range 0 100 }}` |
|
||||
| `fakeit_Float64` | Random float64 | `{{ fakeit_Float64 }}` |
|
||||
| `fakeit_Float64Range(min float64, max float64)` | Random float64 in range | `{{ fakeit_Float64Range 0 100 }}` |
|
||||
| `fakeit_RandomInt(slice []int)` | Random int from slice | `{{ fakeit_RandomInt (slice_Int 1 2 3) }}` |
|
||||
| `fakeit_HexUint(bits int)` | Random hex uint with specified bits | `{{ fakeit_HexUint 8 }}` → `"0xff"` |
|
||||
| Function | Description | Example |
|
||||
| ----------------------------------------------- | ----------------------------------- | -------------------------------------------- |
|
||||
| `fakeit_Number(min int, max int)` | Random number in range | `{{ fakeit_Number 1 100 }}` → `42` |
|
||||
| `fakeit_Int` | Random int | `{{ fakeit_Int }}` |
|
||||
| `fakeit_IntN(n int)` | Random int in `[0, n)` | `{{ fakeit_IntN 100 }}` |
|
||||
| `fakeit_Int8` | Random int8 | `{{ fakeit_Int8 }}` |
|
||||
| `fakeit_Int16` | Random int16 | `{{ fakeit_Int16 }}` |
|
||||
| `fakeit_Int32` | Random int32 | `{{ fakeit_Int32 }}` |
|
||||
| `fakeit_Int64` | Random int64 | `{{ fakeit_Int64 }}` |
|
||||
| `fakeit_IntRange(min int, max int)` | Random int in range | `{{ fakeit_IntRange -10 10 }}` |
|
||||
| `fakeit_Uint` | Random uint | `{{ fakeit_Uint }}` |
|
||||
| `fakeit_UintN(n uint)` | Random uint in `[0, n)` | `{{ fakeit_UintN 100 }}` |
|
||||
| `fakeit_Uint8` | Random uint8 | `{{ fakeit_Uint8 }}` |
|
||||
| `fakeit_Uint16` | Random uint16 | `{{ fakeit_Uint16 }}` |
|
||||
| `fakeit_Uint32` | Random uint32 | `{{ fakeit_Uint32 }}` |
|
||||
| `fakeit_Uint64` | Random uint64 | `{{ fakeit_Uint64 }}` |
|
||||
| `fakeit_UintRange(min uint, max uint)` | Random uint in range | `{{ fakeit_UintRange 10 100 }}` |
|
||||
| `fakeit_Float32` | Random float32 | `{{ fakeit_Float32 }}` |
|
||||
| `fakeit_Float32Range(min float32, max float32)` | Random float32 in range | `{{ fakeit_Float32Range 0 100 }}` |
|
||||
| `fakeit_Float64` | Random float64 | `{{ fakeit_Float64 }}` |
|
||||
| `fakeit_Float64Range(min float64, max float64)` | Random float64 in range | `{{ fakeit_Float64Range 0 100 }}` |
|
||||
| `fakeit_RandomInt(slice []int)` | Random int from slice | `{{ fakeit_RandomInt (slice_Int 1 2 3) }}` |
|
||||
| `fakeit_RandomUint(slice []uint)` | Random uint from slice | `{{ fakeit_RandomUint (slice_Uint 1 2 3) }}` |
|
||||
| `fakeit_HexUint(bits int)` | Random hex uint with specified bits | `{{ fakeit_HexUint 8 }}` → `"0xff"` |
|
||||
|
||||
### String
|
||||
|
||||
@@ -617,6 +776,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
| `fakeit_DigitN(n uint)` | Generate `n` random digits | `{{ fakeit_DigitN 5 }}` → `"71364"` |
|
||||
| `fakeit_Letter` | Single random letter | `"g"` |
|
||||
| `fakeit_LetterN(n uint)` | Generate `n` random letters | `{{ fakeit_LetterN 10 }}` → `"gbRMaRxHki"` |
|
||||
| `fakeit_Vowel` | Single lowercase vowel | `"a"` |
|
||||
| `fakeit_Lexify(pattern string)` | Replace `?` with random letters | `{{ fakeit_Lexify "?????@??????.com" }}` → `"billy@mister.com"` |
|
||||
| `fakeit_Numerify(pattern string)` | Replace `#` with random digits | `{{ fakeit_Numerify "(###)###-####" }}` → `"(555)867-5309"` |
|
||||
| `fakeit_RandomString(slice []string)` | Random string from slice | `{{ fakeit_RandomString (slice_Str "a" "b" "c") }}` |
|
||||
@@ -631,26 +791,26 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
|
||||
### Minecraft
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| --------------------------------- | ----------------- | ---------------- |
|
||||
| `fakeit_MinecraftOre` | Minecraft ore | `"coal"` |
|
||||
| `fakeit_MinecraftWood` | Minecraft wood | `"oak"` |
|
||||
| `fakeit_MinecraftArmorTier` | Armor tier | `"iron"` |
|
||||
| `fakeit_MinecraftArmorPart` | Armor part | `"helmet"` |
|
||||
| `fakeit_MinecraftWeapon` | Minecraft weapon | `"bow"` |
|
||||
| `fakeit_MinecraftTool` | Minecraft tool | `"shovel"` |
|
||||
| `fakeit_MinecraftDye` | Minecraft dye | `"white"` |
|
||||
| `fakeit_MinecraftFood` | Minecraft food | `"apple"` |
|
||||
| `fakeit_MinecraftAnimal` | Minecraft animal | `"chicken"` |
|
||||
| `fakeit_MinecraftVillagerJob` | Villager job | `"farmer"` |
|
||||
| `fakeit_MinecraftVillagerStation` | Villager station | `"furnace"` |
|
||||
| `fakeit_MinecraftVillagerLevel` | Villager level | `"master"` |
|
||||
| `fakeit_MinecraftMobPassive` | Passive mob | `"cow"` |
|
||||
| `fakeit_MinecraftMobNeutral` | Neutral mob | `"bee"` |
|
||||
| `fakeit_MinecraftMobHostile` | Hostile mob | `"spider"` |
|
||||
| `fakeit_MinecraftMobBoss` | Boss mob | `"ender dragon"` |
|
||||
| `fakeit_MinecraftBiome` | Minecraft biome | `"forest"` |
|
||||
| `fakeit_MinecraftWeather` | Minecraft weather | `"rain"` |
|
||||
| Function | Description | Example Output |
|
||||
| --------------------------------- | ----------------- | ------------------- |
|
||||
| `fakeit_MinecraftOre` | Minecraft ore | `"coal"` |
|
||||
| `fakeit_MinecraftWood` | Minecraft wood | `"oak"` |
|
||||
| `fakeit_MinecraftArmorTier` | Armor tier | `"iron"` |
|
||||
| `fakeit_MinecraftArmorPart` | Armor part | `"helmet"` |
|
||||
| `fakeit_MinecraftWeapon` | Minecraft weapon | `"bow"` |
|
||||
| `fakeit_MinecraftTool` | Minecraft tool | `"shovel"` |
|
||||
| `fakeit_MinecraftDye` | Minecraft dye | `"white"` |
|
||||
| `fakeit_MinecraftFood` | Minecraft food | `"apple"` |
|
||||
| `fakeit_MinecraftAnimal` | Minecraft animal | `"chicken"` |
|
||||
| `fakeit_MinecraftVillagerJob` | Villager job | `"farmer"` |
|
||||
| `fakeit_MinecraftVillagerStation` | Villager station | `"stonecutter"` |
|
||||
| `fakeit_MinecraftVillagerLevel` | Villager level | `"master"` |
|
||||
| `fakeit_MinecraftMobPassive` | Passive mob | `"cow"` |
|
||||
| `fakeit_MinecraftMobNeutral` | Neutral mob | `"bee"` |
|
||||
| `fakeit_MinecraftMobHostile` | Hostile mob | `"wither skeleton"` |
|
||||
| `fakeit_MinecraftMobBoss` | Boss mob | `"ender dragon"` |
|
||||
| `fakeit_MinecraftBiome` | Minecraft biome | `"forest"` |
|
||||
| `fakeit_MinecraftWeather` | Minecraft weather | `"rain"` |
|
||||
|
||||
### Book
|
||||
|
||||
@@ -669,26 +829,28 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
||||
|
||||
### Error
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| ------------------------ | ----------------- | ---------------------------------- |
|
||||
| `fakeit_Error` | Random error | `"connection refused"` |
|
||||
| `fakeit_ErrorDatabase` | Database error | `"database connection failed"` |
|
||||
| `fakeit_ErrorGRPC` | gRPC error | `"rpc error: code = Unavailable"` |
|
||||
| `fakeit_ErrorHTTP` | HTTP error | `"HTTP 500 Internal Server Error"` |
|
||||
| `fakeit_ErrorHTTPClient` | HTTP client error | `"HTTP 404 Not Found"` |
|
||||
| `fakeit_ErrorHTTPServer` | HTTP server error | `"HTTP 503 Service Unavailable"` |
|
||||
| `fakeit_ErrorRuntime` | Runtime error | `"panic: runtime error"` |
|
||||
| Function | Description | Example Output |
|
||||
| ------------------------ | ----------------- | ----------------------------------------------- |
|
||||
| `fakeit_Error` | Random error | `"variable assigned before declaration"` |
|
||||
| `fakeit_ErrorObject` | Error object | `"protocol"` |
|
||||
| `fakeit_ErrorDatabase` | Database error | `"destination pointer is nil"` |
|
||||
| `fakeit_ErrorGRPC` | gRPC error | `"connection refused"` |
|
||||
| `fakeit_ErrorHTTP` | HTTP error | `"wrote more than the declared Content-Length"` |
|
||||
| `fakeit_ErrorHTTPClient` | HTTP client error | `"expectation failed"` |
|
||||
| `fakeit_ErrorHTTPServer` | HTTP server error | `"not extended"` |
|
||||
| `fakeit_ErrorRuntime` | Runtime error | `"expected 2 arguments, got 3"` |
|
||||
| `fakeit_ErrorValidation` | Validation error | `"missing required field"` |
|
||||
|
||||
### School
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| --------------- | ----------- | ---------------------- |
|
||||
| `fakeit_School` | School name | `"Harvard University"` |
|
||||
| Function | Description | Example Output |
|
||||
| --------------- | ----------- | ---------------------------- |
|
||||
| `fakeit_School` | School name | `"Hillside Private Academy"` |
|
||||
|
||||
### Song
|
||||
|
||||
| Function | Description | Example Output |
|
||||
| ------------------- | ----------- | --------------------- |
|
||||
| `fakeit_SongName` | Song name | `"Bohemian Rhapsody"` |
|
||||
| `fakeit_SongArtist` | Song artist | `"Queen"` |
|
||||
| `fakeit_SongGenre` | Song genre | `"Rock"` |
|
||||
| Function | Description | Example Output |
|
||||
| ------------------- | ----------- | ------------------------ |
|
||||
| `fakeit_SongName` | Song name | `"What Was I Made For?"` |
|
||||
| `fakeit_SongArtist` | Song artist | `"Queen"` |
|
||||
| `fakeit_SongGenre` | Song genre | `"Rock"` |
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1789006805,
|
||||
"narHash": "sha256-xB8mKMOx1IA9vTDNLmJZ6n4wCMq/cuWBBOzGCRnqxrU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "8ce4ef6cb6f871616146b9fe26d2a5ae594e94fe",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
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/release -- <args>
|
||||
apps = forAllSystems (pkgs: {
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.default}/bin/sarin";
|
||||
meta.description = "High-performance HTTP load testing tool";
|
||||
};
|
||||
});
|
||||
|
||||
devShells = forAllSystems (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
packages = [ pkgs.go_1_27 pkgs.golangci-lint pkgs.go-task ];
|
||||
};
|
||||
});
|
||||
|
||||
# For downstream flakes: inputs.sarin.overlays.default
|
||||
overlays.default = final: _prev: {
|
||||
sarin = final.callPackage ./nix/package.nix { };
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,59 +1,53 @@
|
||||
module go.aykhans.me/sarin
|
||||
|
||||
go 1.26.1
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
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
|
||||
charm.land/bubbles/v2 v2.2.1
|
||||
charm.land/bubbletea/v2 v2.0.10
|
||||
charm.land/glamour/v2 v2.0.1
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
github.com/brianvoe/gofakeit/v7 v7.17.1
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/dop251/goja v0.0.0-20261004174310-e21e22eac913
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/valyala/fasthttp v1.69.0
|
||||
github.com/yuin/gopher-lua v1.1.1
|
||||
github.com/valyala/fasthttp v1.74.0
|
||||
github.com/yuin/gopher-lua v1.1.2
|
||||
go.aykhans.me/utils v1.0.7
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||
golang.org/x/net v0.52.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
golang.org/x/net v0.59.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // 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
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261001125412-878653296cfd // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.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/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.8.2 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20261003200830-ebaad5f31b4d // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // 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/klauspost/compress v1.20.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/molecule-man/go-brrr v1.2.0 // 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/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.7.16 // indirect
|
||||
github.com/xo/terminfo v1.2.0 // indirect
|
||||
github.com/yuin/goldmark v1.8.6 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // 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
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
)
|
||||
@@ -1,127 +1,108 @@
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||
charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
|
||||
charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=
|
||||
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/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
|
||||
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
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/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/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/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
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=
|
||||
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/brianvoe/gofakeit/v7 v7.17.1 h1:50FLBhTGVJQaj6ysRUu0it8wCdYO2uGM9VfuxI+csEc=
|
||||
github.com/brianvoe/gofakeit/v7 v7.17.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
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/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.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=
|
||||
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/ultraviolet v0.0.0-20261001125412-878653296cfd h1:W0HOPGkwRaAmV20plE/f+Rk6Xn+IdR8G0qHsGXcOnF8=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261001125412-878653296cfd/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/slice v0.1.0 h1:m46dwuDf3M5SFYMRhxYkLC6z9WnENGVKbcbJlp13/og=
|
||||
github.com/charmbracelet/x/exp/slice v0.1.0/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
github.com/dlclark/regexp2/v2 v2.8.2 h1:uTTe+cxADL4be+dv4DZwsBIEPCQ6RHPlnVuKrtLqSx0=
|
||||
github.com/dlclark/regexp2/v2 v2.8.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20261004174310-e21e22eac913 h1:0haZHQSJqA4qFZvnKIRgekXrPo2sQ2oRvFemiNHhcp0=
|
||||
github.com/dop251/goja v0.0.0-20261004174310-e21e22eac913/go.mod h1:GrCNKUrVplXZj0juFue0Cj2Xt/eL2Nuu2+y3UTanEo8=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/pprof v0.0.0-20261003200830-ebaad5f31b4d h1:kZK3FQM86gr3cgJHMP7rPn8A3Z75mt+ZaVBoqMlpPF0=
|
||||
github.com/google/pprof v0.0.0-20261003200830-ebaad5f31b4d/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
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/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
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/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/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/molecule-man/go-brrr v1.2.0 h1:dOJU45BC3Gc2WXoxAW0rgCAgVuu2gruAOGncfieA0Jw=
|
||||
github.com/molecule-man/go-brrr v1.2.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/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/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/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
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/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU=
|
||||
github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE=
|
||||
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
|
||||
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
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/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
|
||||
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/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.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.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=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,40 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
var (
|
||||
_ flag.Value = (*byteSize)(nil)
|
||||
_ yaml.Unmarshaler = (*byteSize)(nil)
|
||||
)
|
||||
|
||||
// byteSize is a size in bytes that reads and prints unit suffixes such as 10MiB.
|
||||
type byteSize uint64
|
||||
|
||||
func (size byteSize) String() string {
|
||||
return types.FormatByteSize(uint64(size))
|
||||
}
|
||||
|
||||
// Set fills the size from a command line value.
|
||||
func (size *byteSize) Set(value string) error {
|
||||
parsed, err := types.ParseByteSize(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*size = byteSize(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML reads the raw scalar so a size parses the same way here as on the command line.
|
||||
func (size *byteSize) UnmarshalYAML(node *yaml.Node) error {
|
||||
if node.Kind != yaml.ScalarNode {
|
||||
return fmt.Errorf("expected a size, but got %s", yamlNodeName(node))
|
||||
}
|
||||
|
||||
return size.Set(node.Value)
|
||||
}
|
||||
+65
-42
@@ -20,30 +20,33 @@ Simple usage:
|
||||
|
||||
Flags:
|
||||
General Config:
|
||||
-h, -help Help for sarin
|
||||
-v, -version Version for sarin
|
||||
-s, -show-config bool Show the final config after parsing all sources (default %v)
|
||||
-f, -config-file string Path to the config file (local file / http URL)
|
||||
-c, -concurrency uint Number of concurrent requests (default %d)
|
||||
-r, -requests uint Number of total requests
|
||||
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
||||
-q, -quiet bool Hide the progress bar and runtime logs (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)
|
||||
-h, -help Help for sarin
|
||||
-v, -version Version for sarin
|
||||
-s, -show-config bool Show the final config after parsing all sources (default %v)
|
||||
-f, -config-file []string Path to the config file (local file / http URL)
|
||||
-c, -concurrency uint Number of concurrent requests (default %d)
|
||||
-r, -requests uint Number of total requests
|
||||
-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)
|
||||
-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')
|
||||
-z, -dry-run bool Run without sending the generated requests (default %v)
|
||||
|
||||
Request Config:
|
||||
-U, -url string Target URL for the request
|
||||
-M, -method []string HTTP method for the request (default %s)
|
||||
-B, -body []string Body for the request (e.g. "body text")
|
||||
-P, -param []string URL parameter for the request (e.g. "key1=value1")
|
||||
-H, -header []string Header for the request (e.g. "key1: value1")
|
||||
-C, -cookie []string Cookie for the request (e.g. "key1=value1")
|
||||
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
|
||||
-V, -values []string List of values for templating (e.g. "key1=value1")
|
||||
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
|
||||
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)
|
||||
-lua []string Lua script for request transformation (inline or @file/@url)
|
||||
-js []string JavaScript script for request transformation (inline or @file/@url)`
|
||||
-U, -url string Target URL for the request
|
||||
-M, -method []string HTTP method for the request (default %s)
|
||||
-B, -body []string Body for the request (e.g. "body text")
|
||||
-P, -param []string URL parameter for the request (e.g. "key1=value1")
|
||||
-H, -header []string Header for the request (e.g. "key1: value1")
|
||||
-C, -cookie []string Cookie for the request (e.g. "key1=value1")
|
||||
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
|
||||
-V, -values []string List of values for templating (e.g. "key1=value1")
|
||||
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
|
||||
-S, -max-response-body size Largest response body to read (e.g. 10MiB, 2MB), 0 for no limit (default %s)
|
||||
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)
|
||||
-lua []string Lua script for request transformation (inline or @file/@url)
|
||||
-js []string JavaScript script for request transformation (inline or @file/@url)`
|
||||
|
||||
var _ IParser = ConfigCLIParser{}
|
||||
|
||||
@@ -88,23 +91,26 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||
concurrency uint
|
||||
requestCount uint64
|
||||
duration time.Duration
|
||||
quiet bool
|
||||
logLevel string
|
||||
logFile string
|
||||
progress string
|
||||
output string
|
||||
dryRun bool
|
||||
|
||||
// Request config
|
||||
urlInput string
|
||||
methods = stringSliceArg{}
|
||||
bodies = stringSliceArg{}
|
||||
params = stringSliceArg{}
|
||||
headers = stringSliceArg{}
|
||||
cookies = stringSliceArg{}
|
||||
proxies = stringSliceArg{}
|
||||
values = stringSliceArg{}
|
||||
timeout time.Duration
|
||||
insecure bool
|
||||
luaScripts = stringSliceArg{}
|
||||
jsScripts = stringSliceArg{}
|
||||
urlInput string
|
||||
methods = stringSliceArg{}
|
||||
bodies = stringSliceArg{}
|
||||
params = stringSliceArg{}
|
||||
headers = stringSliceArg{}
|
||||
cookies = stringSliceArg{}
|
||||
proxies = stringSliceArg{}
|
||||
values = stringSliceArg{}
|
||||
timeout time.Duration
|
||||
maxResponseBody byteSize
|
||||
insecure bool
|
||||
luaScripts = stringSliceArg{}
|
||||
jsScripts = stringSliceArg{}
|
||||
)
|
||||
|
||||
{
|
||||
@@ -127,14 +133,20 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
|
||||
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
|
||||
|
||||
flagSet.BoolVar(&quiet, "quiet", false, "Hide the progress bar and runtime logs")
|
||||
flagSet.BoolVar(&quiet, "q", false, "Hide the progress bar and runtime logs")
|
||||
flagSet.StringVar(&logLevel, "log-level", "", "Runtime log levels to emit, comma-separated (possible values: info, error)")
|
||||
flagSet.StringVar(&logLevel, "l", "", "Runtime log levels to emit, comma-separated (possible values: info, error)")
|
||||
|
||||
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, "o", "", "Output format (possible values: table, json, yaml, none)")
|
||||
|
||||
flagSet.BoolVar(&dryRun, "dry-run", false, "Run without sending requests")
|
||||
flagSet.BoolVar(&dryRun, "z", false, "Run without sending requests")
|
||||
flagSet.BoolVar(&dryRun, "dry-run", false, "Run without sending the generated requests")
|
||||
flagSet.BoolVar(&dryRun, "z", false, "Run without sending the generated requests")
|
||||
|
||||
// Request config
|
||||
flagSet.StringVar(&urlInput, "url", "", "Target URL for the request")
|
||||
@@ -164,6 +176,9 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||
flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
||||
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
||||
|
||||
flagSet.Var(&maxResponseBody, "max-response-body", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
|
||||
flagSet.Var(&maxResponseBody, "S", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
|
||||
|
||||
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
|
||||
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
|
||||
|
||||
@@ -205,8 +220,12 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||
config.Requests = new(requestCount)
|
||||
case "duration", "d":
|
||||
config.Duration = new(duration)
|
||||
case "quiet", "q":
|
||||
config.Quiet = new(quiet)
|
||||
case "log-level", "l":
|
||||
config.LogLevel = new(logLevel)
|
||||
case "log-file", "w":
|
||||
config.LogFile = new(logFile)
|
||||
case "progress", "p":
|
||||
config.Progress = new(ConfigProgressType(progress))
|
||||
case "output", "o":
|
||||
config.Output = new(ConfigOutputType(output))
|
||||
case "dry-run", "z":
|
||||
@@ -244,6 +263,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||
config.Values = append(config.Values, values...)
|
||||
case "timeout", "T":
|
||||
config.Timeout = new(timeout)
|
||||
case "max-response-body", "S":
|
||||
config.MaxResponseBody = new(uint64(maxResponseBody))
|
||||
case "insecure", "I":
|
||||
config.Insecure = new(insecure)
|
||||
case "lua":
|
||||
@@ -265,12 +286,14 @@ func (parser ConfigCLIParser) PrintHelp() {
|
||||
cliUsageText+"\n",
|
||||
Defaults.ShowConfig,
|
||||
Defaults.Concurrency,
|
||||
Defaults.Quiet,
|
||||
Defaults.LogLevel,
|
||||
Defaults.Progress,
|
||||
Defaults.Output,
|
||||
Defaults.DryRun,
|
||||
|
||||
Defaults.Method,
|
||||
Defaults.RequestTimeout,
|
||||
types.FormatByteSize(Defaults.MaxResponseBody),
|
||||
Defaults.Insecure,
|
||||
)
|
||||
}
|
||||
+199
-87
@@ -6,17 +6,18 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/styles"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/term"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/glamour/v2"
|
||||
"charm.land/glamour/v2/styles"
|
||||
"charm.land/lipgloss/v2"
|
||||
"go.aykhans.me/sarin/internal/sarin"
|
||||
"go.aykhans.me/sarin/internal/script"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
"go.aykhans.me/sarin/internal/version"
|
||||
@@ -26,30 +27,35 @@ import (
|
||||
)
|
||||
|
||||
var Defaults = struct {
|
||||
UserAgent string
|
||||
Method string
|
||||
RequestTimeout time.Duration
|
||||
Concurrency uint
|
||||
ShowConfig bool
|
||||
Quiet bool
|
||||
Insecure bool
|
||||
Output ConfigOutputType
|
||||
DryRun bool
|
||||
UserAgent string
|
||||
Method string
|
||||
RequestTimeout time.Duration
|
||||
MaxResponseBody uint64
|
||||
Concurrency uint
|
||||
ShowConfig bool
|
||||
Progress ConfigProgressType
|
||||
Insecure bool
|
||||
Output ConfigOutputType
|
||||
DryRun bool
|
||||
LogLevel string
|
||||
}{
|
||||
UserAgent: "Sarin/" + version.Version,
|
||||
Method: "GET",
|
||||
RequestTimeout: time.Second * 10,
|
||||
Concurrency: 1,
|
||||
ShowConfig: false,
|
||||
Quiet: false,
|
||||
Insecure: false,
|
||||
Output: ConfigOutputTypeTable,
|
||||
DryRun: false,
|
||||
UserAgent: "Sarin/" + version.Version,
|
||||
Method: "GET",
|
||||
RequestTimeout: time.Second * 10,
|
||||
MaxResponseBody: 10 << 20, // 10 MiB
|
||||
Concurrency: 1,
|
||||
ShowConfig: false,
|
||||
Progress: ConfigProgressTypeBar,
|
||||
Insecure: false,
|
||||
Output: ConfigOutputTypeTable,
|
||||
DryRun: false,
|
||||
LogLevel: "error",
|
||||
}
|
||||
|
||||
var (
|
||||
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
|
||||
ValidRequestURLSchemes = []string{"http", "https"}
|
||||
ValidLogLevels = []string{"info", "error"}
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -70,27 +76,37 @@ var (
|
||||
ConfigOutputTypeNone ConfigOutputType = "none"
|
||||
)
|
||||
|
||||
type ConfigProgressType string
|
||||
|
||||
var (
|
||||
ConfigProgressTypeBar ConfigProgressType = "bar"
|
||||
ConfigProgressTypeNone ConfigProgressType = "none"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
||||
Files []types.ConfigFile `yaml:"files,omitempty"`
|
||||
Methods []string `yaml:"methods,omitempty"`
|
||||
URL *url.URL `yaml:"url,omitempty"`
|
||||
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
||||
Concurrency *uint `yaml:"concurrency,omitempty"`
|
||||
Requests *uint64 `yaml:"requests,omitempty"`
|
||||
Duration *time.Duration `yaml:"duration,omitempty"`
|
||||
Quiet *bool `yaml:"quiet,omitempty"`
|
||||
Output *ConfigOutputType `yaml:"output,omitempty"`
|
||||
Insecure *bool `yaml:"insecure,omitempty"`
|
||||
DryRun *bool `yaml:"dryRun,omitempty"`
|
||||
Params types.Params `yaml:"params,omitempty"`
|
||||
Headers types.Headers `yaml:"headers,omitempty"`
|
||||
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
||||
Bodies []string `yaml:"bodies,omitempty"`
|
||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||
Values []string `yaml:"values,omitempty"`
|
||||
Lua []string `yaml:"lua,omitempty"`
|
||||
Js []string `yaml:"js,omitempty"`
|
||||
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
||||
Files []types.ConfigFile `yaml:"files,omitempty"`
|
||||
Methods []string `yaml:"methods,omitempty"`
|
||||
URL *url.URL `yaml:"url,omitempty"`
|
||||
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
||||
MaxResponseBody *uint64 `yaml:"maxResponseBody,omitempty"`
|
||||
Concurrency *uint `yaml:"concurrency,omitempty"`
|
||||
Requests *uint64 `yaml:"requests,omitempty"`
|
||||
Duration *time.Duration `yaml:"duration,omitempty"`
|
||||
Progress *ConfigProgressType `yaml:"progress,omitempty"`
|
||||
Output *ConfigOutputType `yaml:"output,omitempty"`
|
||||
Insecure *bool `yaml:"insecure,omitempty"`
|
||||
DryRun *bool `yaml:"dryRun,omitempty"`
|
||||
Params types.Params `yaml:"params,omitempty"`
|
||||
Headers types.Headers `yaml:"headers,omitempty"`
|
||||
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
||||
Bodies []string `yaml:"bodies,omitempty"`
|
||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||
Values []string `yaml:"values,omitempty"`
|
||||
Lua []string `yaml:"lua,omitempty"`
|
||||
Js []string `yaml:"js,omitempty"`
|
||||
LogLevel *string `yaml:"logLevel,omitempty"`
|
||||
LogFile *string `yaml:"logFile,omitempty"`
|
||||
}
|
||||
|
||||
func (config Config) MarshalYAML() (any, error) {
|
||||
@@ -164,6 +180,9 @@ func (config Config) MarshalYAML() (any, error) {
|
||||
if config.Timeout != nil {
|
||||
addField(content, "timeout", toNode(*config.Timeout), "")
|
||||
}
|
||||
if config.MaxResponseBody != nil {
|
||||
addField(content, "maxResponseBody", toNode(types.FormatByteSize(*config.MaxResponseBody)), "")
|
||||
}
|
||||
if config.Concurrency != nil {
|
||||
addField(content, "concurrency", toNode(*config.Concurrency), "")
|
||||
}
|
||||
@@ -173,8 +192,8 @@ func (config Config) MarshalYAML() (any, error) {
|
||||
if config.Duration != nil {
|
||||
addField(content, "duration", toNode(*config.Duration), "")
|
||||
}
|
||||
if config.Quiet != nil {
|
||||
addField(content, "quiet", toNode(*config.Quiet), "")
|
||||
if config.Progress != nil {
|
||||
addField(content, "progress", toNode(string(*config.Progress)), "")
|
||||
}
|
||||
if config.Output != nil {
|
||||
addField(content, "output", toNode(string(*config.Output)), "")
|
||||
@@ -221,6 +240,13 @@ func (config Config) MarshalYAML() (any, error) {
|
||||
addStringSlice(content, "values", config.Values, false)
|
||||
addStringSlice(content, "lua", config.Lua, 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
|
||||
}
|
||||
@@ -228,12 +254,12 @@ func (config Config) MarshalYAML() (any, error) {
|
||||
func (config Config) Print() bool {
|
||||
configYAML, err := yaml.Marshal(config)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render("Error marshaling config to yaml: "+err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render("Error marshaling config to yaml: "+err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Pipe mode: output raw content directly
|
||||
if !term.IsTerminal(os.Stdout.Fd()) {
|
||||
if !sarin.IsInteractiveTerminal(os.Stdout.Fd()) {
|
||||
fmt.Println(string(configYAML))
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -247,25 +273,23 @@ func (config Config) Print() bool {
|
||||
glamour.WithWordWrap(0),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
content, err := renderer.Render("```yaml\n" + string(configYAML) + "```")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(
|
||||
printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML},
|
||||
tea.WithAltScreen(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
m, err := p.Run()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -283,6 +307,9 @@ func (config *Config) Merge(newConfig *Config) {
|
||||
if newConfig.Timeout != nil {
|
||||
config.Timeout = newConfig.Timeout
|
||||
}
|
||||
if newConfig.MaxResponseBody != nil {
|
||||
config.MaxResponseBody = newConfig.MaxResponseBody
|
||||
}
|
||||
if newConfig.Concurrency != nil {
|
||||
config.Concurrency = newConfig.Concurrency
|
||||
}
|
||||
@@ -295,8 +322,8 @@ func (config *Config) Merge(newConfig *Config) {
|
||||
if newConfig.ShowConfig != nil {
|
||||
config.ShowConfig = newConfig.ShowConfig
|
||||
}
|
||||
if newConfig.Quiet != nil {
|
||||
config.Quiet = newConfig.Quiet
|
||||
if newConfig.Progress != nil {
|
||||
config.Progress = newConfig.Progress
|
||||
}
|
||||
if newConfig.Output != nil {
|
||||
config.Output = newConfig.Output
|
||||
@@ -331,6 +358,12 @@ func (config *Config) Merge(newConfig *Config) {
|
||||
if len(newConfig.Js) != 0 {
|
||||
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() {
|
||||
@@ -355,14 +388,17 @@ func (config *Config) SetDefaults() {
|
||||
if config.Timeout == nil {
|
||||
config.Timeout = &Defaults.RequestTimeout
|
||||
}
|
||||
if config.MaxResponseBody == nil {
|
||||
config.MaxResponseBody = &Defaults.MaxResponseBody
|
||||
}
|
||||
if config.Concurrency == nil {
|
||||
config.Concurrency = new(Defaults.Concurrency)
|
||||
}
|
||||
if config.ShowConfig == nil {
|
||||
config.ShowConfig = new(Defaults.ShowConfig)
|
||||
}
|
||||
if config.Quiet == nil {
|
||||
config.Quiet = new(Defaults.Quiet)
|
||||
if config.Progress == nil {
|
||||
config.Progress = new(Defaults.Progress)
|
||||
}
|
||||
if config.Insecure == nil {
|
||||
config.Insecure = new(Defaults.Insecure)
|
||||
@@ -377,6 +413,14 @@ func (config *Config) SetDefaults() {
|
||||
if config.Output == nil {
|
||||
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.
|
||||
@@ -422,12 +466,33 @@ func (config Config) Validate() error {
|
||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
|
||||
}
|
||||
|
||||
if config.MaxResponseBody != nil && *config.MaxResponseBody > types.ByteSizeLimit {
|
||||
validationErrors = append(validationErrors, types.NewFieldValidationError(
|
||||
"MaxResponseBody",
|
||||
types.FormatByteSize(*config.MaxResponseBody),
|
||||
errors.New("max response body must not exceed "+types.FormatByteSize(types.ByteSizeLimit)),
|
||||
))
|
||||
}
|
||||
|
||||
if config.ShowConfig == nil {
|
||||
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
|
||||
}
|
||||
|
||||
if config.Quiet == nil {
|
||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Quiet", "", errors.New("quiet field is required")))
|
||||
if config.Progress == nil {
|
||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Progress", "", errors.New("progress 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 {
|
||||
@@ -460,6 +525,36 @@ func (config Config) Validate() error {
|
||||
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 {
|
||||
if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
|
||||
validationErrors = append(
|
||||
@@ -538,11 +633,11 @@ func ReadAllConfigs() *Config {
|
||||
_ = utilsErr.MustHandle(err,
|
||||
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
|
||||
cliParser.PrintHelp()
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(
|
||||
"\nUnexpected CLI arguments provided: ",
|
||||
)+strings.Join(err.Args, ", "),
|
||||
)
|
||||
))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
@@ -557,28 +652,35 @@ func ReadAllConfigs() *Config {
|
||||
|
||||
for _, configFile := range append(envConfig.Files, cliConf.Files...) {
|
||||
fileConfig, err := parseConfigFile(configFile, 10)
|
||||
|
||||
// A nested configFile fails under its own name, not the one given on the command line.
|
||||
path := configFile.Path()
|
||||
if configFileErr, ok := errors.AsType[types.ConfigFileError](err); ok {
|
||||
path = configFileErr.Path
|
||||
}
|
||||
|
||||
_ = utilsErr.MustHandle(err,
|
||||
utilsErr.OnType(func(err types.ConfigFileReadError) error {
|
||||
cliParser.PrintHelp()
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(
|
||||
fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(),
|
||||
fmt.Sprintf("\nFailed to read config file (%s): ", path)+err.Error(),
|
||||
),
|
||||
)
|
||||
))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
utilsErr.OnType(func(err types.UnmarshalError) error {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(
|
||||
fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(),
|
||||
fmt.Sprintf("\nFailed to parse config file (%s): ", path)+err.Error(),
|
||||
),
|
||||
)
|
||||
))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
utilsErr.OnType(func(err types.FieldParseErrors) error {
|
||||
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", configFile.Path()), err.Errors...)
|
||||
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", path), err.Errors...)
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
@@ -594,7 +696,7 @@ func ReadAllConfigs() *Config {
|
||||
|
||||
// parseConfigFile recursively parses a config file and its nested files up to maxDepth levels.
|
||||
// Returns the merged configuration or an error if parsing fails.
|
||||
// It can return the following errors:
|
||||
// It can return the following errors, each wrapped in a types.ConfigFileError naming the file:
|
||||
// - types.ConfigFileReadError
|
||||
// - types.UnmarshalError
|
||||
// - types.FieldParseErrors
|
||||
@@ -602,7 +704,7 @@ func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error)
|
||||
configFileParser := NewConfigFileParser(configFile)
|
||||
fileConfig, err := configFileParser.Parse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, types.NewConfigFileError(configFile.Path(), err)
|
||||
}
|
||||
|
||||
if maxDepth <= 0 {
|
||||
@@ -640,7 +742,7 @@ func validateScriptSource(script string) error {
|
||||
return types.ErrScriptEmpty
|
||||
}
|
||||
|
||||
// Not a file/URL reference - it's an inline script
|
||||
// Not a file/URL reference, so it's an inline script
|
||||
if !strings.HasPrefix(script, "@") {
|
||||
return nil
|
||||
}
|
||||
@@ -650,7 +752,7 @@ func validateScriptSource(script string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// It's a file or URL reference - validate the source
|
||||
// It's a file or URL reference, so validate the source
|
||||
source := script[1:] // Remove the @ prefix
|
||||
|
||||
if source == "" {
|
||||
@@ -669,20 +771,20 @@ func validateScriptSource(script string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// It's a file path - basic validation (not empty, checked above)
|
||||
// It's a file path, already checked to be non-empty above
|
||||
return nil
|
||||
}
|
||||
|
||||
func printParseErrors(parserName string, errors ...types.FieldParseError) {
|
||||
for _, fieldErr := range errors {
|
||||
if fieldErr.Value == "" {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s' (%s): ", parserName, fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -723,7 +825,7 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
@@ -746,13 +848,22 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m printConfigModel) View() string {
|
||||
func (m printConfigModel) View() tea.View {
|
||||
// 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 {
|
||||
return "\n Initializing..."
|
||||
return newView("\n Initializing...")
|
||||
}
|
||||
|
||||
content := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbar())
|
||||
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView())
|
||||
return newView(fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView()))
|
||||
}
|
||||
|
||||
func (m *printConfigModel) saveContent() (printConfigModel, tea.Cmd) {
|
||||
@@ -772,12 +883,13 @@ func (m *printConfigModel) handleResize(msg tea.WindowSizeMsg) {
|
||||
width := msg.Width - scrollbarWidth
|
||||
|
||||
if !m.ready {
|
||||
m.viewport = viewport.New(width, height)
|
||||
m.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height))
|
||||
m.viewport.SetHorizontalStep(0)
|
||||
m.viewport.SetContent(m.contentWithLineNumbers())
|
||||
m.ready = true
|
||||
} else {
|
||||
m.viewport.Width = width
|
||||
m.viewport.Height = height
|
||||
m.viewport.SetWidth(width)
|
||||
m.viewport.SetHeight(height)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,12 +904,12 @@ func (m printConfigModel) headerView() string {
|
||||
printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit")
|
||||
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)
|
||||
}
|
||||
|
||||
func (m printConfigModel) footerView() string {
|
||||
return strings.Repeat("─", m.viewport.Width+scrollbarWidth)
|
||||
return strings.Repeat("─", m.viewport.Width()+scrollbarWidth)
|
||||
}
|
||||
|
||||
func (m printConfigModel) contentWithLineNumbers() string {
|
||||
@@ -819,7 +931,7 @@ func (m printConfigModel) contentWithLineNumbers() string {
|
||||
}
|
||||
|
||||
func (m printConfigModel) scrollbar() string {
|
||||
height := m.viewport.Height
|
||||
height := m.viewport.Height()
|
||||
trackHeight := height - scrollbarBottomSpace
|
||||
totalLines := m.viewport.TotalLineCount()
|
||||
|
||||
|
||||
+90
-78
@@ -49,74 +49,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
||||
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 != "" {
|
||||
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
|
||||
if err != nil {
|
||||
@@ -165,22 +97,58 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
|
||||
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
|
||||
if logLevel := parser.getEnv("LOG_LEVEL"); logLevel != "" {
|
||||
config.LogLevel = new(logLevel)
|
||||
}
|
||||
|
||||
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 {
|
||||
fieldParseErrors = append(
|
||||
fieldParseErrors,
|
||||
types.NewFieldParseError(
|
||||
parser.getFullEnvName("TIMEOUT"),
|
||||
timeout,
|
||||
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
|
||||
parser.getFullEnvName("DRY_RUN"),
|
||||
dryRun,
|
||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
config.Timeout = &timeoutParsed
|
||||
config.DryRun = &dryRunParsed
|
||||
}
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
config.Params.Parse(param)
|
||||
}
|
||||
@@ -193,10 +161,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
||||
config.Cookies.Parse(cookie)
|
||||
}
|
||||
|
||||
if body := parser.getEnv("BODY"); body != "" {
|
||||
config.Bodies = []string{body}
|
||||
}
|
||||
|
||||
if proxy := parser.getEnv("PROXY"); proxy != "" {
|
||||
err := config.Proxies.Parse(proxy)
|
||||
if err != nil {
|
||||
@@ -215,6 +179,54 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
||||
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 maxResponseBody := parser.getEnv("MAX_RESPONSE_BODY"); maxResponseBody != "" {
|
||||
maxResponseBodyParsed, err := types.ParseByteSize(maxResponseBody)
|
||||
if err != nil {
|
||||
fieldParseErrors = append(
|
||||
fieldParseErrors,
|
||||
types.NewFieldParseError(
|
||||
parser.getFullEnvName("MAX_RESPONSE_BODY"),
|
||||
maxResponseBody,
|
||||
err,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
config.MaxResponseBody = &maxResponseBodyParsed
|
||||
}
|
||||
}
|
||||
|
||||
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
||||
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 != "" {
|
||||
config.Lua = []string{lua}
|
||||
}
|
||||
|
||||
+282
-56
@@ -1,13 +1,17 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -79,10 +83,18 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
||||
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
// A declared size above the limit is refused before anything is read.
|
||||
if resp.ContentLength > types.RemoteFetchLimit {
|
||||
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||
if err != nil {
|
||||
return nil, types.NewHTTPFetchError(url, err)
|
||||
}
|
||||
if len(data) > types.RemoteFetchLimit {
|
||||
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
@@ -126,7 +138,7 @@ func (ss *stringOrSliceField) UnmarshalYAML(node *yaml.Node) error {
|
||||
*ss = slice
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("expected a string or a sequence of strings, but got %v", node.Kind)
|
||||
return fmt.Errorf("expected a string or a sequence of strings, but got %s", yamlNodeName(node))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +159,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
|
||||
// Handle sequence of maps: [{key1: value1}, {key2: value2}]
|
||||
for _, item := range node.Content {
|
||||
if item.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("expected a mapping in sequence, but got %v", item.Kind)
|
||||
return fmt.Errorf("expected a mapping in sequence, but got %s", yamlNodeName(item))
|
||||
}
|
||||
if err := kv.unmarshalMapping(item); err != nil {
|
||||
return err
|
||||
@@ -155,7 +167,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("expected a mapping or sequence of mappings, but got %v", node.Kind)
|
||||
return fmt.Errorf("expected a mapping or sequence of mappings, but got %s", yamlNodeName(node))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +178,7 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
||||
valueNode := node.Content[i+1]
|
||||
|
||||
if keyNode.Kind != yaml.ScalarNode {
|
||||
return fmt.Errorf("expected a string key, but got %v", keyNode.Kind)
|
||||
return fmt.Errorf("expected a string key, but got %s", yamlNodeName(keyNode))
|
||||
}
|
||||
|
||||
key := keyNode.Value
|
||||
@@ -178,12 +190,12 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
||||
case yaml.SequenceNode:
|
||||
for _, v := range valueNode.Content {
|
||||
if v.Kind != yaml.ScalarNode {
|
||||
return fmt.Errorf("expected string values in array for key %q, but got %v", key, v.Kind)
|
||||
return fmt.Errorf("expected string values in array for key %q, but got %s", key, yamlNodeName(v))
|
||||
}
|
||||
values = append(values, v.Value)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("expected a string or array of strings for key %q, but got %v", key, valueNode.Kind)
|
||||
return fmt.Errorf("expected a string or array of strings for key %q, but got %s", key, yamlNodeName(valueNode))
|
||||
}
|
||||
|
||||
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
|
||||
@@ -192,26 +204,29 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
||||
}
|
||||
|
||||
type configYAML struct {
|
||||
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
||||
Method stringOrSliceField `yaml:"method"`
|
||||
URL *string `yaml:"url"`
|
||||
Timeout *time.Duration `yaml:"timeout"`
|
||||
Concurrency *uint `yaml:"concurrency"`
|
||||
RequestCount *uint64 `yaml:"requests"`
|
||||
Duration *time.Duration `yaml:"duration"`
|
||||
Quiet *bool `yaml:"quiet"`
|
||||
Output *string `yaml:"output"`
|
||||
Insecure *bool `yaml:"insecure"`
|
||||
ShowConfig *bool `yaml:"showConfig"`
|
||||
DryRun *bool `yaml:"dryRun"`
|
||||
Params keyValuesField `yaml:"params"`
|
||||
Headers keyValuesField `yaml:"headers"`
|
||||
Cookies keyValuesField `yaml:"cookies"`
|
||||
Bodies stringOrSliceField `yaml:"body"`
|
||||
Proxies stringOrSliceField `yaml:"proxy"`
|
||||
Values stringOrSliceField `yaml:"values"`
|
||||
Lua stringOrSliceField `yaml:"lua"`
|
||||
Js stringOrSliceField `yaml:"js"`
|
||||
ShowConfig *bool `yaml:"showConfig"`
|
||||
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
||||
Concurrency *uint `yaml:"concurrency"`
|
||||
RequestCount *uint64 `yaml:"requests"`
|
||||
Duration *time.Duration `yaml:"duration"`
|
||||
LogLevel *string `yaml:"logLevel"`
|
||||
LogFile *string `yaml:"logFile"`
|
||||
Progress *string `yaml:"progress"`
|
||||
Output *string `yaml:"output"`
|
||||
DryRun *bool `yaml:"dryRun"`
|
||||
URL *string `yaml:"url"`
|
||||
Method stringOrSliceField `yaml:"method"`
|
||||
Bodies stringOrSliceField `yaml:"body"`
|
||||
Params keyValuesField `yaml:"params"`
|
||||
Headers keyValuesField `yaml:"headers"`
|
||||
Cookies keyValuesField `yaml:"cookies"`
|
||||
Proxies stringOrSliceField `yaml:"proxy"`
|
||||
Values stringOrSliceField `yaml:"values"`
|
||||
Timeout *time.Duration `yaml:"timeout"`
|
||||
MaxResponseBody *byteSize `yaml:"maxResponseBody"`
|
||||
Insecure *bool `yaml:"insecure"`
|
||||
Lua stringOrSliceField `yaml:"lua"`
|
||||
Js stringOrSliceField `yaml:"js"`
|
||||
}
|
||||
|
||||
// ParseYAML parses YAML config file arguments into a Config object.
|
||||
@@ -224,40 +239,31 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
||||
parsedData = &configYAML{}
|
||||
)
|
||||
|
||||
err := yaml.Unmarshal(data, &parsedData)
|
||||
if err != nil {
|
||||
return nil, types.NewUnmarshalError(err)
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
decoder.KnownFields(true)
|
||||
// The decoder returns a bare io.EOF for an empty file, errors.Is would also match a wrapped one.
|
||||
if err := decoder.Decode(parsedData); err != nil && err != io.EOF { //nolint:errorlint
|
||||
return nil, types.NewUnmarshalError(yamlError(err))
|
||||
}
|
||||
|
||||
// Decode reads one document, so anything after the first "---" would be dropped silently.
|
||||
for {
|
||||
var next yaml.Node
|
||||
err := decoder.Decode(&next)
|
||||
if err == io.EOF { //nolint:errorlint
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, types.NewUnmarshalError(yamlError(err))
|
||||
}
|
||||
if !isEmptyDocument(&next) {
|
||||
return nil, types.NewUnmarshalError(types.ErrYAMLMultipleDocuments)
|
||||
}
|
||||
}
|
||||
|
||||
var fieldParseErrors []types.FieldParseError
|
||||
|
||||
config.Methods = append(config.Methods, parsedData.Method...)
|
||||
config.Timeout = parsedData.Timeout
|
||||
config.Concurrency = parsedData.Concurrency
|
||||
config.Requests = parsedData.RequestCount
|
||||
config.Duration = parsedData.Duration
|
||||
config.ShowConfig = parsedData.ShowConfig
|
||||
config.Quiet = parsedData.Quiet
|
||||
|
||||
if parsedData.Output != nil {
|
||||
config.Output = new(ConfigOutputType(*parsedData.Output))
|
||||
}
|
||||
|
||||
config.Insecure = parsedData.Insecure
|
||||
config.DryRun = parsedData.DryRun
|
||||
for _, kv := range parsedData.Params {
|
||||
config.Params = append(config.Params, types.Param(kv))
|
||||
}
|
||||
for _, kv := range parsedData.Headers {
|
||||
config.Headers = append(config.Headers, types.Header(kv))
|
||||
}
|
||||
for _, kv := range parsedData.Cookies {
|
||||
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 {
|
||||
@@ -265,6 +271,22 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
config.Concurrency = parsedData.Concurrency
|
||||
config.Requests = parsedData.RequestCount
|
||||
config.Duration = parsedData.Duration
|
||||
config.LogLevel = parsedData.LogLevel
|
||||
config.LogFile = parsedData.LogFile
|
||||
|
||||
if parsedData.Progress != nil {
|
||||
config.Progress = new(ConfigProgressType(*parsedData.Progress))
|
||||
}
|
||||
|
||||
if parsedData.Output != nil {
|
||||
config.Output = new(ConfigOutputType(*parsedData.Output))
|
||||
}
|
||||
|
||||
config.DryRun = parsedData.DryRun
|
||||
|
||||
if parsedData.URL != nil {
|
||||
urlParsed, err := url.Parse(*parsedData.URL)
|
||||
if err != nil {
|
||||
@@ -274,6 +296,18 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
config.Methods = append(config.Methods, parsedData.Method...)
|
||||
config.Bodies = append(config.Bodies, parsedData.Bodies...)
|
||||
for _, kv := range parsedData.Params {
|
||||
config.Params = append(config.Params, types.Param(kv))
|
||||
}
|
||||
for _, kv := range parsedData.Headers {
|
||||
config.Headers = append(config.Headers, types.Header(kv))
|
||||
}
|
||||
for _, kv := range parsedData.Cookies {
|
||||
config.Cookies = append(config.Cookies, types.Cookie(kv))
|
||||
}
|
||||
|
||||
for i, proxy := range parsedData.Proxies {
|
||||
err := config.Proxies.Parse(proxy)
|
||||
if err != nil {
|
||||
@@ -284,9 +318,201 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
config.Values = append(config.Values, parsedData.Values...)
|
||||
config.Timeout = parsedData.Timeout
|
||||
if parsedData.MaxResponseBody != nil {
|
||||
config.MaxResponseBody = new(uint64(*parsedData.MaxResponseBody))
|
||||
}
|
||||
config.Insecure = parsedData.Insecure
|
||||
config.Lua = append(config.Lua, parsedData.Lua...)
|
||||
config.Js = append(config.Js, parsedData.Js...)
|
||||
|
||||
if len(fieldParseErrors) > 0 {
|
||||
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// configYAMLTypeName is the Go type name the yaml package puts in its messages.
|
||||
var configYAMLTypeName = reflect.TypeFor[configYAML]().String()
|
||||
|
||||
// isEmptyDocument reports whether a document holds nothing, which a trailing "---" produces.
|
||||
// A null tag alone is not enough, since a user can hang it on a mapping that does hold content.
|
||||
func isEmptyDocument(node *yaml.Node) bool {
|
||||
if len(node.Content) == 0 {
|
||||
return true
|
||||
}
|
||||
root := node.Content[0]
|
||||
return root.Kind == yaml.ScalarNode && root.Tag == "!!null"
|
||||
}
|
||||
|
||||
// yamlError rewrites yaml decode errors so they read like config file errors.
|
||||
// Errors from building the config arrive collected, everything earlier arrives on its own.
|
||||
func yamlError(err error) error {
|
||||
var loadErrors *yaml.LoadErrors
|
||||
if errors.As(err, &loadErrors) && len(loadErrors.Errors) > 0 {
|
||||
var builder strings.Builder
|
||||
for i, loadError := range loadErrors.Errors {
|
||||
if i > 0 {
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString(yamlErrorText(loadError))
|
||||
}
|
||||
return errors.New(builder.String())
|
||||
}
|
||||
|
||||
if loadError, ok := errors.AsType[*yaml.LoadError](err); ok {
|
||||
return errors.New(yamlErrorText(loadError))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// yamlErrorText renders one error as "line 3: while scanning a plain scalar: found a tab character".
|
||||
// The mark is where reading gave up and the context mark starts the construct that broke.
|
||||
func yamlErrorText(loadError *yaml.LoadError) string {
|
||||
var builder strings.Builder
|
||||
if loadError.Mark.Line > 0 {
|
||||
fmt.Fprintf(&builder, "line %d: ", loadError.Mark.Line)
|
||||
}
|
||||
if loadError.ContextMsg != "" {
|
||||
builder.WriteString(loadError.ContextMsg + ": ")
|
||||
}
|
||||
builder.WriteString(yamlErrorMessage(loadError.Message))
|
||||
|
||||
start := loadError.ContextMark
|
||||
if loadError.ContextMsg != "" && start.Line > 0 && start.Line != loadError.Mark.Line {
|
||||
fmt.Fprintf(&builder, " (started at line %d)", start.Line)
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// yamlTypeNames maps the Go types and YAML tags the yaml package prints to words a user knows.
|
||||
var yamlTypeNames = map[string]string{
|
||||
"!!str": "text",
|
||||
"!!int": "a number",
|
||||
"!!float": "a number",
|
||||
"!!bool": "true or false",
|
||||
"!!seq": "a list",
|
||||
"!!map": "a mapping",
|
||||
"!!null": "an empty value",
|
||||
"!!timestamp": "a date",
|
||||
"!!binary": "binary data",
|
||||
"string": "text",
|
||||
"bool": "true or false",
|
||||
"uint": "a whole number of 0 or more",
|
||||
"uint64": "a whole number of 0 or more",
|
||||
"time.Duration": "a duration such as 10s",
|
||||
configYAMLTypeName: "a mapping of config keys",
|
||||
}
|
||||
|
||||
// yamlNodeName names what a node holds, so an error can say "a list" instead of a kind number.
|
||||
// A tag that contradicts the shape is kept, since the shape alone would read as a contradiction.
|
||||
func yamlNodeName(node *yaml.Node) string {
|
||||
if name, ok := yamlTypeNames[node.Tag]; ok && yamlTagKind(node.Tag) == node.Kind {
|
||||
return name
|
||||
}
|
||||
if node.Tag == "" {
|
||||
return yamlKindName(node.Kind)
|
||||
}
|
||||
return yamlKindName(node.Kind) + " tagged " + node.Tag
|
||||
}
|
||||
|
||||
// yamlTagKind returns the node shape a tag belongs on, so a mismatch can be spotted.
|
||||
func yamlTagKind(tag string) yaml.Kind {
|
||||
switch tag {
|
||||
case "!!seq":
|
||||
return yaml.SequenceNode
|
||||
case "!!map":
|
||||
return yaml.MappingNode
|
||||
default:
|
||||
return yaml.ScalarNode
|
||||
}
|
||||
}
|
||||
|
||||
// yamlKindName names a node by its shape, for when its tag says nothing a user would know.
|
||||
func yamlKindName(kind yaml.Kind) string {
|
||||
switch kind {
|
||||
case yaml.SequenceNode:
|
||||
return "a list"
|
||||
case yaml.MappingNode:
|
||||
return "a mapping"
|
||||
case yaml.AliasNode:
|
||||
return "an alias"
|
||||
default:
|
||||
return "a value"
|
||||
}
|
||||
}
|
||||
|
||||
// yamlErrorMessage turns "field x not found in type config.configYAML" into "unknown key "x"".
|
||||
func yamlErrorMessage(message string) string {
|
||||
if field, ok := strings.CutSuffix(message, " not found in type "+configYAMLTypeName); ok {
|
||||
if name, ok := strings.CutPrefix(field, "field "); ok {
|
||||
return fmt.Sprintf("unknown key %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The user controls the value, so the rightmost separator is the only one that is ours.
|
||||
// After "as a" comes the tag the user wrote, after "into" comes the type the key accepts.
|
||||
if names, ok := strings.CutPrefix(message, "cannot construct "); ok {
|
||||
at, width, tagged := -1, 0, false
|
||||
for index, separator := range []string{" into ", " as a "} {
|
||||
if found := strings.LastIndex(names, separator); found > at {
|
||||
at, width, tagged = found, len(separator), index == 1
|
||||
}
|
||||
}
|
||||
if at >= 0 {
|
||||
got, want := yamlGotName(names[:at]), yamlWantedName(names[at+width:])
|
||||
if tagged {
|
||||
return fmt.Sprintf("%s is not %s", got, want)
|
||||
}
|
||||
return fmt.Sprintf("expected %s, but got %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(message, configYAMLTypeName, "the config file")
|
||||
}
|
||||
|
||||
// yamlWantedName names what a key accepts, which the yaml package prints as a Go type.
|
||||
func yamlWantedName(text string) string {
|
||||
if name, ok := yamlTypeNames[text]; ok {
|
||||
return name
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// yamlGotName names what the file held, which the yaml package prints as a tag and sometimes a value.
|
||||
// A tag the user would not know stays in the text, since without it the message reads as nonsense.
|
||||
func yamlGotName(text string) string {
|
||||
// A collection tag carries no backticks, so a scalar wearing one arrives glued to it.
|
||||
for _, tag := range []string{"!!seq", "!!map"} {
|
||||
if value, ok := strings.CutPrefix(text, tag); ok && value != "" {
|
||||
return yamlValueText(value) + " tagged " + tag
|
||||
}
|
||||
}
|
||||
|
||||
tag, value, _ := strings.Cut(text, " ")
|
||||
value = strings.TrimSuffix(strings.TrimPrefix(value, "`"), "`")
|
||||
|
||||
name, known := yamlTypeNames[tag]
|
||||
switch {
|
||||
case known && value != "":
|
||||
return yamlValueText(value)
|
||||
case known:
|
||||
return name
|
||||
case value != "":
|
||||
return yamlValueText(value) + " tagged " + tag
|
||||
default:
|
||||
return "a value tagged " + tag
|
||||
}
|
||||
}
|
||||
|
||||
// yamlValueText quotes a value, saying so when the yaml package shortened it to seven characters.
|
||||
func yamlValueText(value string) string {
|
||||
if start, ok := strings.CutSuffix(value, "..."); ok && len(start) == 7 {
|
||||
return "a value starting with " + strconv.Quote(start)
|
||||
}
|
||||
return strconv.Quote(value)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F
|
||||
|
||||
func ValidateTemplates(config *Config) []types.FieldValidationError {
|
||||
// Create template function map using the same functions as sarin package
|
||||
// Use nil for fileCache during validation - templates are only parsed, not executed
|
||||
// fileCache is nil because templates are only parsed here, not executed
|
||||
randSource := sarin.NewDefaultRandSource()
|
||||
funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
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)
|
||||
}
|
||||
+205
-96
@@ -5,26 +5,102 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
utilsSlice "go.aykhans.me/utils/slice"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
type HostClientGenerator func() *fasthttp.HostClient
|
||||
// clientIndexGenerator returns the index of the host client (and proxy) to use next.
|
||||
type clientIndexGenerator func() int
|
||||
|
||||
func safeUintToInt(u uint) int {
|
||||
if u > math.MaxInt {
|
||||
return math.MaxInt
|
||||
// preferIPv4Resolver hides a host's IPv6 addresses whenever it also has IPv4 ones.
|
||||
type preferIPv4Resolver struct{}
|
||||
|
||||
func (preferIPv4Resolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
return lookupPreferred(ctx, host, true)
|
||||
}
|
||||
|
||||
// preferIPv6Resolver is the mirror of preferIPv4Resolver, used for the fallback dial.
|
||||
type preferIPv6Resolver struct{}
|
||||
|
||||
func (preferIPv6Resolver) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
return lookupPreferred(ctx, host, false)
|
||||
}
|
||||
|
||||
// lookupPreferred resolves host and keeps only the wanted family, unless that family is absent.
|
||||
func lookupPreferred(ctx context.Context, host string, wantIPv4 bool) ([]net.IPAddr, error) {
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
preferred := make([]net.IPAddr, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
if (ip.IP.To4() != nil) == wantIPv4 {
|
||||
preferred = append(preferred, ip)
|
||||
}
|
||||
}
|
||||
if len(preferred) > 0 {
|
||||
return preferred, nil
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// preferIPv4DialTimeoutShare leaves half the timeout for the IPv6 retry.
|
||||
const preferIPv4DialTimeoutShare = 2
|
||||
|
||||
// preferIPv4Dialer dials over IPv4 and retries over IPv6 alone when that fails.
|
||||
type preferIPv4Dialer struct {
|
||||
ipv4 *fasthttp.TCPDialer
|
||||
ipv6 *fasthttp.TCPDialer
|
||||
}
|
||||
|
||||
// dialerConcurrency matches the limit on fasthttp own package-level dialer.
|
||||
const dialerConcurrency = 1000
|
||||
|
||||
// dialer is shared so all clients use one DNS cache.
|
||||
var dialer = &preferIPv4Dialer{
|
||||
ipv4: &fasthttp.TCPDialer{Resolver: preferIPv4Resolver{}, Concurrency: dialerConcurrency},
|
||||
ipv6: &fasthttp.TCPDialer{Resolver: preferIPv6Resolver{}, Concurrency: dialerConcurrency},
|
||||
}
|
||||
|
||||
// DialTimeout dials addr, which must carry a port.
|
||||
func (d *preferIPv4Dialer) DialTimeout(addr string, timeout time.Duration) (net.Conn, error) {
|
||||
// fasthttp passes 0 from its pooled dial path.
|
||||
if timeout <= 0 {
|
||||
timeout = fasthttp.DefaultDialTimeout
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
conn, err := d.ipv4.DialDualStackTimeout(addr, timeout/preferIPv4DialTimeoutShare)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, fallbackErr := d.ipv6.DialDualStackTimeout(addr, remaining)
|
||||
if fallbackErr != nil {
|
||||
// Report the IPv4 error, not the fallback one.
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// dialFunc fills in the default port, which fasthttp skips for a custom dial func.
|
||||
func (d *preferIPv4Dialer) dialFunc(isTLS bool) fasthttp.DialFuncWithTimeout {
|
||||
return func(addr string, timeout time.Duration) (net.Conn, error) {
|
||||
return d.DialTimeout(fasthttp.AddMissingPort(addr, isTLS), timeout)
|
||||
}
|
||||
return int(u)
|
||||
}
|
||||
|
||||
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
|
||||
@@ -38,6 +114,7 @@ func NewHostClients(
|
||||
maxConns uint,
|
||||
requestURL *url.URL,
|
||||
skipVerify bool,
|
||||
maxResponseBody uint64,
|
||||
) ([]*fasthttp.HostClient, error) {
|
||||
isTLS := requestURL.Scheme == "https"
|
||||
|
||||
@@ -66,6 +143,9 @@ func NewHostClients(
|
||||
MaxConnDuration: timeout,
|
||||
WriteTimeout: timeout,
|
||||
ReadTimeout: timeout,
|
||||
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||
MaxIdemponentCallAttempts: 1,
|
||||
ReadBufferSize: 64 << 10, // 64 KiB
|
||||
DisableHeaderNamesNormalizing: true,
|
||||
DisablePathNormalizing: true,
|
||||
NoDefaultUserAgentHeader: true,
|
||||
@@ -77,8 +157,9 @@ func NewHostClients(
|
||||
}
|
||||
|
||||
client := &fasthttp.HostClient{
|
||||
MaxConns: safeUintToInt(maxConns),
|
||||
IsTLS: isTLS,
|
||||
MaxConns: safeUintToInt(maxConns),
|
||||
IsTLS: isTLS,
|
||||
DialTimeout: dialer.dialFunc(isTLS),
|
||||
TLSConfig: &tls.Config{
|
||||
InsecureSkipVerify: skipVerify, //nolint:gosec
|
||||
},
|
||||
@@ -87,6 +168,9 @@ func NewHostClients(
|
||||
MaxConnDuration: timeout,
|
||||
WriteTimeout: timeout,
|
||||
ReadTimeout: timeout,
|
||||
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||
MaxIdemponentCallAttempts: 1,
|
||||
ReadBufferSize: 64 << 10, // 64 KiB
|
||||
DisableHeaderNamesNormalizing: true,
|
||||
DisablePathNormalizing: true,
|
||||
NoDefaultUserAgentHeader: true,
|
||||
@@ -94,40 +178,45 @@ func NewHostClients(
|
||||
return []*fasthttp.HostClient{client}, nil
|
||||
}
|
||||
|
||||
// NewProxyDialFunc creates a dial function for the given proxy URL.
|
||||
// NewProxyDialFunc creates a dial function for the given proxy URL with a fixed timeout.
|
||||
// It can return the following errors:
|
||||
// - types.ProxyUnsupportedSchemeError
|
||||
// - types.ErrProxyNoContextDialer
|
||||
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
||||
var (
|
||||
dialer fasthttp.DialFunc
|
||||
err error
|
||||
)
|
||||
dial, err := NewProxyDialFuncWithTimeout(ctx, proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func(addr string) (net.Conn, error) {
|
||||
return dial(addr, timeout)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewProxyDialFuncWithTimeout creates a dial function for the given proxy URL that takes the timeout per call.
|
||||
// It can return the following errors:
|
||||
// - types.ProxyUnsupportedSchemeError
|
||||
// - types.ErrProxyNoContextDialer
|
||||
func NewProxyDialFuncWithTimeout(ctx context.Context, proxyURL *url.URL) (fasthttp.DialFuncWithTimeout, error) {
|
||||
switch proxyURL.Scheme {
|
||||
case "socks5":
|
||||
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fasthttpSocksDialer(ctx, proxyURL, true)
|
||||
case "socks5h":
|
||||
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fasthttpSocksDialer(ctx, proxyURL, false)
|
||||
case "http":
|
||||
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxyURL.String(), timeout)
|
||||
return fasthttpConnectDialer(proxyURL, false), nil
|
||||
case "https":
|
||||
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
|
||||
return fasthttpConnectDialer(proxyURL, true), nil
|
||||
default:
|
||||
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
|
||||
}
|
||||
|
||||
return dialer, nil
|
||||
}
|
||||
|
||||
// The returned dial function can return the following errors:
|
||||
// fasthttpSocksDialer creates a SOCKS5 dial function that takes the timeout per call.
|
||||
// It can return the following errors:
|
||||
// - types.ErrProxyNoContextDialer
|
||||
// - types.ProxyDialError
|
||||
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
|
||||
func fasthttpSocksDialer(ctx context.Context, proxyURL *url.URL, resolveLocally bool) (fasthttp.DialFuncWithTimeout, error) {
|
||||
netDialer := &net.Dialer{}
|
||||
|
||||
// Parse auth from proxy URL if present
|
||||
@@ -149,66 +238,92 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
||||
|
||||
proxyStr := proxyURL.String()
|
||||
|
||||
// Assert to ContextDialer for timeout support
|
||||
// Timeouts need DialContext, which proxy.SOCKS5 always provides with a net.Dialer.
|
||||
contextDialer, ok := socksDialer.(proxy.ContextDialer)
|
||||
if !ok {
|
||||
// Fallback without timeout (should not happen with net.Dialer)
|
||||
return func(addr string) (net.Conn, error) {
|
||||
conn, err := socksDialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
return conn, nil
|
||||
}, nil
|
||||
return nil, types.ErrProxyNoContextDialer
|
||||
}
|
||||
|
||||
// Return dial function that uses context with timeout
|
||||
return func(addr string) (net.Conn, error) {
|
||||
return func(addr string, timeout time.Duration) (net.Conn, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
addrs := []string{addr}
|
||||
if resolveLocally {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
var err error
|
||||
addrs, err = resolveIPv4First(ctx, addr, timeout)
|
||||
if err != nil {
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
}
|
||||
|
||||
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
|
||||
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
||||
dnsCancel()
|
||||
if err != nil {
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, types.NewProxyDialError(proxyStr, types.NewProxyResolveError(host))
|
||||
// Try every resolved address, so one unreachable family does not fail the dial.
|
||||
var lastErr error
|
||||
for _, candidate := range addrs {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
// Use the first resolved IP
|
||||
addr = net.JoinHostPort(ips[0].String(), port)
|
||||
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
||||
conn, err := contextDialer.DialContext(dialCtx, "tcp", candidate)
|
||||
dialCancel()
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
// Use remaining time for dial
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
||||
defer dialCancel()
|
||||
|
||||
conn, err := contextDialer.DialContext(dialCtx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
return conn, nil
|
||||
return nil, types.NewProxyDialError(proxyStr, lastErr)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveIPv4First resolves the host of addr into candidate addresses, IPv4 ones first.
|
||||
// It can return the following errors:
|
||||
// - types.ProxyResolveError
|
||||
func resolveIPv4First(ctx context.Context, addr string, timeout time.Duration) ([]string, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
|
||||
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
||||
dnsCancel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, types.NewProxyResolveError(host)
|
||||
}
|
||||
|
||||
addrs := make([]string, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
if ip.To4() != nil {
|
||||
addrs = append(addrs, net.JoinHostPort(ip.String(), port))
|
||||
}
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if ip.To4() == nil {
|
||||
addrs = append(addrs, net.JoinHostPort(ip.String(), port))
|
||||
}
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// fasthttpConnectDialer creates a dial function that tunnels through an HTTP proxy with CONNECT,
|
||||
// reaching the proxy over TLS when useTLS is set.
|
||||
//
|
||||
// The returned dial function can return the following errors:
|
||||
// - types.ProxyDialError
|
||||
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
|
||||
func fasthttpConnectDialer(proxyURL *url.URL, useTLS bool) fasthttp.DialFuncWithTimeout {
|
||||
defaultPort := "80"
|
||||
if useTLS {
|
||||
defaultPort = "443"
|
||||
}
|
||||
|
||||
proxyAddr := proxyURL.Host
|
||||
if proxyURL.Port() == "" {
|
||||
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), "443")
|
||||
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), defaultPort)
|
||||
}
|
||||
|
||||
// Build Proxy-Authorization header if auth is present
|
||||
@@ -222,10 +337,10 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
||||
|
||||
proxyStr := proxyURL.String()
|
||||
|
||||
return func(addr string) (net.Conn, error) {
|
||||
return func(addr string, timeout time.Duration) (net.Conn, error) {
|
||||
// Establish TCP connection to proxy with timeout
|
||||
start := time.Now()
|
||||
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
||||
conn, err := dialer.DialTimeout(proxyAddr, timeout)
|
||||
if err != nil {
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
@@ -242,13 +357,15 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
|
||||
// Upgrade to TLS
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
ServerName: proxyURL.Hostname(),
|
||||
})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
if useTLS {
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
ServerName: proxyURL.Hostname(),
|
||||
})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
|
||||
// Build and send CONNECT request
|
||||
@@ -262,35 +379,35 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
||||
connectReq.Header.Set("Proxy-Authorization", proxyAuth)
|
||||
}
|
||||
|
||||
if err := connectReq.Write(tlsConn); err != nil {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
if err := connectReq.Write(conn); err != nil {
|
||||
conn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
|
||||
// Read response using buffered reader, but return wrapped connection
|
||||
// to preserve any buffered data
|
||||
bufReader := bufio.NewReader(tlsConn)
|
||||
bufReader := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(bufReader, connectReq)
|
||||
if err != nil {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
conn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
resp.Body.Close() //nolint:errcheck,gosec
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
conn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
|
||||
}
|
||||
|
||||
// Clear deadline for the tunneled connection
|
||||
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
|
||||
tlsConn.Close() //nolint:errcheck,gosec
|
||||
if err := conn.SetDeadline(time.Time{}); err != nil {
|
||||
conn.Close() //nolint:errcheck,gosec
|
||||
return nil, types.NewProxyDialError(proxyStr, err)
|
||||
}
|
||||
|
||||
// Return wrapped connection that uses the buffered reader
|
||||
// to avoid losing any data that was read ahead
|
||||
return &bufferedConn{Conn: tlsConn, reader: bufReader}, nil
|
||||
return &bufferedConn{Conn: conn, reader: bufReader}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,18 +423,10 @@ func (c *bufferedConn) Read(b []byte) (int, error) {
|
||||
return c.reader.Read(b)
|
||||
}
|
||||
|
||||
func NewHostClientGenerator(clients ...*fasthttp.HostClient) HostClientGenerator {
|
||||
switch len(clients) {
|
||||
case 0:
|
||||
hostClient := &fasthttp.HostClient{}
|
||||
return func() *fasthttp.HostClient {
|
||||
return hostClient
|
||||
}
|
||||
case 1:
|
||||
return func() *fasthttp.HostClient {
|
||||
return clients[0]
|
||||
}
|
||||
default:
|
||||
return utilsSlice.RandomCycle(nil, clients...)
|
||||
func newClientIndexGenerator(count int) clientIndexGenerator {
|
||||
indexes := make([]int, count)
|
||||
for i := range indexes {
|
||||
indexes[i] = i
|
||||
}
|
||||
return utilsSlice.RandomCycle(nil, indexes...)
|
||||
}
|
||||
@@ -58,8 +58,7 @@ func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
|
||||
|
||||
file := &CachedFile{Content: content, Filename: filename}
|
||||
|
||||
// LoadOrStore handles race condition - if another goroutine
|
||||
// cached it first, we get theirs (no duplicate storage)
|
||||
// If another goroutine cached it first, LoadOrStore returns theirs
|
||||
actual, _ := fc.cache.LoadOrStore(source, file)
|
||||
return actual.(*CachedFile), nil
|
||||
}
|
||||
@@ -94,10 +93,18 @@ func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
|
||||
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(resp.Body)
|
||||
// A declared size above the limit is refused before anything is read.
|
||||
if resp.ContentLength > types.RemoteFetchLimit {
|
||||
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||
if err != nil {
|
||||
return nil, "", types.NewHTTPFetchError(url, err)
|
||||
}
|
||||
if len(content) > types.RemoteFetchLimit {
|
||||
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
// Extract filename from URL path
|
||||
filename := path.Base(url)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
@@ -12,3 +13,24 @@ func NewDefaultRandSource() rand.Source {
|
||||
uint64(now>>32),
|
||||
)
|
||||
}
|
||||
|
||||
func firstOrEmpty(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func safeUintToInt(u uint) int {
|
||||
if u > math.MaxInt {
|
||||
return math.MaxInt
|
||||
}
|
||||
return int(u)
|
||||
}
|
||||
|
||||
func safeUint64ToInt(u uint64) int {
|
||||
if u > math.MaxInt {
|
||||
return math.MaxInt
|
||||
}
|
||||
return int(u)
|
||||
}
|
||||
+94
-37
@@ -56,9 +56,9 @@ func NewRequestGenerator(
|
||||
|
||||
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)
|
||||
paramsGenerator, isParamsGeneratorDynamic, paramKeysAreStatic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
|
||||
headersGenerator, isHeadersGeneratorDynamic, headerKeysAreStatic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
|
||||
cookiesGenerator, isCookiesGeneratorDynamic, cookieKeysAreStatic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
|
||||
|
||||
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
||||
var bodyTemplateRoot *template.Template
|
||||
@@ -83,13 +83,26 @@ func NewRequestGenerator(
|
||||
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 (
|
||||
data valuesData
|
||||
path string
|
||||
err error
|
||||
)
|
||||
return func(req *fasthttp.Request) error {
|
||||
resetRequestData(reqData)
|
||||
resetStringSliceMap(reqData.Headers, reuseHeaderSlices)
|
||||
resetStringSliceMap(reqData.Params, reuseParamSlices)
|
||||
resetStringSliceMap(reqData.Cookies, reuseCookieSlices)
|
||||
reqData.Method = ""
|
||||
reqData.Path = ""
|
||||
reqData.Body = ""
|
||||
|
||||
data, err = valuesGenerator()
|
||||
if err != nil {
|
||||
@@ -143,20 +156,24 @@ func NewRequestGenerator(
|
||||
hasScripts
|
||||
}
|
||||
|
||||
func resetRequestData(reqData *script.RequestData) {
|
||||
reqData.Method = ""
|
||||
reqData.Path = ""
|
||||
reqData.Body = ""
|
||||
clear(reqData.Headers)
|
||||
clear(reqData.Params)
|
||||
clear(reqData.Cookies)
|
||||
// resetStringSliceMap empties m for the next render. With reuse it truncates the
|
||||
// value slices in place so the following appends land in the existing backing
|
||||
// arrays, without it the map is cleared so no stale key can survive.
|
||||
func resetStringSliceMap(m map[string][]string, reuse bool) {
|
||||
if !reuse {
|
||||
clear(m)
|
||||
return
|
||||
}
|
||||
for k, v := range m {
|
||||
m[k] = v[:0]
|
||||
}
|
||||
}
|
||||
|
||||
func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) {
|
||||
req.Header.SetHost(host)
|
||||
req.SetRequestURI(reqData.Path)
|
||||
req.Header.SetMethod(reqData.Method)
|
||||
req.SetBody([]byte(reqData.Body))
|
||||
req.SetBodyString(reqData.Body)
|
||||
|
||||
for k, values := range reqData.Headers {
|
||||
for _, v := range values {
|
||||
@@ -164,20 +181,17 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
|
||||
}
|
||||
}
|
||||
|
||||
for k, values := range reqData.Params {
|
||||
for _, v := range values {
|
||||
req.URI().QueryArgs().Add(k, v)
|
||||
if len(reqData.Params) > 0 {
|
||||
args := req.URI().QueryArgs()
|
||||
for k, values := range reqData.Params {
|
||||
for _, v := range values {
|
||||
args.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(reqData.Cookies) > 0 {
|
||||
cookieStrings := make([]string, 0, len(reqData.Cookies))
|
||||
for k, values := range reqData.Cookies {
|
||||
for _, v := range values {
|
||||
cookieStrings = append(cookieStrings, k+"="+v)
|
||||
}
|
||||
}
|
||||
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
|
||||
req.Header.Add("Cookie", cookieHeaderValue(reqData.Cookies))
|
||||
}
|
||||
|
||||
if scheme == "https" {
|
||||
@@ -185,6 +199,22 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
|
||||
}
|
||||
}
|
||||
|
||||
// cookieHeaderValue joins cookies into a single Cookie header value ("a=1; b=2").
|
||||
func cookieHeaderValue(cookies map[string][]string) string {
|
||||
var sb strings.Builder
|
||||
for k, values := range cookies {
|
||||
for _, v := range values {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("; ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(v)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot)
|
||||
|
||||
@@ -221,8 +251,8 @@ func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func()
|
||||
}, isDynamic
|
||||
}
|
||||
|
||||
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||
generators, isDynamic := buildKeyValueGenerators(localRand, params, lazyRoot)
|
||||
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, params, lazyRoot)
|
||||
|
||||
var (
|
||||
key, value string
|
||||
@@ -243,11 +273,11 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot
|
||||
reqData.Params[key] = append(reqData.Params[key], value)
|
||||
}
|
||||
return nil
|
||||
}, isDynamic
|
||||
}, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||
generators, isDynamic := buildKeyValueGenerators(localRand, headers, lazyRoot)
|
||||
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, headers, lazyRoot)
|
||||
|
||||
var (
|
||||
key, value string
|
||||
@@ -268,11 +298,11 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRo
|
||||
reqData.Headers[key] = append(reqData.Headers[key], value)
|
||||
}
|
||||
return nil
|
||||
}, isDynamic
|
||||
}, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
|
||||
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
|
||||
|
||||
var (
|
||||
key, value string
|
||||
@@ -293,14 +323,26 @@ func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRo
|
||||
reqData.Cookies[key] = append(reqData.Cookies[key], value)
|
||||
}
|
||||
return nil
|
||||
}, isDynamic
|
||||
}, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
isDynamic := false
|
||||
for i, v := range values {
|
||||
generators[i], _ = createTemplateFunc(v, lazyRoot)
|
||||
var valueIsDynamic bool
|
||||
generators[i], valueIsDynamic = createTemplateFunc(v, lazyRoot)
|
||||
if valueIsDynamic {
|
||||
isDynamic = true
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -308,7 +350,7 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
|
||||
data map[string]string
|
||||
err error
|
||||
)
|
||||
return func() (valuesData, error) {
|
||||
generate := func() (valuesData, error) {
|
||||
result := make(map[string]string)
|
||||
for _, generator := range generators {
|
||||
rendered, err = generator(nil)
|
||||
@@ -326,6 +368,15 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
|
||||
|
||||
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) {
|
||||
@@ -335,15 +386,19 @@ func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(
|
||||
|
||||
tmpl, err := lazyRoot().New("").Parse(value)
|
||||
if err == nil && hasTemplateActions(tmpl) {
|
||||
var err error
|
||||
var (
|
||||
buf bytes.Buffer
|
||||
err error
|
||||
)
|
||||
return func(data any) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
buf.Reset()
|
||||
if err = tmpl.Execute(&buf, data); err != nil {
|
||||
return "", types.NewTemplateRenderError(err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}, true
|
||||
}
|
||||
|
||||
return func(_ any) (string, error) { return value, nil }, false
|
||||
}
|
||||
|
||||
@@ -360,8 +415,9 @@ func buildKeyValueGenerators[T keyValueItem](
|
||||
localRand *rand.Rand,
|
||||
items []T,
|
||||
lazyRoot func() *template.Template,
|
||||
) ([]keyValueGenerator, bool) {
|
||||
) ([]keyValueGenerator, bool, bool) {
|
||||
isDynamic := false
|
||||
keysAreStatic := true
|
||||
generators := make([]keyValueGenerator, len(items))
|
||||
|
||||
for generatorIndex, item := range items {
|
||||
@@ -372,6 +428,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
||||
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot)
|
||||
if keyIsDynamic {
|
||||
isDynamic = true
|
||||
keysAreStatic = false
|
||||
}
|
||||
|
||||
// Generate value functions
|
||||
@@ -394,7 +451,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
||||
}
|
||||
}
|
||||
|
||||
return generators, isDynamic
|
||||
return generators, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
func buildStringSliceGenerator(
|
||||
|
||||
@@ -2,7 +2,6 @@ package sarin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"slices"
|
||||
@@ -10,14 +9,22 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
const DefaultResponseDurationAccuracy uint32 = 1
|
||||
const DefaultResponseColumnMaxWidth = 50
|
||||
|
||||
// maxResponseKeys bounds the distinct response keys, so error messages carrying
|
||||
// per-request text cannot grow the map without limit. It stays above the number of
|
||||
// HTTP status codes, so real ones are never merged.
|
||||
const maxResponseKeys = 1000
|
||||
|
||||
// otherResponseKey collects everything that arrives after the key limit is reached.
|
||||
const otherResponseKey = "other"
|
||||
|
||||
// Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings.
|
||||
type Duration time.Duration
|
||||
|
||||
@@ -96,6 +103,11 @@ func (data *SarinResponseData) Add(responseKey string, responseTime time.Duratio
|
||||
defer data.Unlock()
|
||||
|
||||
response, ok := data.Responses[responseKey]
|
||||
if !ok && len(data.Responses) >= maxResponseKeys {
|
||||
responseKey = otherResponseKey
|
||||
response, ok = data.Responses[responseKey]
|
||||
}
|
||||
|
||||
if !ok {
|
||||
data.Responses[responseKey] = &Response{
|
||||
durations: map[time.Duration]uint64{
|
||||
@@ -159,7 +171,7 @@ func (data *SarinResponseData) PrintTable() {
|
||||
return cellStyle
|
||||
})
|
||||
|
||||
fmt.Println(tbl)
|
||||
lipgloss.Println(tbl)
|
||||
}
|
||||
|
||||
func (data *SarinResponseData) PrintJSON() {
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
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
|
||||
|
||||
// jobsCtx ends the run, either when the duration expires or on Ctrl+C.
|
||||
jobsCtx context.Context //nolint:containedctx
|
||||
jobsCancel context.CancelFunc
|
||||
|
||||
hostClients []*fasthttp.HostClient
|
||||
scriptHTTPClients []*scriptHTTPClient
|
||||
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,
|
||||
maxResponseBody uint64,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
proxyURLs := make([]url.URL, len(proxies))
|
||||
for i, proxy := range proxies {
|
||||
proxyURLs[i] = url.URL(proxy)
|
||||
}
|
||||
|
||||
hostClients, err := NewHostClients(ctx, timeout, proxyURLs, workers, requestURL, skipCertVerify, maxResponseBody)
|
||||
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)
|
||||
|
||||
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
||||
|
||||
var scriptHTTPClients []*scriptHTTPClient
|
||||
if !scriptChain.IsEmpty() {
|
||||
scriptHTTPClients, err = newScriptHTTPClients(jobsCtx, proxyURLs, workers, maxResponseBody)
|
||||
if err != nil {
|
||||
jobsCancel()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
srn := &sarin{
|
||||
jobsCtx: jobsCtx,
|
||||
jobsCancel: jobsCancel,
|
||||
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,
|
||||
scriptHTTPClients: scriptHTTPClients,
|
||||
fileCache: NewFileCache(time.Second * 30),
|
||||
scriptChain: scriptChain,
|
||||
}
|
||||
|
||||
if collectStats {
|
||||
srn.responses = NewSarinResponseData(uint32(100))
|
||||
}
|
||||
|
||||
return srn, nil
|
||||
}
|
||||
|
||||
func (s sarin) GetResponses() *SarinResponseData {
|
||||
return s.responses
|
||||
}
|
||||
|
||||
func (s sarin) Start(stopCtrl *StopController) {
|
||||
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, &counter, sendLog, sendRespLog)
|
||||
|
||||
if runTUI {
|
||||
go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
|
||||
}
|
||||
|
||||
// Setup duration-based cancellation
|
||||
s.setupDurationTimeout()
|
||||
// Distribute jobs to workers.
|
||||
// This blocks until all jobs are sent or the context is canceled.
|
||||
s.sendJobs(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), while 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
|
||||
}
|
||||
|
||||
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
|
||||
for range max(s.workers, 1) {
|
||||
wg.Go(func() {
|
||||
s.Worker(jobs, counter, sendLog, sendRespLog)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) setupDurationTimeout() {
|
||||
if s.totalDuration != nil {
|
||||
go func() {
|
||||
timer := time.NewTimer(*s.totalDuration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
s.jobsCancel()
|
||||
case <-s.jobsCtx.Done():
|
||||
// Context cancelled, cleanup
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) sendJobs(jobs chan<- struct{}) {
|
||||
if s.totalRequests != nil && *s.totalRequests > 0 {
|
||||
for range *s.totalRequests {
|
||||
if s.jobsCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
jobs <- struct{}{}
|
||||
}
|
||||
} else {
|
||||
for s.jobsCtx.Err() == nil {
|
||||
jobs <- struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,816 +0,0 @@
|
||||
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{}{}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"compress/zlib"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"go.aykhans.me/sarin/internal/script"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
)
|
||||
|
||||
// scriptHTTPDefaultTimeout is the default timeout for script requests.
|
||||
const scriptHTTPDefaultTimeout = 10 * time.Second
|
||||
|
||||
// scriptHTTPClient sends scripts' http.* requests. It is safe for concurrent use.
|
||||
type scriptHTTPClient struct {
|
||||
ctx context.Context //nolint:containedctx
|
||||
client *fasthttp.Client
|
||||
insecureClient *fasthttp.Client
|
||||
defaultTimeout time.Duration
|
||||
maxResponseBody int
|
||||
}
|
||||
|
||||
var _ script.HTTPDoer = (*scriptHTTPClient)(nil)
|
||||
|
||||
// newScriptHTTPClients creates one client per proxy, in the same order as NewHostClients.
|
||||
// It can return the following errors:
|
||||
// - types.ProxyDialError
|
||||
func newScriptHTTPClients(ctx context.Context, proxies []url.URL, maxConns uint, maxResponseBody uint64) ([]*scriptHTTPClient, error) {
|
||||
if len(proxies) == 0 {
|
||||
return []*scriptHTTPClient{newScriptHTTPClient(ctx, dialer.DialTimeout, scriptHTTPDefaultTimeout, maxConns, maxResponseBody)}, nil
|
||||
}
|
||||
|
||||
clients := make([]*scriptHTTPClient, 0, len(proxies))
|
||||
for _, proxy := range proxies {
|
||||
dial, err := NewProxyDialFuncWithTimeout(ctx, &proxy)
|
||||
if err != nil {
|
||||
return nil, types.NewProxyDialError(proxy.String(), err)
|
||||
}
|
||||
clients = append(clients, newScriptHTTPClient(ctx, dial, scriptHTTPDefaultTimeout, maxConns, maxResponseBody))
|
||||
}
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
func newScriptHTTPClient(
|
||||
ctx context.Context,
|
||||
dial fasthttp.DialFuncWithTimeout,
|
||||
defaultTimeout time.Duration,
|
||||
maxConns uint,
|
||||
maxResponseBody uint64,
|
||||
) *scriptHTTPClient {
|
||||
newClient := func(insecure bool) *fasthttp.Client {
|
||||
// Read and write timeouts stay unset because they would cap the per-call timeout,
|
||||
// so dialWithDeadline is what bounds the TLS handshake.
|
||||
return &fasthttp.Client{
|
||||
DialTimeout: dialWithDeadline(dial),
|
||||
MaxConnsPerHost: safeUintToInt(maxConns),
|
||||
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||
MaxIdemponentCallAttempts: 1,
|
||||
ReadBufferSize: 64 << 10, // 64 KiB
|
||||
TLSConfig: &tls.Config{
|
||||
InsecureSkipVerify: insecure, //nolint:gosec
|
||||
},
|
||||
DisableHeaderNamesNormalizing: true,
|
||||
DisablePathNormalizing: true,
|
||||
NoDefaultUserAgentHeader: true,
|
||||
}
|
||||
}
|
||||
|
||||
return &scriptHTTPClient{
|
||||
ctx: ctx,
|
||||
client: newClient(false),
|
||||
insecureClient: newClient(true),
|
||||
defaultTimeout: defaultTimeout,
|
||||
maxResponseBody: safeUint64ToInt(maxResponseBody),
|
||||
}
|
||||
}
|
||||
|
||||
// dialWithDeadline dials within the request timeout and keeps it as the connection deadline,
|
||||
// so the TLS handshake fasthttp runs on the first write cannot outlive the request.
|
||||
func dialWithDeadline(dial fasthttp.DialFuncWithTimeout) fasthttp.DialFuncWithTimeout {
|
||||
return func(addr string, timeout time.Duration) (net.Conn, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = scriptHTTPDefaultTimeout
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
conn, err := dial(addr, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := conn.SetDeadline(deadline); err != nil {
|
||||
conn.Close() //nolint:errcheck,gosec
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Do sends the request. With maxRedirects the timeout applies to each hop.
|
||||
// It can return the following errors:
|
||||
// - types.ScriptHTTPRequestError
|
||||
func (c *scriptHTTPClient) Do(r *script.HTTPRequest) (*script.HTTPResponse, error) {
|
||||
if err := c.ctx.Err(); err != nil {
|
||||
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(r.URL)
|
||||
if err != nil {
|
||||
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
|
||||
}
|
||||
if (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
|
||||
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, types.ErrScriptHTTPURLInvalid)
|
||||
}
|
||||
|
||||
req := fasthttp.AcquireRequest()
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
req.SetRequestURI(r.URL)
|
||||
req.Header.SetMethod(r.Method)
|
||||
for key, values := range r.Headers {
|
||||
for _, value := range values {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.Params) > 0 {
|
||||
args := req.URI().QueryArgs()
|
||||
for key, values := range r.Params {
|
||||
for _, value := range values {
|
||||
args.Add(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.Cookies) > 0 {
|
||||
req.Header.Add("Cookie", cookieHeaderValue(r.Cookies))
|
||||
}
|
||||
|
||||
req.SetBodyString(r.Body)
|
||||
|
||||
timeout := r.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = c.defaultTimeout
|
||||
}
|
||||
|
||||
client := c.client
|
||||
if r.Insecure {
|
||||
client = c.insecureClient
|
||||
}
|
||||
|
||||
req.SetTimeout(timeout)
|
||||
if r.MaxRedirects > 0 {
|
||||
err = client.DoRedirects(req, resp, r.MaxRedirects)
|
||||
} else {
|
||||
err = client.Do(req, resp)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
|
||||
err = types.ErrResponseBodyTooLarge
|
||||
}
|
||||
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
|
||||
}
|
||||
|
||||
body, err := decodeResponseBody(resp, c.maxResponseBody)
|
||||
if err != nil {
|
||||
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
|
||||
}
|
||||
|
||||
return &script.HTTPResponse{
|
||||
Status: resp.StatusCode(),
|
||||
Headers: collectRespHeaders(resp),
|
||||
Body: string(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decodeResponseBody decodes a body the server compressed, since scripts read it as text.
|
||||
// The limit covers the decoded size, which is what a compression bomb inflates to.
|
||||
// An empty body is left alone because the decoders reject zero bytes, and a HEAD or a 304
|
||||
// carries the encoding with no body. A coding fasthttp does not decode, and the bare
|
||||
// deflate stream some servers send without a zlib header, are left raw for the script.
|
||||
func decodeResponseBody(resp *fasthttp.Response, maxResponseBody int) ([]byte, error) {
|
||||
if len(resp.Header.ContentEncoding()) == 0 || len(resp.Body()) == 0 {
|
||||
return resp.Body(), nil
|
||||
}
|
||||
|
||||
decoded, err := resp.BodyUncompressedWithLimit(maxResponseBody)
|
||||
if errors.Is(err, fasthttp.ErrContentEncodingUnsupported) || errors.Is(err, zlib.ErrHeader) {
|
||||
return resp.Body(), nil
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
|
||||
err = types.ErrResponseBodyTooLarge
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Neither header describes the decoded body, so both go, as net/http does on a gunzip.
|
||||
resp.Header.SetContentEncoding("")
|
||||
resp.Header.Del(fasthttp.HeaderContentLength)
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+137
-22
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
@@ -85,6 +86,38 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"slice_Uint": func(values ...uint) []uint { return values },
|
||||
"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_NowUnix": func() int64 { return time.Now().Unix() },
|
||||
"time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() },
|
||||
@@ -174,18 +207,20 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
|
||||
// Fakeit / Person
|
||||
// "fakeit_Person": fakeit.Person() *PersonInfo,
|
||||
"fakeit_Name": fakeit.Name,
|
||||
"fakeit_NamePrefix": fakeit.NamePrefix,
|
||||
"fakeit_NameSuffix": fakeit.NameSuffix,
|
||||
"fakeit_FirstName": fakeit.FirstName,
|
||||
"fakeit_MiddleName": fakeit.MiddleName,
|
||||
"fakeit_LastName": fakeit.LastName,
|
||||
"fakeit_Gender": fakeit.Gender,
|
||||
"fakeit_Age": fakeit.Age,
|
||||
"fakeit_Ethnicity": fakeit.Ethnicity,
|
||||
"fakeit_SSN": fakeit.SSN,
|
||||
"fakeit_EIN": fakeit.EIN,
|
||||
"fakeit_Hobby": fakeit.Hobby,
|
||||
"fakeit_Name": fakeit.Name,
|
||||
"fakeit_NamePrefix": fakeit.NamePrefix,
|
||||
"fakeit_NameSuffix": fakeit.NameSuffix,
|
||||
"fakeit_FirstName": fakeit.FirstName,
|
||||
"fakeit_MiddleName": fakeit.MiddleName,
|
||||
"fakeit_LastName": fakeit.LastName,
|
||||
"fakeit_Gender": fakeit.Gender,
|
||||
"fakeit_Age": fakeit.Age,
|
||||
"fakeit_Ethnicity": fakeit.Ethnicity,
|
||||
"fakeit_SSN": fakeit.SSN,
|
||||
"fakeit_EIN": fakeit.EIN,
|
||||
"fakeit_Hobby": fakeit.Hobby,
|
||||
"fakeit_SocialMedia": fakeit.SocialMedia,
|
||||
"fakeit_Bio": fakeit.Bio,
|
||||
// "fakeit_Contact": fakeit.Contact() *ContactInfo,
|
||||
"fakeit_Email": fakeit.Email,
|
||||
"fakeit_Phone": fakeit.Phone,
|
||||
@@ -199,6 +234,9 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
// "fakeit_Generate": fakeit.Generate(value string) string,
|
||||
"fakeit_Regex": fakeit.Regex,
|
||||
|
||||
// Fakeit / Database
|
||||
// "fakeit_SQL": fakeit.SQL(so *SQLOptions) (string, error),
|
||||
|
||||
// Fakeit / Auth
|
||||
"fakeit_Username": fakeit.Username,
|
||||
"fakeit_Password": fakeit.Password,
|
||||
@@ -236,6 +274,15 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
return value
|
||||
},
|
||||
|
||||
// Fakeit / Airline
|
||||
"fakeit_AirlineAircraftType": fakeit.AirlineAircraftType,
|
||||
"fakeit_AirlineAirplane": fakeit.AirlineAirplane,
|
||||
"fakeit_AirlineAirport": fakeit.AirlineAirport,
|
||||
"fakeit_AirlineAirportIATA": fakeit.AirlineAirportIATA,
|
||||
"fakeit_AirlineFlightNumber": fakeit.AirlineFlightNumber,
|
||||
"fakeit_AirlineRecordLocator": fakeit.AirlineRecordLocator,
|
||||
"fakeit_AirlineSeat": fakeit.AirlineSeat,
|
||||
|
||||
// Fakeit / Game
|
||||
"fakeit_Gamertag": fakeit.Gamertag,
|
||||
// "fakeit_Dice": fakeit.Dice(numDice uint, sides []uint) []uint,
|
||||
@@ -269,12 +316,16 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"fakeit_NounCollectiveThing": fakeit.NounCollectiveThing,
|
||||
"fakeit_NounCountable": fakeit.NounCountable,
|
||||
"fakeit_NounUncountable": fakeit.NounUncountable,
|
||||
"fakeit_NounProper": fakeit.NounProper,
|
||||
"fakeit_NounDeterminer": fakeit.NounDeterminer,
|
||||
|
||||
// Verbs
|
||||
"fakeit_Verb": fakeit.Verb,
|
||||
"fakeit_VerbAction": fakeit.VerbAction,
|
||||
"fakeit_VerbLinking": fakeit.VerbLinking,
|
||||
"fakeit_VerbHelping": fakeit.VerbHelping,
|
||||
"fakeit_Verb": fakeit.Verb,
|
||||
"fakeit_VerbAction": fakeit.VerbAction,
|
||||
"fakeit_VerbLinking": fakeit.VerbLinking,
|
||||
"fakeit_VerbHelping": fakeit.VerbHelping,
|
||||
"fakeit_VerbTransitive": fakeit.VerbTransitive,
|
||||
"fakeit_VerbIntransitive": fakeit.VerbIntransitive,
|
||||
|
||||
// Adverbs
|
||||
"fakeit_Adverb": fakeit.Adverb,
|
||||
@@ -311,6 +362,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"fakeit_PronounDemonstrative": fakeit.PronounDemonstrative,
|
||||
"fakeit_PronounInterrogative": fakeit.PronounInterrogative,
|
||||
"fakeit_PronounRelative": fakeit.PronounRelative,
|
||||
"fakeit_PronounIndefinite": fakeit.PronounIndefinite,
|
||||
|
||||
// Connectives
|
||||
"fakeit_Connective": fakeit.Connective,
|
||||
@@ -322,7 +374,8 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"fakeit_ConnectiveExamplify": fakeit.ConnectiveExamplify,
|
||||
|
||||
// Words
|
||||
"fakeit_Word": fakeit.Word,
|
||||
"fakeit_Word": fakeit.Word,
|
||||
"fakeit_Interjection": fakeit.Interjection,
|
||||
|
||||
// Text
|
||||
"fakeit_Sentence": fakeit.Sentence,
|
||||
@@ -333,6 +386,11 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"fakeit_Question": fakeit.Question,
|
||||
"fakeit_Quote": fakeit.Quote,
|
||||
"fakeit_Phrase": fakeit.Phrase,
|
||||
"fakeit_PhraseNoun": fakeit.PhraseNoun,
|
||||
"fakeit_PhraseVerb": fakeit.PhraseVerb,
|
||||
"fakeit_PhraseAdverb": fakeit.PhraseAdverb,
|
||||
"fakeit_PhrasePreposition": fakeit.PhrasePreposition,
|
||||
"fakeit_Comment": fakeit.Comment,
|
||||
|
||||
// Fakeit / Foods
|
||||
"fakeit_Fruit": fakeit.Fruit,
|
||||
@@ -342,6 +400,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"fakeit_Dinner": fakeit.Dinner,
|
||||
"fakeit_Snack": fakeit.Snack,
|
||||
"fakeit_Dessert": fakeit.Dessert,
|
||||
"fakeit_Drink": fakeit.Drink,
|
||||
|
||||
// Fakeit / Misc
|
||||
"fakeit_Bool": fakeit.Bool,
|
||||
@@ -354,6 +413,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"fakeit_Color": fakeit.Color,
|
||||
"fakeit_HexColor": fakeit.HexColor,
|
||||
"fakeit_RGBColor": fakeit.RGBColor,
|
||||
"fakeit_HSLColor": fakeit.HSLColor,
|
||||
"fakeit_SafeColor": fakeit.SafeColor,
|
||||
"fakeit_NiceColors": fakeit.NiceColors,
|
||||
|
||||
@@ -497,6 +557,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
// Fakeit / Language
|
||||
"fakeit_Language": fakeit.Language,
|
||||
"fakeit_LanguageAbbreviation": fakeit.LanguageAbbreviation,
|
||||
"fakeit_LanguageBCP": fakeit.LanguageBCP,
|
||||
"fakeit_ProgrammingLanguage": fakeit.ProgrammingLanguage,
|
||||
|
||||
// Fakeit / Number
|
||||
@@ -507,25 +568,29 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"fakeit_Int16": fakeit.Int16,
|
||||
"fakeit_Int32": fakeit.Int32,
|
||||
"fakeit_Int64": fakeit.Int64,
|
||||
"fakeit_IntRange": fakeit.IntRange,
|
||||
"fakeit_Uint": fakeit.Uint,
|
||||
"fakeit_UintN": fakeit.UintN,
|
||||
"fakeit_Uint8": fakeit.Uint8,
|
||||
"fakeit_Uint16": fakeit.Uint16,
|
||||
"fakeit_Uint32": fakeit.Uint32,
|
||||
"fakeit_Uint64": fakeit.Uint64,
|
||||
"fakeit_UintRange": fakeit.UintRange,
|
||||
"fakeit_Float32": fakeit.Float32,
|
||||
"fakeit_Float32Range": fakeit.Float32Range,
|
||||
"fakeit_Float64": fakeit.Float64,
|
||||
"fakeit_Float64Range": fakeit.Float64Range,
|
||||
// "fakeit_ShuffleInts": fakeit.ShuffleInts,
|
||||
"fakeit_RandomInt": fakeit.RandomInt,
|
||||
"fakeit_HexUint": fakeit.HexUint,
|
||||
"fakeit_RandomInt": fakeit.RandomInt,
|
||||
"fakeit_RandomUint": fakeit.RandomUint,
|
||||
"fakeit_HexUint": fakeit.HexUint,
|
||||
|
||||
// Fakeit / String
|
||||
"fakeit_Digit": fakeit.Digit,
|
||||
"fakeit_DigitN": fakeit.DigitN,
|
||||
"fakeit_Letter": fakeit.Letter,
|
||||
"fakeit_LetterN": fakeit.LetterN,
|
||||
"fakeit_Vowel": fakeit.Vowel,
|
||||
"fakeit_Lexify": fakeit.Lexify,
|
||||
"fakeit_Numerify": fakeit.Numerify,
|
||||
// "fakeit_ShuffleStrings": fakeit.ShuffleStrings,
|
||||
@@ -569,13 +634,14 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
|
||||
// Fakeit / Error
|
||||
"fakeit_Error": func() string { return fakeit.Error().Error() },
|
||||
"fakeit_ErrorObject": func() string { return fakeit.ErrorObject().Error() },
|
||||
"fakeit_ErrorDatabase": func() string { return fakeit.ErrorDatabase().Error() },
|
||||
"fakeit_ErrorGRPC": func() string { return fakeit.ErrorGRPC().Error() },
|
||||
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
|
||||
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().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_ErrorValidation": func() string { return fakeit.ErrorValidation().Error() },
|
||||
|
||||
// Fakeit / School
|
||||
"fakeit_School": fakeit.School,
|
||||
@@ -585,6 +651,55 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"fakeit_SongName": fakeit.SongName,
|
||||
"fakeit_SongArtist": fakeit.SongArtist,
|
||||
"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))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,7 +751,7 @@ func NewDefaultBodyTemplateFuncMap(
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(val, "@@"):
|
||||
// Escaped @ - send as literal string without first @
|
||||
// Escaped @: send the string without the first @
|
||||
if err := writer.WriteField(key, val[1:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
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{}{}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package sarin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"go.aykhans.me/sarin/internal/script"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// responseErrorKey drops the local address, whose port would mint a stats entry per connection.
|
||||
func responseErrorKey(err error) string {
|
||||
if errors.Is(err, fasthttp.ErrBodyTooLarge) {
|
||||
return types.ErrResponseBodyTooLarge.Error()
|
||||
}
|
||||
|
||||
message := err.Error()
|
||||
|
||||
var opErr *net.OpError
|
||||
if !errors.As(err, &opErr) || opErr.Source == nil {
|
||||
return message
|
||||
}
|
||||
|
||||
withoutSource := *opErr
|
||||
withoutSource.Source = nil
|
||||
return strings.Replace(message, opErr.Error(), withoutSource.Error(), 1)
|
||||
}
|
||||
|
||||
// requestErrorKey keys a generation failure by its source instead of its message.
|
||||
// A script picks its own URLs and error texts, so the message would mint a new
|
||||
// stats entry per request. The full message still goes to the runtime log.
|
||||
func requestErrorKey(err error) string {
|
||||
if chainErr, ok := errors.AsType[types.ScriptChainError](err); ok {
|
||||
return chainErr.EngineType + " script[" + strconv.Itoa(chainErr.Index) + "] error"
|
||||
}
|
||||
return "request generation error"
|
||||
}
|
||||
|
||||
func (s sarin) Worker(
|
||||
jobs <-chan struct{},
|
||||
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()
|
||||
}
|
||||
|
||||
nextClientIndex := newClientIndexGenerator(len(s.hostClients))
|
||||
bindScriptProxy := s.newScriptProxyBinder(scriptTransformer)
|
||||
|
||||
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, nextClientIndex, bindScriptProxy, counter, sendLog)
|
||||
case s.collectStats && !isDynamic:
|
||||
s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||
case !s.collectStats && isDynamic:
|
||||
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
|
||||
default:
|
||||
s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||
}
|
||||
} else {
|
||||
switch {
|
||||
case s.collectStats && isDynamic:
|
||||
s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
|
||||
case s.collectStats && !isDynamic:
|
||||
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
|
||||
case !s.collectStats && isDynamic:
|
||||
s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
|
||||
default:
|
||||
s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newScriptProxyBinder returns a func that sends the scripts' http.* calls through proxy i.
|
||||
func (s sarin) newScriptProxyBinder(scriptTransformer *script.Transformer) func(i int) {
|
||||
if scriptTransformer == nil || scriptTransformer.IsEmpty() {
|
||||
return func(int) {}
|
||||
}
|
||||
return func(i int) {
|
||||
scriptTransformer.SetHTTPDoer(s.scriptHTTPClients[i])
|
||||
}
|
||||
}
|
||||
|
||||
func (s sarin) workerStatsWithDynamic(
|
||||
jobs <-chan struct{},
|
||||
req *fasthttp.Request,
|
||||
resp *fasthttp.Response,
|
||||
requestGenerator RequestGenerator,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
bindScriptProxy func(int),
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
|
||||
// Bound before generation so scripts' http.* calls use the main request's proxy.
|
||||
clientIndex := nextClientIndex()
|
||||
bindScriptProxy(clientIndex)
|
||||
if err := requestGenerator(req); err != nil {
|
||||
s.responses.Add(requestErrorKey(err), 0)
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
|
||||
respDuration := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
s.responses.Add(responseErrorKey(err), 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,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
// Static request generation failed, so record all jobs as errors
|
||||
errorKey := requestErrorKey(err)
|
||||
for range jobs {
|
||||
s.responses.Add(errorKey, 0)
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
startTime := time.Now()
|
||||
err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
|
||||
respDuration := time.Since(startTime)
|
||||
if err != nil {
|
||||
s.responses.Add(responseErrorKey(err), 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,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
bindScriptProxy func(int),
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
// Bound before generation so scripts' http.* calls use the main request's proxy.
|
||||
clientIndex := nextClientIndex()
|
||||
bindScriptProxy(clientIndex)
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
continue
|
||||
}
|
||||
startTime := time.Now()
|
||||
err := s.hostClients[clientIndex].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,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
sendRespLog respLogger,
|
||||
) {
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
|
||||
// Static request generation failed, so just count the jobs without sending
|
||||
for range jobs {
|
||||
counter.Add(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
startTime := time.Now()
|
||||
err := s.hostClients[nextClientIndex()].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,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
bindScriptProxy func(int),
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
|
||||
bindScriptProxy(nextClientIndex())
|
||||
startTime := time.Now()
|
||||
if err := requestGenerator(req); err != nil {
|
||||
s.responses.Add(requestErrorKey(err), 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, so record all jobs as errors
|
||||
errorKey := requestErrorKey(err)
|
||||
for range jobs {
|
||||
s.responses.Add(errorKey, 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,
|
||||
nextClientIndex clientIndexGenerator,
|
||||
bindScriptProxy func(int),
|
||||
counter *atomic.Uint64,
|
||||
sendLog runtimeLogger,
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
|
||||
bindScriptProxy(nextClientIndex())
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,16 @@ func (t *Transformer) Transform(req *RequestData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetHTTPDoer sets the doer that the scripts' http.* calls are sent through.
|
||||
func (t *Transformer) SetHTTPDoer(doer HTTPDoer) {
|
||||
for _, engine := range t.luaEngines {
|
||||
engine.SetHTTPDoer(doer)
|
||||
}
|
||||
for _, engine := range t.jsEngines {
|
||||
engine.SetHTTPDoer(doer)
|
||||
}
|
||||
}
|
||||
|
||||
// Close releases all engine resources.
|
||||
func (t *Transformer) Close() {
|
||||
for _, engine := range t.luaEngines {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
)
|
||||
|
||||
// HTTPRequest is a request made by a script through the http.* functions.
|
||||
type HTTPRequest struct {
|
||||
Method string
|
||||
URL string
|
||||
Headers map[string][]string
|
||||
Params map[string][]string
|
||||
Cookies map[string][]string
|
||||
Body string
|
||||
Timeout time.Duration // zero means the doer's default timeout
|
||||
Insecure bool
|
||||
MaxRedirects int
|
||||
}
|
||||
|
||||
// HTTPResponse is the response returned to a script by the http.* functions.
|
||||
type HTTPResponse struct {
|
||||
Status int
|
||||
Headers map[string][]string
|
||||
Body string
|
||||
}
|
||||
|
||||
// Header returns the first value of the named header, matching the name case-insensitively.
|
||||
func (r *HTTPResponse) Header(name string) (string, bool) {
|
||||
for key, values := range r.Headers {
|
||||
if strings.EqualFold(key, name) && len(values) > 0 {
|
||||
return values[0], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Cookie returns the value of the named cookie from the Set-Cookie headers.
|
||||
func (r *HTTPResponse) Cookie(name string) (string, bool) {
|
||||
for key, values := range r.Headers {
|
||||
if !strings.EqualFold(key, "Set-Cookie") {
|
||||
continue
|
||||
}
|
||||
for _, line := range values {
|
||||
if cookie, err := http.ParseSetCookie(line); err == nil && cookie.Name == name {
|
||||
return cookie.Value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// HTTPDoer sends the HTTP requests that scripts make.
|
||||
type HTTPDoer interface {
|
||||
Do(req *HTTPRequest) (*HTTPResponse, error)
|
||||
}
|
||||
|
||||
// httpDefaultMethod is used when the options set no method.
|
||||
const httpDefaultMethod = http.MethodGet
|
||||
|
||||
// httpMaxRedirectsLimit is the highest maxRedirects a script may ask for.
|
||||
const httpMaxRedirectsLimit = 100
|
||||
|
||||
// Option keys accepted by the http function.
|
||||
const (
|
||||
httpOptionMethod = "method"
|
||||
httpOptionHeaders = "headers"
|
||||
httpOptionParams = "params"
|
||||
httpOptionCookies = "cookies"
|
||||
httpOptionBody = "body"
|
||||
httpOptionTimeout = "timeout"
|
||||
httpOptionInsecure = "insecure"
|
||||
httpOptionMaxRedirects = "maxRedirects"
|
||||
)
|
||||
|
||||
// httpBridge connects http to the doer. Calls only work while transform runs.
|
||||
type httpBridge struct {
|
||||
doer HTTPDoer
|
||||
active bool
|
||||
}
|
||||
|
||||
// It can return the following errors:
|
||||
// - types.ErrScriptHTTPOutsideTransform
|
||||
// - types.ErrScriptHTTPUnavailable
|
||||
// - types.ErrScriptHTTPMethodEmpty
|
||||
// - any error returned by the doer
|
||||
func (b *httpBridge) do(req *HTTPRequest) (*HTTPResponse, error) {
|
||||
if !b.active {
|
||||
return nil, types.ErrScriptHTTPOutsideTransform
|
||||
}
|
||||
if b.doer == nil {
|
||||
return nil, types.ErrScriptHTTPUnavailable
|
||||
}
|
||||
if req.Method == "" {
|
||||
return nil, types.ErrScriptHTTPMethodEmpty
|
||||
}
|
||||
return b.doer.Do(req)
|
||||
}
|
||||
|
||||
// parseHTTPMaxRedirects checks that the redirect limit is a non-negative whole number.
|
||||
// It can return the following errors:
|
||||
// - types.ScriptHTTPOptionError
|
||||
func parseHTTPMaxRedirects(count float64) (int, error) {
|
||||
if math.IsNaN(count) || count < 0 || count > httpMaxRedirectsLimit || count != math.Trunc(count) {
|
||||
return 0, types.NewScriptHTTPOptionError(
|
||||
httpOptionMaxRedirects,
|
||||
types.NewScriptTypeError(
|
||||
"a whole number between 0 and "+strconv.Itoa(httpMaxRedirectsLimit),
|
||||
strconv.FormatFloat(count, 'g', -1, 64),
|
||||
),
|
||||
)
|
||||
}
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
// parseHTTPTimeout parses a Go duration string such as "500ms" or "2s".
|
||||
// It can return the following errors:
|
||||
// - types.ScriptHTTPOptionError
|
||||
func parseHTTPTimeout(value string) (time.Duration, error) {
|
||||
timeout, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, types.NewScriptHTTPOptionError(httpOptionTimeout, err)
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return 0, types.NewScriptHTTPOptionError(httpOptionTimeout, types.NewScriptTypeError("a duration greater than 0", value))
|
||||
}
|
||||
return timeout, nil
|
||||
}
|
||||
+171
-46
@@ -2,6 +2,9 @@ package script
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
@@ -11,8 +14,15 @@ import (
|
||||
type JsEngine struct {
|
||||
runtime *goja.Runtime
|
||||
transform goja.Callable
|
||||
jsonParse goja.Callable
|
||||
// errorConstructor is the built-in Error, captured before the script can replace it.
|
||||
errorConstructor goja.Value
|
||||
http httpBridge
|
||||
}
|
||||
|
||||
// promiseType is what a transform returns when it is declared async or as a generator.
|
||||
var promiseType = reflect.TypeFor[*goja.Promise]()
|
||||
|
||||
// NewJsEngine creates a new JavaScript script engine with the given script content.
|
||||
// The script must define a global `transform` function that takes a request object
|
||||
// and returns the modified request object.
|
||||
@@ -29,11 +39,16 @@ type JsEngine struct {
|
||||
// - types.ScriptExecutionError
|
||||
func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
||||
vm := goja.New()
|
||||
engine := &JsEngine{runtime: vm}
|
||||
|
||||
// Register the globals before running the script so its functions can use them
|
||||
if err := engine.registerHTTP(); err != nil {
|
||||
return nil, types.NewScriptExecutionError("JavaScript", err)
|
||||
}
|
||||
|
||||
// Execute the script to define the transform function
|
||||
_, err := vm.RunString(scriptContent)
|
||||
if err != nil {
|
||||
return nil, types.NewScriptExecutionError("JavaScript", err)
|
||||
if _, err := vm.RunString(scriptContent); err != nil {
|
||||
return nil, types.NewScriptExecutionError("JavaScript", engine.exceptionError(err))
|
||||
}
|
||||
|
||||
// Get the transform function
|
||||
@@ -47,23 +62,43 @@ func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
||||
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
|
||||
}
|
||||
|
||||
return &JsEngine{
|
||||
runtime: vm,
|
||||
transform: transform,
|
||||
}, nil
|
||||
// A generator returns an iterator, which is an ordinary object to Go, so the
|
||||
// call cannot tell it apart and the declaration is the only thing left to read.
|
||||
if isGeneratorFunction(vm, transformVal) {
|
||||
return nil, types.ErrScriptTransformAsync
|
||||
}
|
||||
|
||||
engine.transform = transform
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
|
||||
func (e *JsEngine) SetHTTPDoer(doer HTTPDoer) {
|
||||
e.http.doer = doer
|
||||
}
|
||||
|
||||
// Transform executes the JavaScript transform function with the given request data.
|
||||
// It can return the following errors:
|
||||
// - types.ScriptExecutionError
|
||||
func (e *JsEngine) Transform(req *RequestData) error {
|
||||
e.http.active = true
|
||||
defer func() { e.http.active = false }()
|
||||
|
||||
// Convert RequestData to JavaScript object
|
||||
reqObj := e.requestDataToObject(req)
|
||||
|
||||
// Call transform(req)
|
||||
result, err := e.transform(goja.Undefined(), reqObj)
|
||||
if err != nil {
|
||||
return types.NewScriptExecutionError("JavaScript", err)
|
||||
return types.NewScriptExecutionError("JavaScript", e.exceptionError(err))
|
||||
}
|
||||
|
||||
// An async or generator transform returns a promise nothing ever awaits, and a promise
|
||||
// carries none of the request fields, so the request would go out untransformed.
|
||||
// ExportType reads the type alone, while Export would run every getter on the result.
|
||||
if result.ExportType() == promiseType {
|
||||
return types.ErrScriptTransformAsync
|
||||
}
|
||||
|
||||
// Update RequestData from the returned object
|
||||
@@ -79,6 +114,8 @@ func (e *JsEngine) Close() {
|
||||
// goja doesn't have an explicit close method, but we can help GC
|
||||
e.runtime = nil
|
||||
e.transform = nil
|
||||
e.jsonParse = nil
|
||||
e.errorConstructor = nil
|
||||
}
|
||||
|
||||
// requestDataToObject converts RequestData to a goja Value (JavaScript object).
|
||||
@@ -124,37 +161,70 @@ func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error {
|
||||
return types.ErrScriptTransformReturnObject
|
||||
}
|
||||
|
||||
// Method
|
||||
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) {
|
||||
req.Method = v.String()
|
||||
// Reading a field runs the script's own getters and toString, which throw as a Go
|
||||
// panic outside goja's execution context, so the whole read runs inside Try.
|
||||
var fieldErr error
|
||||
if thrown := e.runtime.Try(func() {
|
||||
// Method
|
||||
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
req.Method = v.String()
|
||||
}
|
||||
|
||||
// Path
|
||||
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
req.Path = v.String()
|
||||
}
|
||||
|
||||
// Body
|
||||
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
req.Body = v.String()
|
||||
}
|
||||
|
||||
// Headers
|
||||
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
values, err := objectField("headers", v)
|
||||
if err != nil {
|
||||
fieldErr = err
|
||||
return
|
||||
}
|
||||
req.Headers = e.objectToStringSliceMap(values)
|
||||
}
|
||||
|
||||
// Params
|
||||
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
values, err := objectField("params", v)
|
||||
if err != nil {
|
||||
fieldErr = err
|
||||
return
|
||||
}
|
||||
req.Params = e.objectToStringSliceMap(values)
|
||||
}
|
||||
|
||||
// Cookies
|
||||
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
values, err := objectField("cookies", v)
|
||||
if err != nil {
|
||||
fieldErr = err
|
||||
return
|
||||
}
|
||||
req.Cookies = e.objectToStringSliceMap(values)
|
||||
}
|
||||
}); thrown != nil {
|
||||
return e.exceptionError(thrown)
|
||||
}
|
||||
|
||||
// Path
|
||||
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) {
|
||||
req.Path = v.String()
|
||||
}
|
||||
return fieldErr
|
||||
}
|
||||
|
||||
// Body
|
||||
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) {
|
||||
req.Body = v.String()
|
||||
// objectField rejects anything that is not a plain object, since reading keys off an
|
||||
// array or a boxed string yields headers named after indexes and off a Date yields none.
|
||||
// A Map, a Set and a typed array still pass, so the field is emptied rather than refused.
|
||||
func objectField(name string, value goja.Value) (*goja.Object, error) {
|
||||
obj, ok := value.(*goja.Object)
|
||||
if !ok || obj.ClassName() != "Object" {
|
||||
return nil, fmt.Errorf("%s must be an object", name)
|
||||
}
|
||||
|
||||
// Headers
|
||||
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
req.Headers = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||
}
|
||||
|
||||
// Params
|
||||
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
req.Params = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||
}
|
||||
|
||||
// Cookies
|
||||
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||
req.Cookies = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||
}
|
||||
|
||||
return nil
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// stringSliceToArray converts a Go []string to a JavaScript array.
|
||||
@@ -176,23 +246,78 @@ func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string
|
||||
result := make(map[string][]string)
|
||||
for _, key := range obj.Keys() {
|
||||
v := obj.Get(key)
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's an array
|
||||
if arr, ok := v.Export().([]any); ok {
|
||||
var values []string
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
values = append(values, s)
|
||||
if arr, ok := v.(*goja.Object); ok && arr.ClassName() == "Array" {
|
||||
length := int(arr.Get("length").ToInteger())
|
||||
values := make([]string, 0, length)
|
||||
for i := range length {
|
||||
if text, ok := jsStringValue(arr.Get(strconv.Itoa(i))); ok {
|
||||
values = append(values, text)
|
||||
}
|
||||
}
|
||||
result[key] = values
|
||||
} else {
|
||||
// Single value - wrap in slice
|
||||
result[key] = []string{v.String()}
|
||||
continue
|
||||
}
|
||||
|
||||
// Single value, wrap it in a slice
|
||||
if text, ok := jsStringValue(v); ok {
|
||||
result[key] = []string{text}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// exceptionError keeps the thrown value's message without goja's stack frame.
|
||||
// Stringifying runs through Runtime.Try because a thrown value with no usable
|
||||
// toString makes goja panic outside its own execution context.
|
||||
func (e *JsEngine) exceptionError(err error) error {
|
||||
var exception *goja.Exception
|
||||
if !errors.As(err, &exception) {
|
||||
return err
|
||||
}
|
||||
|
||||
var message string
|
||||
if thrown := e.runtime.Try(func() { message = exception.Value().String() }); thrown != nil {
|
||||
message = "unprintable thrown value"
|
||||
}
|
||||
return errors.New(message)
|
||||
}
|
||||
|
||||
// jsStringValue renders a JavaScript primitive the way the script would see it.
|
||||
// Objects, arrays and functions have no useful text form, so they are skipped.
|
||||
func jsStringValue(v goja.Value) (string, bool) {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return "", false
|
||||
}
|
||||
if _, isObject := v.(*goja.Object); isObject {
|
||||
return "", false
|
||||
}
|
||||
return v.String(), true
|
||||
}
|
||||
|
||||
// isGeneratorFunction reports whether a function is declared with a star.
|
||||
// The name comes from the script's own prototype chain, so a script can falsify it either way.
|
||||
// Reading it can run a script getter, so the whole walk stays inside Try.
|
||||
func isGeneratorFunction(runtime *goja.Runtime, value goja.Value) bool {
|
||||
var name string
|
||||
thrown := runtime.Try(func() {
|
||||
obj := value.ToObject(runtime)
|
||||
if obj == nil {
|
||||
return
|
||||
}
|
||||
constructor := obj.Get("constructor")
|
||||
if constructor == nil || goja.IsUndefined(constructor) || goja.IsNull(constructor) {
|
||||
return
|
||||
}
|
||||
constructorObj := constructor.ToObject(runtime)
|
||||
if constructorObj == nil {
|
||||
return
|
||||
}
|
||||
if value := constructorObj.Get("name"); value != nil {
|
||||
name = value.String()
|
||||
}
|
||||
})
|
||||
|
||||
return thrown == nil && name == "GeneratorFunction"
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
)
|
||||
|
||||
// registerHTTP defines the global http function.
|
||||
func (e *JsEngine) registerHTTP() error {
|
||||
rt := e.runtime
|
||||
|
||||
jsonParse, ok := goja.AssertFunction(rt.Get("JSON").ToObject(rt).Get("parse"))
|
||||
if !ok {
|
||||
return errors.New("JSON.parse is not available")
|
||||
}
|
||||
e.jsonParse = jsonParse
|
||||
e.errorConstructor = rt.Get("Error")
|
||||
|
||||
return rt.Set("http", func(call goja.FunctionCall) goja.Value {
|
||||
return e.httpCall(call.Argument(0), call.Argument(1))
|
||||
})
|
||||
}
|
||||
|
||||
func (e *JsEngine) httpCall(urlValue, optsValue goja.Value) goja.Value {
|
||||
rt := e.runtime
|
||||
|
||||
url, ok := urlValue.Export().(string)
|
||||
if !ok {
|
||||
panic(rt.NewTypeError("http: url must be a string, got %s", jsTypeName(urlValue)))
|
||||
}
|
||||
req := &HTTPRequest{Method: httpDefaultMethod, URL: url}
|
||||
|
||||
if !goja.IsUndefined(optsValue) && !goja.IsNull(optsValue) {
|
||||
opts, ok := optsValue.(*goja.Object)
|
||||
if !ok {
|
||||
panic(rt.NewTypeError("http: options must be an object, got %s", jsTypeName(optsValue)))
|
||||
}
|
||||
if err := e.parseHTTPOptions(opts, req); err != nil {
|
||||
e.throw(err)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := e.http.do(req)
|
||||
if err != nil {
|
||||
e.throw(err)
|
||||
}
|
||||
|
||||
return e.httpResponseToObject(resp)
|
||||
}
|
||||
|
||||
// parseHTTPOptions fills req from a JavaScript options object.
|
||||
// It can return the following errors:
|
||||
// - types.ScriptHTTPOptionError
|
||||
func (e *JsEngine) parseHTTPOptions(opts *goja.Object, req *HTTPRequest) error {
|
||||
for _, name := range opts.Keys() {
|
||||
value := opts.Get(name)
|
||||
if goja.IsUndefined(value) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch name {
|
||||
case httpOptionMethod:
|
||||
method, ok := value.Export().(string)
|
||||
if !ok {
|
||||
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", jsTypeName(value)))
|
||||
}
|
||||
|
||||
req.Method = method
|
||||
case httpOptionHeaders, httpOptionParams, httpOptionCookies:
|
||||
object, ok := value.(*goja.Object)
|
||||
if !ok {
|
||||
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("object", jsTypeName(value)))
|
||||
}
|
||||
|
||||
values := e.objectToStringSliceMap(object)
|
||||
switch name {
|
||||
case httpOptionHeaders:
|
||||
req.Headers = values
|
||||
case httpOptionParams:
|
||||
req.Params = values
|
||||
default:
|
||||
req.Cookies = values
|
||||
}
|
||||
case httpOptionBody:
|
||||
body, ok := value.Export().(string)
|
||||
if !ok {
|
||||
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", jsTypeName(value)))
|
||||
}
|
||||
|
||||
req.Body = body
|
||||
case httpOptionTimeout:
|
||||
timeout, ok := value.Export().(string)
|
||||
if !ok {
|
||||
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("duration string", jsTypeName(value)))
|
||||
}
|
||||
|
||||
var err error
|
||||
if req.Timeout, err = parseHTTPTimeout(timeout); err != nil {
|
||||
return err
|
||||
}
|
||||
case httpOptionInsecure:
|
||||
flag, ok := value.Export().(bool)
|
||||
if !ok {
|
||||
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", jsTypeName(value)))
|
||||
}
|
||||
|
||||
req.Insecure = flag
|
||||
case httpOptionMaxRedirects:
|
||||
count, ok := numberValue(value)
|
||||
if !ok {
|
||||
return types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", jsTypeName(value)))
|
||||
}
|
||||
|
||||
var err error
|
||||
if req.MaxRedirects, err = parseHTTPMaxRedirects(count); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpResponseToObject converts a response to a JavaScript object.
|
||||
func (e *JsEngine) httpResponseToObject(resp *HTTPResponse) *goja.Object {
|
||||
rt := e.runtime
|
||||
obj := rt.NewObject()
|
||||
|
||||
headers := rt.NewObject()
|
||||
for key, values := range resp.Headers {
|
||||
_ = headers.Set(key, e.stringSliceToArray(values))
|
||||
}
|
||||
|
||||
lookup := func(find func(string) (string, bool)) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
name, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
panic(rt.NewTypeError("name must be a string, got %s", jsTypeName(call.Argument(0))))
|
||||
}
|
||||
if value, ok := find(name); ok {
|
||||
return rt.ToValue(value)
|
||||
}
|
||||
return goja.Null()
|
||||
}
|
||||
}
|
||||
|
||||
_ = obj.Set("status", resp.Status)
|
||||
_ = obj.Set("headers", headers)
|
||||
_ = obj.Set("body", resp.Body)
|
||||
_ = obj.Set("header", lookup(resp.Header))
|
||||
_ = obj.Set("cookie", lookup(resp.Cookie))
|
||||
_ = obj.Set("json", func(goja.FunctionCall) goja.Value {
|
||||
value, err := e.jsonParse(goja.Undefined(), rt.ToValue(resp.Body))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return value
|
||||
})
|
||||
// body is a JavaScript string, so bytes that are not valid UTF-8 reach the script
|
||||
// only through this.
|
||||
_ = obj.Set("bytes", func(goja.FunctionCall) goja.Value {
|
||||
buffer := rt.NewArrayBuffer([]byte(resp.Body))
|
||||
value, err := rt.New(rt.Get("Uint8Array").ToObject(rt), rt.ToValue(buffer))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return value
|
||||
})
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// jsTypeName returns a short type name for error messages.
|
||||
func jsTypeName(value goja.Value) string {
|
||||
switch {
|
||||
case value == nil || goja.IsUndefined(value):
|
||||
return "undefined"
|
||||
case goja.IsNull(value):
|
||||
return "null"
|
||||
}
|
||||
switch value.Export().(type) {
|
||||
case string:
|
||||
return "string"
|
||||
case bool:
|
||||
return "boolean"
|
||||
case int64, float64:
|
||||
return "number"
|
||||
case []any:
|
||||
return "array"
|
||||
default:
|
||||
if _, ok := goja.AssertFunction(value); ok {
|
||||
return "function"
|
||||
}
|
||||
return "object"
|
||||
}
|
||||
}
|
||||
|
||||
// throw raises err as a plain JavaScript Error instead of a GoError.
|
||||
func (e *JsEngine) throw(err error) {
|
||||
errorValue, newErr := e.runtime.New(e.errorConstructor, e.runtime.ToValue(err.Error()))
|
||||
if newErr != nil {
|
||||
panic(e.runtime.NewGoError(err))
|
||||
}
|
||||
panic(errorValue)
|
||||
}
|
||||
|
||||
// numberValue reports whether value is a JavaScript number, and returns it.
|
||||
func numberValue(value goja.Value) (float64, bool) {
|
||||
switch number := value.Export().(type) {
|
||||
case int64:
|
||||
return float64(number), true
|
||||
case float64:
|
||||
return number, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
+53
-44
@@ -11,6 +11,7 @@ import (
|
||||
type LuaEngine struct {
|
||||
state *lua.LState
|
||||
transform *lua.LFunction
|
||||
http httpBridge
|
||||
}
|
||||
|
||||
// NewLuaEngine creates a new Lua script engine with the given script content.
|
||||
@@ -29,6 +30,11 @@ type LuaEngine struct {
|
||||
// - types.ScriptExecutionError
|
||||
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
||||
L := lua.NewState()
|
||||
engine := &LuaEngine{state: L}
|
||||
|
||||
// Register the globals before running the script so its functions can use them
|
||||
engine.registerHTTP()
|
||||
registerLuaJSON(L)
|
||||
|
||||
// Execute the script to define the transform function
|
||||
if err := L.DoString(scriptContent); err != nil {
|
||||
@@ -42,17 +48,23 @@ func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
||||
L.Close()
|
||||
return nil, types.ErrScriptTransformMissing
|
||||
}
|
||||
engine.transform = transform.(*lua.LFunction)
|
||||
|
||||
return &LuaEngine{
|
||||
state: L,
|
||||
transform: transform.(*lua.LFunction),
|
||||
}, nil
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
|
||||
func (e *LuaEngine) SetHTTPDoer(doer HTTPDoer) {
|
||||
e.http.doer = doer
|
||||
}
|
||||
|
||||
// Transform executes the Lua transform function with the given request data.
|
||||
// It can return the following errors:
|
||||
// - types.ScriptExecutionError
|
||||
func (e *LuaEngine) Transform(req *RequestData) error {
|
||||
e.http.active = true
|
||||
defer func() { e.http.active = false }()
|
||||
|
||||
// Convert RequestData to Lua table
|
||||
reqTable := e.requestDataToTable(req)
|
||||
|
||||
@@ -93,39 +105,23 @@ func (e *LuaEngine) requestDataToTable(req *RequestData) *lua.LTable {
|
||||
t.RawSetString("path", lua.LString(req.Path))
|
||||
t.RawSetString("body", lua.LString(req.Body))
|
||||
|
||||
// Headers (map[string][]string -> table of arrays)
|
||||
headers := L.NewTable()
|
||||
for k, values := range req.Headers {
|
||||
arr := L.NewTable()
|
||||
t.RawSetString("headers", stringSliceMapToTable(L, req.Headers))
|
||||
t.RawSetString("params", stringSliceMapToTable(L, req.Params))
|
||||
t.RawSetString("cookies", stringSliceMapToTable(L, req.Cookies))
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// stringSliceMapToTable converts a Go map[string][]string to a Lua table of arrays.
|
||||
func stringSliceMapToTable(state *lua.LState, m map[string][]string) *lua.LTable {
|
||||
t := state.NewTable()
|
||||
for k, values := range m {
|
||||
arr := state.NewTable()
|
||||
for _, v := range values {
|
||||
arr.Append(lua.LString(v))
|
||||
}
|
||||
headers.RawSetString(k, arr)
|
||||
t.RawSetString(k, arr)
|
||||
}
|
||||
t.RawSetString("headers", headers)
|
||||
|
||||
// Params (map[string][]string -> table of arrays)
|
||||
params := L.NewTable()
|
||||
for k, values := range req.Params {
|
||||
arr := L.NewTable()
|
||||
for _, v := range values {
|
||||
arr.Append(lua.LString(v))
|
||||
}
|
||||
params.RawSetString(k, arr)
|
||||
}
|
||||
t.RawSetString("params", params)
|
||||
|
||||
// Cookies (map[string][]string -> table of arrays)
|
||||
cookies := L.NewTable()
|
||||
for k, values := range req.Cookies {
|
||||
arr := L.NewTable()
|
||||
for _, v := range values {
|
||||
arr.Append(lua.LString(v))
|
||||
}
|
||||
cookies.RawSetString(k, arr)
|
||||
}
|
||||
t.RawSetString("cookies", cookies)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -172,20 +168,33 @@ func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
|
||||
}
|
||||
key := string(k.(lua.LString))
|
||||
|
||||
switch v.Type() {
|
||||
case lua.LTString:
|
||||
// Single string value
|
||||
result[key] = []string{string(v.(lua.LString))}
|
||||
case lua.LTTable:
|
||||
// Array of strings
|
||||
var values []string
|
||||
v.(*lua.LTable).ForEach(func(_, item lua.LValue) {
|
||||
if item.Type() == lua.LTString {
|
||||
values = append(values, string(item.(lua.LString)))
|
||||
if v.Type() == lua.LTTable {
|
||||
// Array of values, keys outside 1..n are skipped
|
||||
arr := v.(*lua.LTable)
|
||||
length := arr.Len()
|
||||
values := make([]string, 0, length)
|
||||
for i := 1; i <= length; i++ {
|
||||
if text, ok := luaScalarString(arr.RawGetInt(i)); ok {
|
||||
values = append(values, text)
|
||||
}
|
||||
})
|
||||
}
|
||||
result[key] = values
|
||||
return
|
||||
}
|
||||
|
||||
if text, ok := luaScalarString(v); ok {
|
||||
result[key] = []string{text}
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// luaScalarString renders strings, numbers and booleans as text.
|
||||
func luaScalarString(v lua.LValue) (string, bool) {
|
||||
switch v.Type() {
|
||||
case lua.LTString, lua.LTNumber, lua.LTBool:
|
||||
return v.String(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
)
|
||||
|
||||
// registerHTTP defines the global http function.
|
||||
func (e *LuaEngine) registerHTTP() {
|
||||
L := e.state
|
||||
L.SetGlobal("http", L.NewFunction(func(L *lua.LState) int {
|
||||
return e.httpCall(L, 1)
|
||||
}))
|
||||
}
|
||||
|
||||
// httpCall sends the request whose URL is at stack position urlIndex and pushes the response.
|
||||
func (e *LuaEngine) httpCall(state *lua.LState, urlIndex int) int {
|
||||
req := &HTTPRequest{
|
||||
Method: httpDefaultMethod,
|
||||
URL: state.CheckString(urlIndex),
|
||||
}
|
||||
|
||||
switch opts := state.Get(urlIndex + 1).(type) {
|
||||
case *lua.LNilType:
|
||||
case *lua.LTable:
|
||||
if err := e.parseHTTPOptions(opts, req); err != nil {
|
||||
state.RaiseError("%s", err.Error())
|
||||
}
|
||||
default:
|
||||
state.ArgError(urlIndex+1, "options must be a table")
|
||||
}
|
||||
|
||||
resp, err := e.http.do(req)
|
||||
if err != nil {
|
||||
state.RaiseError("%s", err.Error())
|
||||
}
|
||||
|
||||
state.Push(httpResponseToTable(state, resp))
|
||||
return 1
|
||||
}
|
||||
|
||||
// parseHTTPOptions fills req from a Lua options table.
|
||||
// It can return the following errors:
|
||||
// - types.ScriptHTTPOptionError
|
||||
func (e *LuaEngine) parseHTTPOptions(opts *lua.LTable, req *HTTPRequest) error {
|
||||
var err error
|
||||
opts.ForEach(func(k, v lua.LValue) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
key, ok := k.(lua.LString)
|
||||
if !ok {
|
||||
err = types.NewScriptHTTPOptionError(k.String(), types.ErrScriptHTTPUnknownOption)
|
||||
return
|
||||
}
|
||||
|
||||
switch name := string(key); name {
|
||||
case httpOptionMethod:
|
||||
method, ok := v.(lua.LString)
|
||||
if !ok {
|
||||
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", v.Type().String()))
|
||||
return
|
||||
}
|
||||
req.Method = string(method)
|
||||
case httpOptionHeaders, httpOptionParams, httpOptionCookies:
|
||||
table, ok := v.(*lua.LTable)
|
||||
if !ok {
|
||||
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("table", v.Type().String()))
|
||||
return
|
||||
}
|
||||
values := e.tableToStringSliceMap(table)
|
||||
switch name {
|
||||
case httpOptionHeaders:
|
||||
req.Headers = values
|
||||
case httpOptionParams:
|
||||
req.Params = values
|
||||
default:
|
||||
req.Cookies = values
|
||||
}
|
||||
case httpOptionBody:
|
||||
body, ok := v.(lua.LString)
|
||||
if !ok {
|
||||
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("string", v.Type().String()))
|
||||
return
|
||||
}
|
||||
req.Body = string(body)
|
||||
case httpOptionTimeout:
|
||||
timeout, ok := v.(lua.LString)
|
||||
if !ok {
|
||||
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("duration string", v.Type().String()))
|
||||
return
|
||||
}
|
||||
req.Timeout, err = parseHTTPTimeout(string(timeout))
|
||||
case httpOptionInsecure:
|
||||
flag, ok := v.(lua.LBool)
|
||||
if !ok {
|
||||
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("boolean", v.Type().String()))
|
||||
return
|
||||
}
|
||||
req.Insecure = bool(flag)
|
||||
case httpOptionMaxRedirects:
|
||||
count, ok := v.(lua.LNumber)
|
||||
if !ok {
|
||||
err = types.NewScriptHTTPOptionError(name, types.NewScriptTypeError("number", v.Type().String()))
|
||||
return
|
||||
}
|
||||
req.MaxRedirects, err = parseHTTPMaxRedirects(float64(count))
|
||||
default:
|
||||
err = types.NewScriptHTTPOptionError(name, types.ErrScriptHTTPUnknownOption)
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// httpResponseToTable converts a response to a Lua table.
|
||||
func httpResponseToTable(state *lua.LState, resp *HTTPResponse) *lua.LTable {
|
||||
t := state.NewTable()
|
||||
t.RawSetString("status", lua.LNumber(resp.Status))
|
||||
t.RawSetString("headers", stringSliceMapToTable(state, resp.Headers))
|
||||
t.RawSetString("body", lua.LString(resp.Body))
|
||||
|
||||
lookup := func(find func(string) (string, bool)) *lua.LFunction {
|
||||
return state.NewFunction(func(L *lua.LState) int {
|
||||
// Last argument, so both res:header() and res.header() work.
|
||||
top := L.GetTop()
|
||||
if top == 0 {
|
||||
L.ArgError(1, "name expected")
|
||||
}
|
||||
if value, ok := find(L.CheckString(top)); ok {
|
||||
L.Push(lua.LString(value))
|
||||
} else {
|
||||
L.Push(lua.LNil)
|
||||
}
|
||||
return 1
|
||||
})
|
||||
}
|
||||
t.RawSetString("header", lookup(resp.Header))
|
||||
t.RawSetString("cookie", lookup(resp.Cookie))
|
||||
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package script
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
)
|
||||
|
||||
// registerLuaJSON defines the global json table with encode and decode.
|
||||
func registerLuaJSON(state *lua.LState) {
|
||||
module := state.NewTable()
|
||||
module.RawSetString("encode", state.NewFunction(luaJSONEncode))
|
||||
module.RawSetString("decode", state.NewFunction(luaJSONDecode))
|
||||
state.SetGlobal("json", module)
|
||||
}
|
||||
|
||||
func luaJSONEncode(state *lua.LState) int {
|
||||
value, err := luaToGo(state.CheckAny(1), make(map[*lua.LTable]bool))
|
||||
if err != nil {
|
||||
state.RaiseError("%s", types.NewScriptJSONError("encode", err).Error())
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
encoder := json.NewEncoder(&buf)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
state.RaiseError("%s", types.NewScriptJSONError("encode", err).Error())
|
||||
}
|
||||
|
||||
state.Push(lua.LString(strings.TrimSuffix(buf.String(), "\n")))
|
||||
return 1
|
||||
}
|
||||
|
||||
func luaJSONDecode(state *lua.LState) int {
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(state.CheckString(1)), &value); err != nil {
|
||||
state.RaiseError("%s", types.NewScriptJSONError("decode", err).Error())
|
||||
}
|
||||
|
||||
state.Push(goToLua(state, value))
|
||||
return 1
|
||||
}
|
||||
|
||||
// luaJSONMaxDepth matches the depth encoding/json accepts, so a table a script
|
||||
// decoded always encodes again, and it stays far below what overflows the Go stack.
|
||||
const luaJSONMaxDepth = 10000
|
||||
|
||||
// luaToGo converts a Lua value to a JSON-encodable Go value.
|
||||
// It can return the following errors:
|
||||
// - types.ErrScriptJSONCycle
|
||||
// - types.ErrScriptJSONDepth
|
||||
// - types.ScriptTypeError
|
||||
func luaToGo(value lua.LValue, visiting map[*lua.LTable]bool) (any, error) {
|
||||
switch v := value.(type) {
|
||||
case *lua.LNilType:
|
||||
return nil, nil
|
||||
case lua.LBool:
|
||||
return bool(v), nil
|
||||
case lua.LNumber:
|
||||
return float64(v), nil
|
||||
case lua.LString:
|
||||
return string(v), nil
|
||||
case *lua.LTable:
|
||||
if visiting[v] {
|
||||
return nil, types.ErrScriptJSONCycle
|
||||
}
|
||||
// visiting holds the tables on the current path, so its size is the depth.
|
||||
// A table millions of levels deep overflows the Go stack, which is a fatal
|
||||
// error no pcall can catch, so the descent is bounded well before that.
|
||||
if len(visiting) >= luaJSONMaxDepth {
|
||||
return nil, types.ErrScriptJSONDepth
|
||||
}
|
||||
visiting[v] = true
|
||||
defer delete(visiting, v)
|
||||
|
||||
if length, ok := luaArrayLength(v); ok {
|
||||
array := make([]any, length)
|
||||
for i := range length {
|
||||
item, err := luaToGo(v.RawGetInt(i+1), visiting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
array[i] = item
|
||||
}
|
||||
return array, nil
|
||||
}
|
||||
|
||||
object := make(map[string]any)
|
||||
var err error
|
||||
v.ForEach(func(key, item lua.LValue) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch key.(type) {
|
||||
case lua.LString, lua.LNumber:
|
||||
default:
|
||||
err = types.NewScriptTypeError("string or number object key", key.Type().String())
|
||||
return
|
||||
}
|
||||
object[key.String()], err = luaToGo(item, visiting)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return object, nil
|
||||
default:
|
||||
return nil, types.NewScriptTypeError("nil, boolean, number, string or table", value.Type().String())
|
||||
}
|
||||
}
|
||||
|
||||
// luaArrayLength reports whether t is a non-empty sequence with keys exactly 1..n.
|
||||
func luaArrayLength(t *lua.LTable) (int, bool) {
|
||||
count := 0
|
||||
isArray := true
|
||||
maxKey := 0.0
|
||||
t.ForEach(func(key, _ lua.LValue) {
|
||||
count++
|
||||
number, ok := key.(lua.LNumber)
|
||||
if !ok || float64(number) < 1 || float64(number) != math.Trunc(float64(number)) {
|
||||
isArray = false
|
||||
return
|
||||
}
|
||||
maxKey = max(maxKey, float64(number))
|
||||
})
|
||||
if !isArray || count == 0 || maxKey != float64(count) {
|
||||
return 0, false
|
||||
}
|
||||
return count, true
|
||||
}
|
||||
|
||||
// goToLua converts a value produced by encoding/json to a Lua value.
|
||||
func goToLua(state *lua.LState, value any) lua.LValue {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return lua.LBool(v)
|
||||
case float64:
|
||||
return lua.LNumber(v)
|
||||
case string:
|
||||
return lua.LString(v)
|
||||
case []any:
|
||||
t := state.CreateTable(len(v), 0)
|
||||
for i, item := range v {
|
||||
t.RawSetInt(i+1, goToLua(state, item))
|
||||
}
|
||||
return t
|
||||
case map[string]any:
|
||||
t := state.CreateTable(0, len(v))
|
||||
for key, item := range v {
|
||||
t.RawSetString(key, goToLua(state, item))
|
||||
}
|
||||
return t
|
||||
default:
|
||||
return lua.LNil
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ type Engine interface {
|
||||
// The script should modify the RequestData and return it.
|
||||
Transform(req *RequestData) error
|
||||
|
||||
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
|
||||
SetHTTPDoer(doer HTTPDoer)
|
||||
|
||||
// Close releases any resources held by the engine.
|
||||
Close()
|
||||
}
|
||||
@@ -69,7 +72,7 @@ func LoadSource(ctx context.Context, source string, engineType EngineType) (*Sou
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(source, "@@"):
|
||||
// Escaped @ - it's an inline script starting with literal @
|
||||
// Escaped @: an inline script starting with a literal @
|
||||
content = source[1:] // Remove first @, keep the rest
|
||||
case strings.HasPrefix(source, "@"):
|
||||
// File or URL reference
|
||||
@@ -125,7 +128,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
|
||||
return err
|
||||
}
|
||||
|
||||
// Try to create an engine - this validates syntax and transform function
|
||||
// Creating an engine validates the syntax and the transform function
|
||||
var engine Engine
|
||||
switch engineType {
|
||||
case EngineTypeLua:
|
||||
@@ -140,7 +143,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up the engine - we only needed it for validation
|
||||
// The engine was only needed for validation
|
||||
engine.Close()
|
||||
return nil
|
||||
}
|
||||
@@ -168,10 +171,18 @@ func fetchURL(ctx context.Context, url string) (string, error) {
|
||||
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
// A declared size above the limit is refused before anything is read.
|
||||
if resp.ContentLength > types.RemoteFetchLimit {
|
||||
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||
if err != nil {
|
||||
return "", types.NewHTTPFetchError(url, err)
|
||||
}
|
||||
if len(data) > types.RemoteFetchLimit {
|
||||
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ByteSizeLimit is the largest size any option may ask for. A limit is also the most
|
||||
// memory one response may take, so it stays well under what a machine can hold.
|
||||
const ByteSizeLimit = 1 << 30 // 1 GiB
|
||||
|
||||
// RemoteFetchLimit bounds a file sarin downloads for itself, such as a config file or a script.
|
||||
const RemoteFetchLimit = 64 << 20 // 64 MiB
|
||||
|
||||
// byteSizeUnits run from the largest scale down, so formatting picks the largest unit
|
||||
// that fits. A bare "K" is out: the common libraries disagree on whether it counts
|
||||
// 1000 or 1024 bytes.
|
||||
var byteSizeUnits = []struct {
|
||||
suffix string
|
||||
scale uint64
|
||||
}{
|
||||
{"TiB", 1 << 40},
|
||||
{"TB", 1000 * 1000 * 1000 * 1000},
|
||||
{"GiB", 1 << 30},
|
||||
{"GB", 1000 * 1000 * 1000},
|
||||
{"MiB", 1 << 20},
|
||||
{"MB", 1000 * 1000},
|
||||
{"KiB", 1 << 10},
|
||||
{"KB", 1000},
|
||||
{"B", 1},
|
||||
}
|
||||
|
||||
// ParseByteSize reads a byte count, with or without a unit suffix.
|
||||
// It can return the following errors:
|
||||
// - ErrByteSizeInvalid
|
||||
func ParseByteSize(value string) (uint64, error) {
|
||||
text := strings.TrimSpace(value)
|
||||
|
||||
// The suffix is split off by index, so no case mapping can shift the digits.
|
||||
digitsEnd := len(text)
|
||||
for digitsEnd > 0 && isASCIILetter(text[digitsEnd-1]) {
|
||||
digitsEnd--
|
||||
}
|
||||
|
||||
scale := uint64(1)
|
||||
if suffix := text[digitsEnd:]; suffix != "" {
|
||||
unitScale, ok := byteSizeScale(suffix)
|
||||
if !ok {
|
||||
return 0, ErrByteSizeInvalid
|
||||
}
|
||||
scale = unitScale
|
||||
}
|
||||
|
||||
size, err := strconv.ParseUint(strings.TrimSpace(text[:digitsEnd]), 10, 64)
|
||||
if err != nil {
|
||||
return 0, ErrByteSizeInvalid
|
||||
}
|
||||
if size > math.MaxUint64/scale {
|
||||
return 0, ErrByteSizeInvalid
|
||||
}
|
||||
return size * scale, nil
|
||||
}
|
||||
|
||||
// FormatByteSize renders a byte count so that ParseByteSize reads back the same value.
|
||||
func FormatByteSize(size uint64) string {
|
||||
for _, unit := range byteSizeUnits {
|
||||
if unit.scale > 1 && size >= unit.scale && size%unit.scale == 0 {
|
||||
return strconv.FormatUint(size/unit.scale, 10) + unit.suffix
|
||||
}
|
||||
}
|
||||
return strconv.FormatUint(size, 10)
|
||||
}
|
||||
|
||||
// byteSizeScale looks up a unit suffix, ignoring case.
|
||||
func byteSizeScale(suffix string) (uint64, bool) {
|
||||
for _, unit := range byteSizeUnits {
|
||||
if strings.EqualFold(suffix, unit.suffix) {
|
||||
return unit.scale, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isASCIILetter(char byte) bool {
|
||||
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
|
||||
}
|
||||
@@ -10,6 +10,17 @@ import (
|
||||
|
||||
var (
|
||||
errNoError = errors.New("no error (internal)")
|
||||
|
||||
ErrByteSizeInvalid = errors.New(
|
||||
"invalid value for size, expected a whole number of bytes or a whole number with a unit: " +
|
||||
"B, KB, MB, GB, TB (1000 based) or KiB, MiB, GiB, TiB (1024 based), e.g. '1048576', '10MiB', '2MB'",
|
||||
)
|
||||
|
||||
ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit))
|
||||
|
||||
ErrResponseBodyTooLarge = errors.New("response body exceeds the body limit")
|
||||
|
||||
ErrYAMLMultipleDocuments = errors.New("expected a single YAML document, but found more than one")
|
||||
)
|
||||
|
||||
type FieldParseError struct {
|
||||
@@ -208,8 +219,41 @@ func (e URLParseError) Unwrap() error {
|
||||
var (
|
||||
ErrFileCacheNotInitialized = errors.New("file cache is not initialized")
|
||||
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 {
|
||||
Err error
|
||||
}
|
||||
@@ -264,6 +308,27 @@ func (e CLIUnexpectedArgsError) Error() string {
|
||||
|
||||
// ======================================== Config File ========================================
|
||||
|
||||
// ConfigFileError names the file a parse error came from, which nested configFile chains lose.
|
||||
type ConfigFileError struct {
|
||||
Path string
|
||||
error error
|
||||
}
|
||||
|
||||
func NewConfigFileError(path string, err error) ConfigFileError {
|
||||
if err == nil {
|
||||
err = errNoError
|
||||
}
|
||||
return ConfigFileError{path, err}
|
||||
}
|
||||
|
||||
func (e ConfigFileError) Error() string {
|
||||
return e.error.Error()
|
||||
}
|
||||
|
||||
func (e ConfigFileError) Unwrap() error {
|
||||
return e.error
|
||||
}
|
||||
|
||||
type ConfigFileReadError struct {
|
||||
error error
|
||||
}
|
||||
@@ -285,6 +350,8 @@ func (e ConfigFileReadError) Unwrap() error {
|
||||
|
||||
// ======================================== Proxy ========================================
|
||||
|
||||
var ErrProxyNoContextDialer = errors.New("SOCKS5 dialer does not support timeouts")
|
||||
|
||||
type ProxyUnsupportedSchemeError struct {
|
||||
Scheme string
|
||||
}
|
||||
@@ -367,9 +434,91 @@ var (
|
||||
ErrScriptSourceEmpty = errors.New("script source cannot be empty after @")
|
||||
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
|
||||
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
|
||||
ErrScriptTransformAsync = errors.New("transform function must be synchronous, async and generator functions are not supported")
|
||||
ErrScriptURLNoHost = errors.New("script URL must have a host")
|
||||
ErrScriptHTTPOutsideTransform = errors.New("http functions can only be called while transform is running")
|
||||
ErrScriptHTTPUnavailable = errors.New("http client is not available")
|
||||
ErrScriptHTTPMethodEmpty = errors.New("http method cannot be empty")
|
||||
ErrScriptHTTPURLInvalid = errors.New("URL must be an absolute http or https URL")
|
||||
ErrScriptHTTPUnknownOption = errors.New("unknown option")
|
||||
ErrScriptJSONCycle = errors.New("cannot encode a table that contains itself")
|
||||
ErrScriptJSONDepth = errors.New("cannot encode a table nested too deeply")
|
||||
)
|
||||
|
||||
type ScriptTypeError struct {
|
||||
Expected string
|
||||
Got string
|
||||
}
|
||||
|
||||
func NewScriptTypeError(expected, got string) ScriptTypeError {
|
||||
return ScriptTypeError{expected, got}
|
||||
}
|
||||
|
||||
func (e ScriptTypeError) Error() string {
|
||||
return fmt.Sprintf("expected %s, got %s", e.Expected, e.Got)
|
||||
}
|
||||
|
||||
type ScriptHTTPOptionError struct {
|
||||
Option string
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewScriptHTTPOptionError(option string, err error) ScriptHTTPOptionError {
|
||||
if err == nil {
|
||||
err = errNoError
|
||||
}
|
||||
return ScriptHTTPOptionError{option, err}
|
||||
}
|
||||
|
||||
func (e ScriptHTTPOptionError) Error() string {
|
||||
return fmt.Sprintf("http option %q: %v", e.Option, e.Err)
|
||||
}
|
||||
|
||||
func (e ScriptHTTPOptionError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type ScriptHTTPRequestError struct {
|
||||
Method string
|
||||
URL string
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewScriptHTTPRequestError(method, url string, err error) ScriptHTTPRequestError {
|
||||
if err == nil {
|
||||
err = errNoError
|
||||
}
|
||||
return ScriptHTTPRequestError{method, url, err}
|
||||
}
|
||||
|
||||
func (e ScriptHTTPRequestError) Error() string {
|
||||
return fmt.Sprintf("http %s %s: %v", e.Method, e.URL, e.Err)
|
||||
}
|
||||
|
||||
func (e ScriptHTTPRequestError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type ScriptJSONError struct {
|
||||
Op string
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewScriptJSONError(op string, err error) ScriptJSONError {
|
||||
if err == nil {
|
||||
err = errNoError
|
||||
}
|
||||
return ScriptJSONError{op, err}
|
||||
}
|
||||
|
||||
func (e ScriptJSONError) Error() string {
|
||||
return fmt.Sprintf("json.%s: %v", e.Op, e.Err)
|
||||
}
|
||||
|
||||
func (e ScriptJSONError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type ScriptLoadError struct {
|
||||
Source string
|
||||
Err error
|
||||
@@ -442,3 +591,91 @@ func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError {
|
||||
func (e ScriptUnknownEngineError) Error() string {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
lib,
|
||||
buildGoModule,
|
||||
go_1_27,
|
||||
rev ? "unknown",
|
||||
buildDate ? "unknown",
|
||||
}:
|
||||
|
||||
(buildGoModule.override { go = go_1_27; }) (finalAttrs: {
|
||||
pname = "sarin";
|
||||
version = "1.5.0"; # bump per release
|
||||
|
||||
src = lib.cleanSource ../.;
|
||||
|
||||
vendorHash = "sha256-SpMOMmDa7llS8wbCDf6QJbWtZ/s8RgzMvVP1xrY934s=";
|
||||
|
||||
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 = [ ];
|
||||
};
|
||||
})
|
||||
Reference in new issue
Block a user