mirror of
https://github.com/aykhans/sarin.git
synced 2026-10-16 14:43:53 +00:00
Compare commits
112
Commits
v1.3.2
...
9e773a4e6c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e773a4e6c
|
||
|
|
bcf2ea3446
|
||
|
|
e85a6d4613
|
||
|
|
4a14d57d15
|
||
|
|
633f548e7b
|
||
|
|
68de415645
|
||
|
|
fb8443fd25
|
||
|
|
e0f6d4aeba
|
||
|
|
17dfa16051
|
||
|
|
a327c4978b
|
||
|
|
9d6e4e6bf8
|
||
|
|
9c7b7c26a0
|
||
|
|
ef5907dc0c
|
||
|
|
1956eb1c44
|
||
|
|
f1d91e3d77
|
||
|
|
412a02fea3
|
||
|
|
8da05bb10c
|
||
|
|
c32949c8e0
|
||
|
|
a30c862936
|
||
|
|
5d91141f80
|
||
|
|
878ca6dfbd
|
||
|
|
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
|
No files matched your search
@@ -13,10 +13,10 @@ jobs:
|
|||||||
name: lint
|
name: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: 1.26.4
|
go-version-file: go.mod
|
||||||
- name: go fix
|
- name: go fix
|
||||||
run: |
|
run: |
|
||||||
go fix ./...
|
go fix ./...
|
||||||
@@ -24,4 +24,4 @@ jobs:
|
|||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.12.2
|
version: v2.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
|
||||||
+124
-28
@@ -2,50 +2,101 @@ name: Build and Release
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [created]
|
types: [published]
|
||||||
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
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
nix:
|
||||||
|
name: Verify Nix package
|
||||||
|
uses: ./.github/workflows/nix.yaml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
with:
|
||||||
|
ref: ${{ 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:
|
build:
|
||||||
name: Build binaries
|
name: Build binaries
|
||||||
|
needs: [nix, version]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.tag || github.ref }}
|
ref: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Set build metadata
|
- name: Set build metadata
|
||||||
run: |
|
run: |
|
||||||
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
|
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
|
||||||
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||||
echo "GO_VERSION=1.26.4" >> $GITHUB_ENV
|
echo "GO_VERSION=$(sed -n 's/^go //p' go.mod)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
if: github.event_name == 'release' || inputs.build_binaries
|
uses: actions/setup-go@v7
|
||||||
uses: actions/setup-go@v6
|
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Build binaries
|
- name: Build binaries
|
||||||
if: github.event_name == 'release' || inputs.build_binaries
|
|
||||||
run: |
|
run: |
|
||||||
LDFLAGS="-X 'go.aykhans.me/sarin/internal/version.Version=${{ env.VERSION }}' \
|
LDFLAGS="-X 'go.aykhans.me/sarin/internal/version.Version=${{ env.VERSION }}' \
|
||||||
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${{ env.GIT_COMMIT }}' \
|
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${{ env.GIT_COMMIT }}' \
|
||||||
@@ -63,29 +114,24 @@ jobs:
|
|||||||
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.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
|
- name: Upload Release Assets
|
||||||
if: github.event_name == 'release' || inputs.build_binaries
|
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ inputs.tag || github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
files: ./sarin-*
|
files: ./sarin-*
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
if: github.event_name == 'release' || inputs.build_docker
|
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
if: github.event_name == 'release' || inputs.build_docker
|
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: github.event_name == 'release' || inputs.build_docker
|
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
if: github.event_name == 'release' || inputs.build_docker
|
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -95,6 +141,56 @@ jobs:
|
|||||||
VERSION=${{ env.VERSION }}
|
VERSION=${{ env.VERSION }}
|
||||||
GIT_COMMIT=${{ env.GIT_COMMIT }}
|
GIT_COMMIT=${{ env.GIT_COMMIT }}
|
||||||
GO_VERSION=${{ env.GO_VERSION }}
|
GO_VERSION=${{ env.GO_VERSION }}
|
||||||
|
# A pre-release must not take over the tag people get by default.
|
||||||
tags: |
|
tags: |
|
||||||
${{ secrets.DOCKERHUB_USERNAME }}/sarin:${{ env.VERSION }}
|
${{ 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/*
|
bin/*
|
||||||
|
|
||||||
|
# Nix build artifacts
|
||||||
|
result
|
||||||
|
result-*
|
||||||
+6
-1
@@ -1,7 +1,7 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go: "1.26"
|
go: "1.27"
|
||||||
concurrency: 12
|
concurrency: 12
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
@@ -81,6 +81,11 @@ linters:
|
|||||||
- staticcheck
|
- staticcheck
|
||||||
text: "SA5011"
|
text: "SA5011"
|
||||||
|
|
||||||
|
- linters:
|
||||||
|
- errcheck
|
||||||
|
- gosec
|
||||||
|
source: "lipgloss\\.Println\\("
|
||||||
|
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
- gofmt
|
- gofmt
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
ARG GO_VERSION=1.26.4
|
ARG GO_VERSION=1.27.1
|
||||||
|
|
||||||
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
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.
|
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
|
||||||
|
|
||||||
[](https://pkg.go.dev/go.aykhans.me/sarin)
|
[](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)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -24,20 +26,19 @@ Sarin is designed for efficient HTTP load testing with minimal resource consumpt
|
|||||||
|
|
||||||
| ✅ Supported | ❌ Not Supported |
|
| ✅ Supported | ❌ Not Supported |
|
||||||
| ---------------------------------------------------------- | ------------------------------- |
|
| ---------------------------------------------------------- | ------------------------------- |
|
||||||
| High-performance with low memory footprint | Detailed response body analysis |
|
| High-performance with low memory footprint | Web UI or complex TUI |
|
||||||
| Long-running duration/count based tests | Extensive response statistics |
|
| Dynamic requests via 370+ template functions | Detailed response body analysis |
|
||||||
| Dynamic requests via 340+ template functions | Web UI or complex TUI |
|
|
||||||
| Request scripting with Lua and JavaScript | Distributed load testing |
|
| Request scripting with Lua and JavaScript | Distributed load testing |
|
||||||
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
||||||
| Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem |
|
| Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem |
|
||||||
| Flexible config (CLI, ENV, YAML) | |
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Docker (Recommended)
|
<details open>
|
||||||
|
<summary><b>Docker</b></summary>
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker pull aykhans/sarin:latest
|
docker run --rm -it aykhans/sarin:latest --version
|
||||||
```
|
```
|
||||||
|
|
||||||
With a local config file:
|
With a local config file:
|
||||||
@@ -52,13 +53,50 @@ With a remote config file:
|
|||||||
docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
|
docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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.
|
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
|
```sh
|
||||||
git clone https://github.com/aykhans/sarin.git && cd sarin
|
git clone https://github.com/aykhans/sarin.git && cd sarin
|
||||||
@@ -72,6 +110,8 @@ CGO_ENABLED=0 go build \
|
|||||||
-o sarin ./cmd/cli/main.go
|
-o sarin ./cmd/cli/main.go
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request:
|
Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request:
|
||||||
@@ -106,7 +146,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c
|
|||||||
|
|
||||||
## Templating
|
## Templating
|
||||||
|
|
||||||
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 340+ built-in functions to generate dynamic data for each request.
|
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 370+ built-in functions to generate dynamic data for each request.
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -3,7 +3,9 @@ version: "3"
|
|||||||
|
|
||||||
vars:
|
vars:
|
||||||
BIN_DIR: ./bin
|
BIN_DIR: ./bin
|
||||||
GOLANGCI_LINT_VERSION: v2.12.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}}"
|
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
@@ -39,6 +41,11 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- "{{.GOLANGCI}} run"
|
- "{{.GOLANGCI}} run"
|
||||||
|
|
||||||
|
nix-hash:
|
||||||
|
desc: Recompute nix/package.nix vendorHash after a dependency change.
|
||||||
|
cmds:
|
||||||
|
- nix run nixpkgs#nix-update -- --flake --version=skip --no-src default
|
||||||
|
|
||||||
test:
|
test:
|
||||||
desc: Run Go tests.
|
desc: Run Go tests.
|
||||||
cmds:
|
cmds:
|
||||||
@@ -82,7 +89,6 @@ tasks:
|
|||||||
vars:
|
vars:
|
||||||
IMAGE_NAME: '{{.IMAGE_NAME | default "sarin"}}'
|
IMAGE_NAME: '{{.IMAGE_NAME | default "sarin"}}'
|
||||||
TAG: '{{.TAG | default "latest"}}'
|
TAG: '{{.TAG | default "latest"}}'
|
||||||
GO_VERSION: '{{.GO_VERSION | default ""}}'
|
|
||||||
VERSION:
|
VERSION:
|
||||||
sh: git describe --tags --always
|
sh: git describe --tags --always
|
||||||
GIT_COMMIT:
|
GIT_COMMIT:
|
||||||
@@ -90,7 +96,7 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- >-
|
- >-
|
||||||
docker build
|
docker build
|
||||||
{{if .GO_VERSION}}--build-arg GO_VERSION={{.GO_VERSION}}{{end}}
|
--build-arg GO_VERSION={{.GO_VERSION}}
|
||||||
--build-arg VERSION={{.VERSION}}
|
--build-arg VERSION={{.VERSION}}
|
||||||
--build-arg GIT_COMMIT={{.GIT_COMMIT}}
|
--build-arg GIT_COMMIT={{.GIT_COMMIT}}
|
||||||
-t {{.IMAGE_NAME}}:{{.TAG}}
|
-t {{.IMAGE_NAME}}:{{.TAG}}
|
||||||
|
|||||||
+12
-10
@@ -7,6 +7,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
"go.aykhans.me/sarin/internal/config"
|
"go.aykhans.me/sarin/internal/config"
|
||||||
"go.aykhans.me/sarin/internal/sarin"
|
"go.aykhans.me/sarin/internal/sarin"
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
@@ -32,13 +33,13 @@ func main() {
|
|||||||
utilsErr.OnType(func(err types.FieldValidationErrors) error {
|
utilsErr.OnType(func(err types.FieldValidationErrors) error {
|
||||||
for _, fieldErr := range err.Errors {
|
for _, fieldErr := range err.Errors {
|
||||||
if fieldErr.Value == "" {
|
if fieldErr.Value == "" {
|
||||||
fmt.Fprintln(os.Stderr,
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||||
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s': ", fieldErr.Field))+fieldErr.Err.Error(),
|
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s': ", fieldErr.Field))+fieldErr.Err.Error(),
|
||||||
)
|
))
|
||||||
} else {
|
} else {
|
||||||
fmt.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(),
|
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s' (%s): ", fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -50,31 +51,32 @@ func main() {
|
|||||||
ctx,
|
ctx,
|
||||||
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
|
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
|
||||||
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
|
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
|
||||||
*combinedConfig.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.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
|
||||||
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
||||||
*combinedConfig.DryRun,
|
*combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile,
|
||||||
combinedConfig.Lua, combinedConfig.Js,
|
combinedConfig.Lua, combinedConfig.Js,
|
||||||
)
|
)
|
||||||
_ = utilsErr.MustHandle(err,
|
_ = utilsErr.MustHandle(err,
|
||||||
utilsErr.OnType(func(err types.ProxyDialError) error {
|
utilsErr.OnType(func(err types.ProxyDialError) error {
|
||||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[PROXY] ")+err.Error())
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[PROXY] ")+err.Error()))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error {
|
utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error {
|
||||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error()))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
utilsErr.OnType(func(err types.ScriptLoadError) error {
|
utilsErr.OnType(func(err types.ScriptLoadError) error {
|
||||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error()))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
srn.Start(ctx, stopCtrl)
|
srn.Start(stopCtrl)
|
||||||
|
|
||||||
switch *combinedConfig.Output {
|
switch *combinedConfig.Output {
|
||||||
case config.ConfigOutputTypeNone:
|
case config.ConfigOutputTypeNone:
|
||||||
|
|||||||
+203
-64
@@ -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.
|
> **Note:** For CLI flags with `string / []string` type, the flag can be used once with a single value or multiple times to provide multiple values.
|
||||||
|
|
||||||
| Name | YAML | CLI | ENV | Default | Description |
|
| Name | YAML | CLI | ENV | Default | Description |
|
||||||
| --------------------------- | ----------------------------------- | -------------------------------------------- | -------------------------------- | ------- | ---------------------------- |
|
| --------------------------------------- | ----------------------------------- | -------------------------------------------- | ----------------------------------- | ------- | ----------------------------- |
|
||||||
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
|
| [Help](#help) | - | `-help` / `-h` | - | - | Show help message |
|
||||||
| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info |
|
| [Version](#version) | - | `-version` / `-v` | - | - | Show version and build info |
|
||||||
| [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration |
|
| [Show Config](#show-config) | `showConfig`<br>(boolean) | `-show-config` / `-s`<br>(boolean) | `SARIN_SHOW_CONFIG`<br>(boolean) | `false` | Show merged configuration |
|
||||||
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
|
| [Config File](#config-file) | `configFile`<br>(string / []string) | `-config-file` / `-f`<br>(string / []string) | `SARIN_CONFIG_FILE`<br>(string) | - | Path to config file(s) |
|
||||||
| [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) |
|
| [URL](#url) | `url`<br>(string) | `-url` / `-U`<br>(string) | `SARIN_URL`<br>(string) | - | Target URL (HTTP/HTTPS) |
|
||||||
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
|
| [Method](#method) | `method`<br>(string / []string) | `-method` / `-M`<br>(string / []string) | `SARIN_METHOD`<br>(string) | `GET` | HTTP method(s) |
|
||||||
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
|
| [Timeout](#timeout) | `timeout`<br>(duration) | `-timeout` / `-T`<br>(duration) | `SARIN_TIMEOUT`<br>(duration) | `10s` | Request timeout |
|
||||||
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
|
| [Max Response Body](#max-response-body) | `maxResponseBody`<br>(size) | `-max-response-body` / `-S`<br>(size) | `SARIN_MAX_RESPONSE_BODY`<br>(size) | `10MiB` | Largest response body to read |
|
||||||
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
|
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
|
||||||
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
|
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
|
||||||
| [Quiet](#quiet) | `quiet`<br>(boolean) | `-quiet` / `-q`<br>(boolean) | `SARIN_QUIET`<br>(boolean) | `false` | Hide progress bar and logs |
|
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
|
||||||
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
|
| [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit |
|
||||||
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
|
| [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
|
||||||
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
| [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
|
||||||
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
|
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
|
||||||
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
|
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
|
||||||
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
|
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
||||||
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
| [Body](#body) | `body`<br>(string / []string) | `-body` / `-B`<br>(string / []string) | `SARIN_BODY`<br>(string) | - | Request body |
|
||||||
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
|
| [Params](#params) | `params`<br>(object) | `-param` / `-P`<br>(string / []string) | `SARIN_PARAM`<br>(string) | - | URL query parameters |
|
||||||
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
|
| [Headers](#headers) | `headers`<br>(object) | `-header` / `-H`<br>(string / []string) | `SARIN_HEADER`<br>(string) | - | HTTP headers |
|
||||||
| [Lua](#lua) | `lua`<br>(string / []string) | `-lua`<br>(string / []string) | `SARIN_LUA`<br>(string) | - | Lua script(s) |
|
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
||||||
| [Js](#js) | `js`<br>(string / []string) | `-js`<br>(string / []string) | `SARIN_JS`<br>(string) | - | JavaScript script(s) |
|
| [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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -131,7 +134,7 @@ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10
|
|||||||
|
|
||||||
## Method
|
## Method
|
||||||
|
|
||||||
HTTP method(s). If multiple values are provided, Sarin cycles through them 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:**
|
**YAML example:**
|
||||||
|
|
||||||
@@ -141,9 +144,9 @@ method: GET
|
|||||||
# OR
|
# OR
|
||||||
|
|
||||||
method:
|
method:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
- PUT
|
- PUT
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -160,15 +163,27 @@ SARIN_METHOD=GET
|
|||||||
|
|
||||||
## Timeout
|
## 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`
|
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
||||||
|
|
||||||
**Examples:** `5s`, `300ms`, `1m20s`
|
**Examples:** `5s`, `300ms`, `1m20s`
|
||||||
|
|
||||||
|
## Max Response Body
|
||||||
|
|
||||||
|
Largest response body sarin reads. Defaults to `10MiB`, and `0` removes the limit.
|
||||||
|
|
||||||
|
The value is a whole number of bytes, with or without a unit: `B`, `KB`, `MB`, `GB`, `TB` are powers of 1000, `KiB`, `MiB`, `GiB`, `TiB` are powers of 1024. Case is ignored and a space before the unit is allowed. The largest accepted value is `1GiB`, because one response can take a few times the limit in memory.
|
||||||
|
|
||||||
|
**Examples:** `1048576`, `10MiB`, `2MB`, `512KiB`
|
||||||
|
|
||||||
|
A server can declare a `Content-Length` far larger than it sends, and the body is allocated from that declared length, so without a limit a single response can exhaust memory. A response above the limit fails with `response body exceeds the body limit` and is counted like any other error.
|
||||||
|
|
||||||
|
The limit applies per response in flight, and a declared Content-Length is allocated up to it, so the worst case is a few times the limit times the concurrency. It covers the requests scripts make as well.
|
||||||
|
|
||||||
## Concurrency
|
## 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
|
## Requests
|
||||||
|
|
||||||
@@ -182,15 +197,34 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
|||||||
|
|
||||||
**Examples:** `1m30s`, `25s`, `1h`
|
**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 or sending a request
|
||||||
|
- `info`: every completed response
|
||||||
|
|
||||||
|
Leave empty to disable logging entirely.
|
||||||
|
|
||||||
|
**Examples:** `error` (only errors), `info` (only responses), `info,error` (both)
|
||||||
|
|
||||||
|
## Log File
|
||||||
|
|
||||||
|
Write runtime logs to this file instead of the terminal or stderr. The parent directory must exist.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com -r 1000 --log-file ./run.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
Progress display. Valid values: `bar` (default), `none`. Use `none` to hide the progress bar.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
Output format for response statistics.
|
Output format for response statistics.
|
||||||
|
|
||||||
Valid formats: `table`, `json`, `yaml`, `none`
|
Valid formats: `table` (default), `json`, `yaml`, `none`
|
||||||
|
|
||||||
Using `none` disables output and reduces memory usage since response statistics are not stored.
|
Using `none` disables output and reduces memory usage since response statistics are not stored.
|
||||||
|
|
||||||
@@ -204,7 +238,7 @@ Skip TLS certificate verification.
|
|||||||
|
|
||||||
## Body
|
## 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:**
|
**YAML example:**
|
||||||
|
|
||||||
@@ -214,9 +248,9 @@ body: '{"product": "car"}'
|
|||||||
# OR
|
# OR
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- '{"product": "car"}'
|
- '{"product": "car"}'
|
||||||
- '{"product": "phone"}'
|
- '{"product": "phone"}'
|
||||||
- '{"product": "watch"}'
|
- '{"product": "watch"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -241,19 +275,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
params:
|
params:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3] # cycles between value2 and value3
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
params:
|
params:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3] # cycles between value2 and value3
|
- key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# To send both values in every request, use separate entries:
|
# To send both values in every request, use separate entries:
|
||||||
params:
|
params:
|
||||||
- key2: value2
|
- key2: value2
|
||||||
- key2: value3 # both sent in every request
|
- key2: value3 # both sent in every request
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -278,19 +312,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
headers:
|
headers:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3] # cycles between value2 and value3
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
headers:
|
headers:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3] # cycles between value2 and value3
|
- key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# To send both values in every request, use separate entries:
|
# To send both values in every request, use separate entries:
|
||||||
headers:
|
headers:
|
||||||
- key2: value2
|
- key2: value2
|
||||||
- key2: value3 # both sent in every request
|
- key2: value3 # both sent in every request
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -315,19 +349,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cookies:
|
cookies:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3] # cycles between value2 and value3
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
cookies:
|
cookies:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3] # cycles between value2 and value3
|
- key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# To send both values in every request, use separate entries:
|
# To send both values in every request, use separate entries:
|
||||||
cookies:
|
cookies:
|
||||||
- key2: value2
|
- key2: value2
|
||||||
- key2: value3 # both sent in every request
|
- key2: value3 # both sent in every request
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -344,7 +378,7 @@ SARIN_COOKIE="key1=value1"
|
|||||||
|
|
||||||
## Proxy
|
## 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`
|
Supported protocols: `http`, `https`, `socks5`, `socks5h`
|
||||||
|
|
||||||
@@ -356,9 +390,9 @@ proxy: http://proxy1.com
|
|||||||
# OR
|
# OR
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
- http://proxy1.com
|
- http://proxy1.com
|
||||||
- socks5://proxy2.com
|
- socks5://proxy2.com
|
||||||
- socks5h://proxy3.com
|
- socks5h://proxy3.com
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -387,9 +421,9 @@ values: "key=value"
|
|||||||
# OR
|
# OR
|
||||||
|
|
||||||
values: |
|
values: |
|
||||||
key1=value1
|
key1=value1
|
||||||
key2=value2
|
key2=value2
|
||||||
key3=value3
|
key3=value3
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -435,7 +469,9 @@ function transform(req)
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`).
|
> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`). Numbers and booleans are converted to text, other values are skipped.
|
||||||
|
|
||||||
|
> **Note:** Lua scripts can send their own HTTP requests with `http` and work with JSON through the global `json` table. See [HTTP Requests in Scripts](#http-requests-in-scripts).
|
||||||
|
|
||||||
**YAML example:**
|
**YAML example:**
|
||||||
|
|
||||||
@@ -500,7 +536,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:**
|
**YAML example:**
|
||||||
|
|
||||||
@@ -533,3 +571,104 @@ js:
|
|||||||
```sh
|
```sh
|
||||||
SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
|
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"`) | `30s` | 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.
|
||||||
|
- **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;
|
||||||
|
}
|
||||||
|
```
|
||||||
+177
-5
@@ -13,6 +13,7 @@ This guide provides practical examples for common Sarin use cases.
|
|||||||
- [File Uploads](#file-uploads)
|
- [File Uploads](#file-uploads)
|
||||||
- [Using Proxies](#using-proxies)
|
- [Using Proxies](#using-proxies)
|
||||||
- [Output Formats](#output-formats)
|
- [Output Formats](#output-formats)
|
||||||
|
- [Runtime Logging](#runtime-logging)
|
||||||
- [Docker Usage](#docker-usage)
|
- [Docker Usage](#docker-usage)
|
||||||
- [Dry Run Mode](#dry-run-mode)
|
- [Dry Run Mode](#dry-run-mode)
|
||||||
- [Show Configuration](#show-configuration)
|
- [Show Configuration](#show-configuration)
|
||||||
@@ -372,7 +373,7 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
> For the complete list of 340+ template functions, see the **[Templating Guide](templating.md)**.
|
> For the complete list of 370+ template functions, see the **[Templating Guide](templating.md)**.
|
||||||
|
|
||||||
## Solving Captchas
|
## Solving Captchas
|
||||||
|
|
||||||
@@ -719,7 +720,7 @@ proxy: socks5://proxy.example.com:1080
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
**Multiple proxies (load balanced):**
|
**Multiple proxies (randomly cycled):**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com -r 1000 -c 10 \
|
sarin -U http://example.com -r 1000 -c 10 \
|
||||||
@@ -836,10 +837,10 @@ output: none
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
**Quiet mode (hide progress bar):**
|
**Hide the progress bar:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com -r 1000 -c 10 -q
|
sarin -U http://example.com -r 1000 -c 10 -p none
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -849,7 +850,42 @@ sarin -U http://example.com -r 1000 -c 10 -q
|
|||||||
url: http://example.com
|
url: http://example.com
|
||||||
requests: 1000
|
requests: 1000
|
||||||
concurrency: 10
|
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 request and generation errors, `info` covers every completed response (status, duration, headers, body). Logs appear in the progress log box on an interactive terminal, go to stderr when piped, or go to a file with `--log-file`.
|
||||||
|
|
||||||
|
**Log responses and errors:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com -r 1000 -c 10 -l info,error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Write logs to a file (the progress bar stays on screen):**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com -r 1000 -c 10 -l info --log-file ./run.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Capture logs while keeping results on stdout:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com -r 1000 -l info -o json > stats.json 2> run.log
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
logLevel: info,error
|
||||||
|
logFile: ./run.log
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -1051,3 +1087,139 @@ js: "@./scripts/body.js"
|
|||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</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;
|
||||||
|
}'
|
||||||
|
```
|
||||||
+188
-152
@@ -4,7 +4,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
|
|||||||
|
|
||||||
> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates.
|
> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates.
|
||||||
|
|
||||||
> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take.
|
> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take. Downloads have their own limits: 30 seconds and 64MiB per file.
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
@@ -183,19 +183,19 @@ body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
|
|||||||
|
|
||||||
# Mixed text fields and files
|
# Mixed text fields and files
|
||||||
body: |
|
body: |
|
||||||
{{ body_FormData
|
{{ body_FormData
|
||||||
"title" "My Report"
|
"title" "My Report"
|
||||||
"author" "John Doe"
|
"author" "John Doe"
|
||||||
"cover" "@/path/to/cover.jpg"
|
"cover" "@/path/to/cover.jpg"
|
||||||
"document" "@/path/to/report.pdf"
|
"document" "@/path/to/report.pdf"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
# Multiple files with same field name
|
# Multiple files with same field name
|
||||||
body: |
|
body: |
|
||||||
{{ body_FormData
|
{{ body_FormData
|
||||||
"files" "@/path/to/file1.pdf"
|
"files" "@/path/to/file1.pdf"
|
||||||
"files" "@/path/to/file2.pdf"
|
"files" "@/path/to/file2.pdf"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
# Escape @ for literal value (sends "@username")
|
# Escape @ for literal value (sends "@username")
|
||||||
body: '{{ body_FormData "twitter" "@@username" }}'
|
body: '{{ body_FormData "twitter" "@@username" }}'
|
||||||
@@ -226,7 +226,7 @@ body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "docume
|
|||||||
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}'
|
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}'
|
||||||
|
|
||||||
# Combined with values for reuse
|
# Combined with values for reuse
|
||||||
values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}"
|
values: 'FILE_DATA={{ file_Base64 "/path/to/file.bin" }}'
|
||||||
body: '{"data": "{{ .Values.FILE_DATA }}"}'
|
body: '{"data": "{{ .Values.FILE_DATA }}"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ body: '{"data": "{{ .Values.FILE_DATA }}"}'
|
|||||||
|
|
||||||
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.
|
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.
|
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:**
|
> **Important: performance and cost:**
|
||||||
>
|
>
|
||||||
@@ -249,7 +249,7 @@ The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `ca
|
|||||||
|
|
||||||
### 2Captcha
|
### 2Captcha
|
||||||
|
|
||||||
Functions for the [2Captcha](https://2captcha.com) service. Note: 2Captcha **does not currently support hCaptcha** through their API.
|
Functions for the [2Captcha](https://2captcha.com) service. Sarin supports reCAPTCHA v2, reCAPTCHA v3, and Cloudflare Turnstile through this integration.
|
||||||
|
|
||||||
| Function | Description |
|
| Function | Description |
|
||||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||||
@@ -259,7 +259,7 @@ Functions for the [2Captcha](https://2captcha.com) service. Note: 2Captcha **doe
|
|||||||
|
|
||||||
### Anti-Captcha
|
### Anti-Captcha
|
||||||
|
|
||||||
Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is currently the only service that supports all four captcha types end-to-end.
|
Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is the only Sarin captcha integration that exposes an hCaptcha function.
|
||||||
|
|
||||||
| Function | Description |
|
| Function | Description |
|
||||||
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -270,7 +270,7 @@ Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is curr
|
|||||||
|
|
||||||
### CapSolver
|
### CapSolver
|
||||||
|
|
||||||
Functions for the [CapSolver](https://capsolver.com) service. Note: CapSolver no longer supports hCaptcha.
|
Functions for the [CapSolver](https://capsolver.com) service. Sarin supports reCAPTCHA v2, reCAPTCHA v3, and Cloudflare Turnstile through this integration.
|
||||||
|
|
||||||
| Function | Description |
|
| Function | Description |
|
||||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
@@ -302,7 +302,7 @@ body: |
|
|||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# hCaptcha via Anti-Captcha (the only service that still supports it)
|
# hCaptcha via Anti-Captcha
|
||||||
method: POST
|
method: POST
|
||||||
url: https://example.com/protected
|
url: https://example.com/protected
|
||||||
body: |
|
body: |
|
||||||
@@ -325,10 +325,10 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
### File
|
### File
|
||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| ---------------------- | -------------- | -------------------- |
|
| ---------------------- | -------------- | --------------------- |
|
||||||
| `fakeit_FileExtension` | File extension | `"nes"` |
|
| `fakeit_FileExtension` | File extension | `"nes"` |
|
||||||
| `fakeit_FileMimeType` | MIME type | `"application/json"` |
|
| `fakeit_FileMimeType` | MIME type | `"application/x-wri"` |
|
||||||
|
|
||||||
### ID
|
### ID
|
||||||
|
|
||||||
@@ -347,7 +347,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| `fakeit_ProductFeature` | Product feature | `"ultra-lightweight"` |
|
| `fakeit_ProductFeature` | Product feature | `"ultra-lightweight"` |
|
||||||
| `fakeit_ProductMaterial` | Product material | `"brass"` |
|
| `fakeit_ProductMaterial` | Product material | `"brass"` |
|
||||||
| `fakeit_ProductUPC` | UPC code | `"012780949980"` |
|
| `fakeit_ProductUPC` | UPC code | `"012780949980"` |
|
||||||
| `fakeit_ProductAudience` | Target audience | `["adults"]` |
|
| `fakeit_ProductAudience` | Target audience | `[adults]` |
|
||||||
| `fakeit_ProductDimension` | Product dimension | `"medium"` |
|
| `fakeit_ProductDimension` | Product dimension | `"medium"` |
|
||||||
| `fakeit_ProductUseCase` | Use case | `"home"` |
|
| `fakeit_ProductUseCase` | Use case | `"home"` |
|
||||||
| `fakeit_ProductBenefit` | Product benefit | `"comfort"` |
|
| `fakeit_ProductBenefit` | Product benefit | `"comfort"` |
|
||||||
@@ -356,23 +356,25 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
### Person
|
### Person
|
||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| ----------------------- | ---------------------- | ------------------------ |
|
| ----------------------- | ---------------------- | ------------------------------------------------------------------- |
|
||||||
| `fakeit_Name` | Full name | `"Markus Moen"` |
|
| `fakeit_Name` | Full name | `"Priscilla Thornton"` |
|
||||||
| `fakeit_NamePrefix` | Name prefix | `"Mr."` |
|
| `fakeit_NamePrefix` | Name prefix | `"Mr."` |
|
||||||
| `fakeit_NameSuffix` | Name suffix | `"Jr."` |
|
| `fakeit_NameSuffix` | Name suffix | `"Jr."` |
|
||||||
| `fakeit_FirstName` | First name | `"Markus"` |
|
| `fakeit_FirstName` | First name | `"Priscilla"` |
|
||||||
| `fakeit_MiddleName` | Middle name | `"Belinda"` |
|
| `fakeit_MiddleName` | Middle name | `"Belinda"` |
|
||||||
| `fakeit_LastName` | Last name | `"Daniel"` |
|
| `fakeit_LastName` | Last name | `"Daniel"` |
|
||||||
| `fakeit_Gender` | Gender | `"male"` |
|
| `fakeit_Gender` | Gender | `"male"` |
|
||||||
| `fakeit_Age` | Age | `40` |
|
| `fakeit_Age` | Age | `40` |
|
||||||
| `fakeit_Ethnicity` | Ethnicity | `"German"` |
|
| `fakeit_Ethnicity` | Ethnicity | `"German"` |
|
||||||
| `fakeit_SSN` | Social Security Number | `"296446360"` |
|
| `fakeit_SSN` | Social Security Number | `"296446360"` |
|
||||||
| `fakeit_EIN` | Employer ID Number | `"12-3456789"` |
|
| `fakeit_EIN` | Employer ID Number | `"12-3456789"` |
|
||||||
| `fakeit_Hobby` | Hobby | `"Swimming"` |
|
| `fakeit_Hobby` | Hobby | `"Swimming"` |
|
||||||
| `fakeit_Email` | Email address | `"markusmoen@pagac.net"` |
|
| `fakeit_SocialMedia` | Social media profile | `"https://twitter.com/ImpossibleTrousers"` |
|
||||||
| `fakeit_Phone` | Phone number | `"6136459948"` |
|
| `fakeit_Bio` | Biography | `"Born in New York, John grew up to become a Software Engineer..."` |
|
||||||
| `fakeit_PhoneFormatted` | Formatted phone | `"136-459-9489"` |
|
| `fakeit_Email` | Email address | `"priscilla.thornton@duncan.biz"` |
|
||||||
|
| `fakeit_Phone` | Phone number | `"6136459948"` |
|
||||||
|
| `fakeit_PhoneFormatted` | Formatted phone | `"136-459-9489"` |
|
||||||
|
|
||||||
### Generate
|
### Generate
|
||||||
|
|
||||||
@@ -391,7 +393,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- |
|
| --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- |
|
||||||
| `fakeit_City` | City name | `"Marcelside"` |
|
| `fakeit_City` | City name | `"Reno"` |
|
||||||
| `fakeit_Country` | Country name | `"United States of America"` |
|
| `fakeit_Country` | Country name | `"United States of America"` |
|
||||||
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
|
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
|
||||||
| `fakeit_State` | State name | `"Illinois"` |
|
| `fakeit_State` | State name | `"Illinois"` |
|
||||||
@@ -400,7 +402,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| `fakeit_StreetName` | Street name | `"View"` |
|
| `fakeit_StreetName` | Street name | `"View"` |
|
||||||
| `fakeit_StreetNumber` | Street number | `"13645"` |
|
| `fakeit_StreetNumber` | Street number | `"13645"` |
|
||||||
| `fakeit_StreetPrefix` | Street prefix | `"East"` |
|
| `fakeit_StreetPrefix` | Street prefix | `"East"` |
|
||||||
| `fakeit_StreetSuffix` | Street suffix | `"Ave"` |
|
| `fakeit_StreetSuffix` | Street suffix | `"side"` |
|
||||||
| `fakeit_Unit` | Unit | `"Apt 123"` |
|
| `fakeit_Unit` | Unit | `"Apt 123"` |
|
||||||
| `fakeit_Zip` | ZIP code | `"13645"` |
|
| `fakeit_Zip` | ZIP code | `"13645"` |
|
||||||
| `fakeit_Latitude` | Random latitude | `-73.534056` |
|
| `fakeit_Latitude` | Random latitude | `-73.534056` |
|
||||||
@@ -408,11 +410,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_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` |
|
| `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
|
### Game
|
||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| ----------------- | ----------- | ------------------- |
|
| ----------------- | ----------- | ----------------- |
|
||||||
| `fakeit_Gamertag` | Gamer tag | `"footinterpret63"` |
|
| `fakeit_Gamertag` | Gamer tag | `"TurkeyThinker"` |
|
||||||
|
|
||||||
### Beer
|
### Beer
|
||||||
|
|
||||||
@@ -443,18 +457,22 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| ---------------------------------- | --------------------------- | ---------------- |
|
| ---------------------------------- | --------------------------- | ---------------- |
|
||||||
| `fakeit_Word` | Random word | `"example"` |
|
| `fakeit_Word` | Random word | `"example"` |
|
||||||
| `fakeit_Noun` | Random noun | `"computer"` |
|
| `fakeit_Noun` | Random noun | `"computer"` |
|
||||||
| `fakeit_NounCommon` | Common noun | `"table"` |
|
| `fakeit_NounCommon` | Common noun | `"group"` |
|
||||||
| `fakeit_NounConcrete` | Concrete noun | `"chair"` |
|
| `fakeit_NounConcrete` | Concrete noun | `"chair"` |
|
||||||
| `fakeit_NounAbstract` | Abstract noun | `"freedom"` |
|
| `fakeit_NounAbstract` | Abstract noun | `"freedom"` |
|
||||||
| `fakeit_NounCollectivePeople` | Collective noun (people) | `"team"` |
|
| `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_NounCollectiveThing` | Collective noun (thing) | `"bunch"` |
|
||||||
| `fakeit_NounCountable` | Countable noun | `"book"` |
|
| `fakeit_NounCountable` | Countable noun | `"book"` |
|
||||||
| `fakeit_NounUncountable` | Uncountable noun | `"water"` |
|
| `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_VerbAction` | Action verb | `"jump"` |
|
||||||
| `fakeit_VerbLinking` | Linking verb | `"is"` |
|
| `fakeit_VerbLinking` | Linking verb | `"is"` |
|
||||||
| `fakeit_VerbHelping` | Helping verb | `"can"` |
|
| `fakeit_VerbHelping` | Helping verb | `"can"` |
|
||||||
|
| `fakeit_VerbTransitive` | Transitive verb | `"follow"` |
|
||||||
|
| `fakeit_VerbIntransitive` | Intransitive verb | `"laugh"` |
|
||||||
| `fakeit_Adverb` | Random adverb | `"quickly"` |
|
| `fakeit_Adverb` | Random adverb | `"quickly"` |
|
||||||
| `fakeit_AdverbManner` | Manner adverb | `"carefully"` |
|
| `fakeit_AdverbManner` | Manner adverb | `"carefully"` |
|
||||||
| `fakeit_AdverbDegree` | Degree adverb | `"very"` |
|
| `fakeit_AdverbDegree` | Degree adverb | `"very"` |
|
||||||
@@ -468,7 +486,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| `fakeit_PrepositionDouble` | Double preposition | `"out of"` |
|
| `fakeit_PrepositionDouble` | Double preposition | `"out of"` |
|
||||||
| `fakeit_PrepositionCompound` | Compound preposition | `"according to"` |
|
| `fakeit_PrepositionCompound` | Compound preposition | `"according to"` |
|
||||||
| `fakeit_Adjective` | Random adjective | `"beautiful"` |
|
| `fakeit_Adjective` | Random adjective | `"beautiful"` |
|
||||||
| `fakeit_AdjectiveDescriptive` | Descriptive adjective | `"large"` |
|
| `fakeit_AdjectiveDescriptive` | Descriptive adjective | `"tired"` |
|
||||||
| `fakeit_AdjectiveQuantitative` | Quantitative adjective | `"many"` |
|
| `fakeit_AdjectiveQuantitative` | Quantitative adjective | `"many"` |
|
||||||
| `fakeit_AdjectiveProper` | Proper adjective | `"American"` |
|
| `fakeit_AdjectiveProper` | Proper adjective | `"American"` |
|
||||||
| `fakeit_AdjectiveDemonstrative` | Demonstrative adjective | `"this"` |
|
| `fakeit_AdjectiveDemonstrative` | Demonstrative adjective | `"this"` |
|
||||||
@@ -483,26 +501,33 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| `fakeit_PronounDemonstrative` | Demonstrative pronoun | `"that"` |
|
| `fakeit_PronounDemonstrative` | Demonstrative pronoun | `"that"` |
|
||||||
| `fakeit_PronounInterrogative` | Interrogative pronoun | `"who"` |
|
| `fakeit_PronounInterrogative` | Interrogative pronoun | `"who"` |
|
||||||
| `fakeit_PronounRelative` | Relative pronoun | `"which"` |
|
| `fakeit_PronounRelative` | Relative pronoun | `"which"` |
|
||||||
|
| `fakeit_PronounIndefinite` | Indefinite pronoun | `"few"` |
|
||||||
| `fakeit_Connective` | Random connective | `"however"` |
|
| `fakeit_Connective` | Random connective | `"however"` |
|
||||||
| `fakeit_ConnectiveTime` | Time connective | `"then"` |
|
| `fakeit_ConnectiveTime` | Time connective | `"then"` |
|
||||||
| `fakeit_ConnectiveComparative` | Comparative connective | `"similarly"` |
|
| `fakeit_ConnectiveComparative` | Comparative connective | `"after all"` |
|
||||||
| `fakeit_ConnectiveComplaint` | Complaint connective | `"although"` |
|
| `fakeit_ConnectiveComplaint` | Complaint connective | `"i.e."` |
|
||||||
| `fakeit_ConnectiveListing` | Listing connective | `"firstly"` |
|
| `fakeit_ConnectiveListing` | Listing connective | `"firstly"` |
|
||||||
| `fakeit_ConnectiveCasual` | Casual connective | `"because"` |
|
| `fakeit_ConnectiveCasual` | Casual connective | `"because"` |
|
||||||
| `fakeit_ConnectiveExamplify` | Examplify connective | `"for example"` |
|
| `fakeit_ConnectiveExamplify` | Examplify connective | `"for example"` |
|
||||||
|
| `fakeit_Interjection` | Interjection | `"wow"` |
|
||||||
|
|
||||||
### Text
|
### Text
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------- |
|
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
|
||||||
| `fakeit_Sentence(wordCount ...int)` | Random sentence (optional word count) | `{{ fakeit_Sentence }}` or `{{ fakeit_Sentence 10 }}` |
|
| `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` |
|
||||||
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
|
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
|
||||||
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` |
|
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"eveniet"` |
|
||||||
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
|
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
|
||||||
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
||||||
| `fakeit_Question` | Random question | `"What is your name?"` |
|
| `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` |
|
||||||
| `fakeit_Quote` | Random quote | `"Life is what happens..."` |
|
| `fakeit_Quote` | Random quote | `"Energy is contagious so is fear"` |
|
||||||
| `fakeit_Phrase` | Random phrase | `"a piece of cake"` |
|
| `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
|
### Foods
|
||||||
|
|
||||||
@@ -515,6 +540,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| `fakeit_Dinner` | Dinner food | `"Wild addicting dip"` |
|
| `fakeit_Dinner` | Dinner food | `"Wild addicting dip"` |
|
||||||
| `fakeit_Snack` | Snack | `"Trail mix"` |
|
| `fakeit_Snack` | Snack | `"Trail mix"` |
|
||||||
| `fakeit_Dessert` | Dessert | `"French napoleons"` |
|
| `fakeit_Dessert` | Dessert | `"French napoleons"` |
|
||||||
|
| `fakeit_Drink` | Drink | `"Soda"` |
|
||||||
|
|
||||||
### Misc
|
### Misc
|
||||||
|
|
||||||
@@ -525,20 +551,23 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
### Color
|
### Color
|
||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| ------------------- | ------------------ | --------------------------------------------------------- |
|
| ------------------- | ------------------ | ------------------------------------------- |
|
||||||
| `fakeit_Color` | Color name | `"MediumOrchid"` |
|
| `fakeit_Color` | Color name | `"MediumOrchid"` |
|
||||||
| `fakeit_HexColor` | Hex color | `"#a99fb4"` |
|
| `fakeit_HexColor` | Hex color | `"#a99fb4"` |
|
||||||
| `fakeit_RGBColor` | RGB color | `[85, 224, 195]` |
|
| `fakeit_RGBColor` | RGB color | `[85 224 195]` |
|
||||||
| `fakeit_SafeColor` | Safe color | `"black"` |
|
| `fakeit_HSLColor` | HSL color | `[210 85 50]` |
|
||||||
| `fakeit_NiceColors` | Nice color palette | `["#cfffdd", "#b4dec1", "#5c5863", "#a85163", "#ff1f4c"]` |
|
| `fakeit_SafeColor` | Safe color | `"black"` |
|
||||||
|
| `fakeit_NiceColors` | Nice color palette | `[#cfffdd #b4dec1 #5c5863 #a85163 #ff1f4c]` |
|
||||||
|
|
||||||
### Image
|
### Image
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| ----------------------------------------- | ------------------------- | -------------------------------- |
|
| ----------------------------------------- | -------------------------- | -------------------------------- |
|
||||||
| `fakeit_ImageJpeg(width int, height int)` | Generate JPEG image bytes | `{{ fakeit_ImageJpeg 100 100 }}` |
|
| `fakeit_ImageJpeg(width int, height int)` | Generate a JPEG byte slice | `{{ fakeit_ImageJpeg 100 100 }}` |
|
||||||
| `fakeit_ImagePng(width int, height int)` | Generate PNG image bytes | `{{ fakeit_ImagePng 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
|
### Internet
|
||||||
|
|
||||||
@@ -567,16 +596,16 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| ------------------ | --------------- | ------------------ |
|
| ------------------ | --------------- | ------------------ |
|
||||||
| `fakeit_InputName` | HTML input name | `"email"` |
|
| `fakeit_InputName` | HTML input name | `"message"` |
|
||||||
| `fakeit_Svg` | SVG image | `"<svg>...</svg>"` |
|
| `fakeit_Svg` | SVG image | `"<svg>...</svg>"` |
|
||||||
|
|
||||||
### Date/Time
|
### Date/Time
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| -------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ |
|
| -------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ |
|
||||||
| `fakeit_Date` | Random date | `2023-06-15 14:30:00` |
|
| `fakeit_Date` | Random date | `2013-11-07 04:31:13.726582492 +0000 UTC` |
|
||||||
| `fakeit_PastDate` | Past date | `2022-03-10 09:15:00` |
|
| `fakeit_PastDate` | Date 1–12 hours before now | Current time − 1–12 hours |
|
||||||
| `fakeit_FutureDate` | Future date | `2025-12-20 18:45:00` |
|
| `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_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_NanoSecond` | Nanosecond | `123456789` |
|
||||||
| `fakeit_Second` | Second (0-59) | `45` |
|
| `fakeit_Second` | Second (0-59) | `45` |
|
||||||
@@ -587,11 +616,11 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| `fakeit_Day` | Day (1-31) | `15` |
|
| `fakeit_Day` | Day (1-31) | `15` |
|
||||||
| `fakeit_WeekDay` | Weekday | `"Monday"` |
|
| `fakeit_WeekDay` | Weekday | `"Monday"` |
|
||||||
| `fakeit_Year` | Year | `2024` |
|
| `fakeit_Year` | Year | `2024` |
|
||||||
| `fakeit_TimeZone` | Timezone | `"America/New_York"` |
|
| `fakeit_TimeZone` | Timezone name | `"West Pacific Standard Time"` |
|
||||||
| `fakeit_TimeZoneAbv` | Timezone abbreviation | `"EST"` |
|
| `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_TimeZoneOffset` | Timezone offset | `-5` |
|
||||||
| `fakeit_TimeZoneRegion` | Timezone region | `"America"` |
|
| `fakeit_TimeZoneRegion` | Timezone region | `"Indian/Chagos"` |
|
||||||
|
|
||||||
### Payment
|
### Payment
|
||||||
|
|
||||||
@@ -607,8 +636,8 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| `fakeit_AchRouting` | ACH routing number | `"513715684"` |
|
| `fakeit_AchRouting` | ACH routing number | `"513715684"` |
|
||||||
| `fakeit_AchAccount` | ACH account number | `"491527954328"` |
|
| `fakeit_AchAccount` | ACH account number | `"491527954328"` |
|
||||||
| `fakeit_BitcoinAddress` | Bitcoin address | `"1BoatSLRHtKNngkdXEeobR76b53LETtpyT"` |
|
| `fakeit_BitcoinAddress` | Bitcoin address | `"1BoatSLRHtKNngkdXEeobR76b53LETtpyT"` |
|
||||||
| `fakeit_BitcoinPrivateKey` | Bitcoin private key | `"5HueCGU8rMjxEXxiPuD5BDuG6o5xjA7QkbPp"` |
|
| `fakeit_BitcoinPrivateKey` | Bitcoin private key | `"5JMZxkQX2PgaasaHc8wnWLNdMu7rxeU7xS64ev7RWNinacicPfm"` |
|
||||||
| `fakeit_BankName` | Bank name | `"Wells Fargo"` |
|
| `fakeit_BankName` | Bank name | `"Toronto Dominion Bank"` |
|
||||||
| `fakeit_BankType` | Bank type | `"Investment Bank"` |
|
| `fakeit_BankType` | Bank type | `"Investment Bank"` |
|
||||||
|
|
||||||
### Finance
|
### Finance
|
||||||
@@ -620,17 +649,17 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
### Company
|
### Company
|
||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| ---------------------- | -------------- | ------------------------------------------ |
|
| ---------------------- | -------------- | ---------------------- |
|
||||||
| `fakeit_BS` | Business speak | `"front-end"` |
|
| `fakeit_BS` | Business speak | `"front-end"` |
|
||||||
| `fakeit_Blurb` | Company blurb | `"word"` |
|
| `fakeit_Blurb` | Company blurb | `"Teamwork"` |
|
||||||
| `fakeit_BuzzWord` | Buzzword | `"disintermediate"` |
|
| `fakeit_BuzzWord` | Buzzword | `"disintermediate"` |
|
||||||
| `fakeit_Company` | Company name | `"Moen, Pagac and Wuckert"` |
|
| `fakeit_Company` | Company name | `"TransparaGov"` |
|
||||||
| `fakeit_CompanySuffix` | Company suffix | `"Inc"` |
|
| `fakeit_CompanySuffix` | Company suffix | `"Inc"` |
|
||||||
| `fakeit_JobDescriptor` | Job descriptor | `"Central"` |
|
| `fakeit_JobDescriptor` | Job descriptor | `"Central"` |
|
||||||
| `fakeit_JobLevel` | Job level | `"Assurance"` |
|
| `fakeit_JobLevel` | Job level | `"Assurance"` |
|
||||||
| `fakeit_JobTitle` | Job title | `"Director"` |
|
| `fakeit_JobTitle` | Job title | `"Director"` |
|
||||||
| `fakeit_Slogan` | Company slogan | `"Universal seamless Focus, interactive."` |
|
| `fakeit_Slogan` | Company slogan | `"Made of styrofoam."` |
|
||||||
|
|
||||||
### Hacker
|
### Hacker
|
||||||
|
|
||||||
@@ -695,9 +724,9 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| `fakeit_EmojiTools` | Tools emoji | `"🔧"` |
|
| `fakeit_EmojiTools` | Tools emoji | `"🔧"` |
|
||||||
| `fakeit_EmojiWeather` | Weather emoji | `"☀️"` |
|
| `fakeit_EmojiWeather` | Weather emoji | `"☀️"` |
|
||||||
| `fakeit_EmojiJob` | Job emoji | `"👨💻"` |
|
| `fakeit_EmojiJob` | Job emoji | `"👨💻"` |
|
||||||
| `fakeit_EmojiPerson` | Person emoji | `"👤"` |
|
| `fakeit_EmojiPerson` | Person emoji | `"🧓"` |
|
||||||
| `fakeit_EmojiGesture` | Gesture emoji | `"🙌"` |
|
| `fakeit_EmojiGesture` | Gesture emoji | `"🤦♀️"` |
|
||||||
| `fakeit_EmojiCostume` | Costume emoji | `"🎃"` |
|
| `fakeit_EmojiCostume` | Costume emoji | `"🧞"` |
|
||||||
| `fakeit_EmojiSentence` | Emoji sentence with random emojis interspersed | `"Weekends reserve time for 🖼️ Disc 🏨 golf and day."` |
|
| `fakeit_EmojiSentence` | Emoji sentence with random emojis interspersed | `"Weekends reserve time for 🖼️ Disc 🏨 golf and day."` |
|
||||||
|
|
||||||
### Language
|
### Language
|
||||||
@@ -706,31 +735,35 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
| ----------------------------- | --------------------- | -------------- |
|
| ----------------------------- | --------------------- | -------------- |
|
||||||
| `fakeit_Language` | Language | `"English"` |
|
| `fakeit_Language` | Language | `"English"` |
|
||||||
| `fakeit_LanguageAbbreviation` | Language abbreviation | `"en"` |
|
| `fakeit_LanguageAbbreviation` | Language abbreviation | `"en"` |
|
||||||
|
| `fakeit_LanguageBCP` | BCP language tag | `"en-US"` |
|
||||||
| `fakeit_ProgrammingLanguage` | Programming language | `"Go"` |
|
| `fakeit_ProgrammingLanguage` | Programming language | `"Go"` |
|
||||||
|
|
||||||
### Number
|
### Number
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| ----------------------------------------------- | ----------------------------------- | ------------------------------------------ |
|
| ----------------------------------------------- | ----------------------------------- | -------------------------------------------- |
|
||||||
| `fakeit_Number(min int, max int)` | Random number in range | `{{ fakeit_Number 1 100 }}` → `42` |
|
| `fakeit_Number(min int, max int)` | Random number in range | `{{ fakeit_Number 1 100 }}` → `42` |
|
||||||
| `fakeit_Int` | Random int | `{{ fakeit_Int }}` |
|
| `fakeit_Int` | Random int | `{{ fakeit_Int }}` |
|
||||||
| `fakeit_IntN(n int)` | Random int from 0 to n | `{{ fakeit_IntN 100 }}` |
|
| `fakeit_IntN(n int)` | Random int in `[0, n)` | `{{ fakeit_IntN 100 }}` |
|
||||||
| `fakeit_Int8` | Random int8 | `{{ fakeit_Int8 }}` |
|
| `fakeit_Int8` | Random int8 | `{{ fakeit_Int8 }}` |
|
||||||
| `fakeit_Int16` | Random int16 | `{{ fakeit_Int16 }}` |
|
| `fakeit_Int16` | Random int16 | `{{ fakeit_Int16 }}` |
|
||||||
| `fakeit_Int32` | Random int32 | `{{ fakeit_Int32 }}` |
|
| `fakeit_Int32` | Random int32 | `{{ fakeit_Int32 }}` |
|
||||||
| `fakeit_Int64` | Random int64 | `{{ fakeit_Int64 }}` |
|
| `fakeit_Int64` | Random int64 | `{{ fakeit_Int64 }}` |
|
||||||
| `fakeit_Uint` | Random uint | `{{ fakeit_Uint }}` |
|
| `fakeit_IntRange(min int, max int)` | Random int in range | `{{ fakeit_IntRange -10 10 }}` |
|
||||||
| `fakeit_UintN(n uint)` | Random uint from 0 to n | `{{ fakeit_UintN 100 }}` |
|
| `fakeit_Uint` | Random uint | `{{ fakeit_Uint }}` |
|
||||||
| `fakeit_Uint8` | Random uint8 | `{{ fakeit_Uint8 }}` |
|
| `fakeit_UintN(n uint)` | Random uint in `[0, n)` | `{{ fakeit_UintN 100 }}` |
|
||||||
| `fakeit_Uint16` | Random uint16 | `{{ fakeit_Uint16 }}` |
|
| `fakeit_Uint8` | Random uint8 | `{{ fakeit_Uint8 }}` |
|
||||||
| `fakeit_Uint32` | Random uint32 | `{{ fakeit_Uint32 }}` |
|
| `fakeit_Uint16` | Random uint16 | `{{ fakeit_Uint16 }}` |
|
||||||
| `fakeit_Uint64` | Random uint64 | `{{ fakeit_Uint64 }}` |
|
| `fakeit_Uint32` | Random uint32 | `{{ fakeit_Uint32 }}` |
|
||||||
| `fakeit_Float32` | Random float32 | `{{ fakeit_Float32 }}` |
|
| `fakeit_Uint64` | Random uint64 | `{{ fakeit_Uint64 }}` |
|
||||||
| `fakeit_Float32Range(min float32, max float32)` | Random float32 in range | `{{ fakeit_Float32Range 0 100 }}` |
|
| `fakeit_UintRange(min uint, max uint)` | Random uint in range | `{{ fakeit_UintRange 10 100 }}` |
|
||||||
| `fakeit_Float64` | Random float64 | `{{ fakeit_Float64 }}` |
|
| `fakeit_Float32` | Random float32 | `{{ fakeit_Float32 }}` |
|
||||||
| `fakeit_Float64Range(min float64, max float64)` | Random float64 in range | `{{ fakeit_Float64Range 0 100 }}` |
|
| `fakeit_Float32Range(min float32, max float32)` | Random float32 in range | `{{ fakeit_Float32Range 0 100 }}` |
|
||||||
| `fakeit_RandomInt(slice []int)` | Random int from slice | `{{ fakeit_RandomInt (slice_Int 1 2 3) }}` |
|
| `fakeit_Float64` | Random float64 | `{{ fakeit_Float64 }}` |
|
||||||
| `fakeit_HexUint(bits int)` | Random hex uint with specified bits | `{{ fakeit_HexUint 8 }}` → `"0xff"` |
|
| `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
|
### String
|
||||||
|
|
||||||
@@ -740,6 +773,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_DigitN(n uint)` | Generate `n` random digits | `{{ fakeit_DigitN 5 }}` → `"71364"` |
|
||||||
| `fakeit_Letter` | Single random letter | `"g"` |
|
| `fakeit_Letter` | Single random letter | `"g"` |
|
||||||
| `fakeit_LetterN(n uint)` | Generate `n` random letters | `{{ fakeit_LetterN 10 }}` → `"gbRMaRxHki"` |
|
| `fakeit_LetterN(n uint)` | Generate `n` random letters | `{{ fakeit_LetterN 10 }}` → `"gbRMaRxHki"` |
|
||||||
|
| `fakeit_Vowel` | Single lowercase vowel | `"a"` |
|
||||||
| `fakeit_Lexify(pattern string)` | Replace `?` with random letters | `{{ fakeit_Lexify "?????@??????.com" }}` → `"billy@mister.com"` |
|
| `fakeit_Lexify(pattern string)` | Replace `?` with random letters | `{{ fakeit_Lexify "?????@??????.com" }}` → `"billy@mister.com"` |
|
||||||
| `fakeit_Numerify(pattern string)` | Replace `#` with random digits | `{{ fakeit_Numerify "(###)###-####" }}` → `"(555)867-5309"` |
|
| `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") }}` |
|
| `fakeit_RandomString(slice []string)` | Random string from slice | `{{ fakeit_RandomString (slice_Str "a" "b" "c") }}` |
|
||||||
@@ -754,26 +788,26 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
### Minecraft
|
### Minecraft
|
||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| --------------------------------- | ----------------- | ---------------- |
|
| --------------------------------- | ----------------- | ------------------- |
|
||||||
| `fakeit_MinecraftOre` | Minecraft ore | `"coal"` |
|
| `fakeit_MinecraftOre` | Minecraft ore | `"coal"` |
|
||||||
| `fakeit_MinecraftWood` | Minecraft wood | `"oak"` |
|
| `fakeit_MinecraftWood` | Minecraft wood | `"oak"` |
|
||||||
| `fakeit_MinecraftArmorTier` | Armor tier | `"iron"` |
|
| `fakeit_MinecraftArmorTier` | Armor tier | `"iron"` |
|
||||||
| `fakeit_MinecraftArmorPart` | Armor part | `"helmet"` |
|
| `fakeit_MinecraftArmorPart` | Armor part | `"helmet"` |
|
||||||
| `fakeit_MinecraftWeapon` | Minecraft weapon | `"bow"` |
|
| `fakeit_MinecraftWeapon` | Minecraft weapon | `"bow"` |
|
||||||
| `fakeit_MinecraftTool` | Minecraft tool | `"shovel"` |
|
| `fakeit_MinecraftTool` | Minecraft tool | `"shovel"` |
|
||||||
| `fakeit_MinecraftDye` | Minecraft dye | `"white"` |
|
| `fakeit_MinecraftDye` | Minecraft dye | `"white"` |
|
||||||
| `fakeit_MinecraftFood` | Minecraft food | `"apple"` |
|
| `fakeit_MinecraftFood` | Minecraft food | `"apple"` |
|
||||||
| `fakeit_MinecraftAnimal` | Minecraft animal | `"chicken"` |
|
| `fakeit_MinecraftAnimal` | Minecraft animal | `"chicken"` |
|
||||||
| `fakeit_MinecraftVillagerJob` | Villager job | `"farmer"` |
|
| `fakeit_MinecraftVillagerJob` | Villager job | `"farmer"` |
|
||||||
| `fakeit_MinecraftVillagerStation` | Villager station | `"furnace"` |
|
| `fakeit_MinecraftVillagerStation` | Villager station | `"stonecutter"` |
|
||||||
| `fakeit_MinecraftVillagerLevel` | Villager level | `"master"` |
|
| `fakeit_MinecraftVillagerLevel` | Villager level | `"master"` |
|
||||||
| `fakeit_MinecraftMobPassive` | Passive mob | `"cow"` |
|
| `fakeit_MinecraftMobPassive` | Passive mob | `"cow"` |
|
||||||
| `fakeit_MinecraftMobNeutral` | Neutral mob | `"bee"` |
|
| `fakeit_MinecraftMobNeutral` | Neutral mob | `"bee"` |
|
||||||
| `fakeit_MinecraftMobHostile` | Hostile mob | `"spider"` |
|
| `fakeit_MinecraftMobHostile` | Hostile mob | `"wither skeleton"` |
|
||||||
| `fakeit_MinecraftMobBoss` | Boss mob | `"ender dragon"` |
|
| `fakeit_MinecraftMobBoss` | Boss mob | `"ender dragon"` |
|
||||||
| `fakeit_MinecraftBiome` | Minecraft biome | `"forest"` |
|
| `fakeit_MinecraftBiome` | Minecraft biome | `"forest"` |
|
||||||
| `fakeit_MinecraftWeather` | Minecraft weather | `"rain"` |
|
| `fakeit_MinecraftWeather` | Minecraft weather | `"rain"` |
|
||||||
|
|
||||||
### Book
|
### Book
|
||||||
|
|
||||||
@@ -792,26 +826,28 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
### Error
|
### Error
|
||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| ------------------------ | ----------------- | ---------------------------------- |
|
| ------------------------ | ----------------- | ----------------------------------------------- |
|
||||||
| `fakeit_Error` | Random error | `"connection refused"` |
|
| `fakeit_Error` | Random error | `"variable assigned before declaration"` |
|
||||||
| `fakeit_ErrorDatabase` | Database error | `"database connection failed"` |
|
| `fakeit_ErrorObject` | Error object | `"protocol"` |
|
||||||
| `fakeit_ErrorGRPC` | gRPC error | `"rpc error: code = Unavailable"` |
|
| `fakeit_ErrorDatabase` | Database error | `"destination pointer is nil"` |
|
||||||
| `fakeit_ErrorHTTP` | HTTP error | `"HTTP 500 Internal Server Error"` |
|
| `fakeit_ErrorGRPC` | gRPC error | `"connection refused"` |
|
||||||
| `fakeit_ErrorHTTPClient` | HTTP client error | `"HTTP 404 Not Found"` |
|
| `fakeit_ErrorHTTP` | HTTP error | `"wrote more than the declared Content-Length"` |
|
||||||
| `fakeit_ErrorHTTPServer` | HTTP server error | `"HTTP 503 Service Unavailable"` |
|
| `fakeit_ErrorHTTPClient` | HTTP client error | `"expectation failed"` |
|
||||||
| `fakeit_ErrorRuntime` | Runtime error | `"panic: runtime error"` |
|
| `fakeit_ErrorHTTPServer` | HTTP server error | `"not extended"` |
|
||||||
|
| `fakeit_ErrorRuntime` | Runtime error | `"expected 2 arguments, got 3"` |
|
||||||
|
| `fakeit_ErrorValidation` | Validation error | `"missing required field"` |
|
||||||
|
|
||||||
### School
|
### School
|
||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| --------------- | ----------- | ---------------------- |
|
| --------------- | ----------- | ---------------------------- |
|
||||||
| `fakeit_School` | School name | `"Harvard University"` |
|
| `fakeit_School` | School name | `"Hillside Private Academy"` |
|
||||||
|
|
||||||
### Song
|
### Song
|
||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| ------------------- | ----------- | --------------------- |
|
| ------------------- | ----------- | ------------------------ |
|
||||||
| `fakeit_SongName` | Song name | `"Bohemian Rhapsody"` |
|
| `fakeit_SongName` | Song name | `"What Was I Made For?"` |
|
||||||
| `fakeit_SongArtist` | Song artist | `"Queen"` |
|
| `fakeit_SongArtist` | Song artist | `"Queen"` |
|
||||||
| `fakeit_SongGenre` | Song genre | `"Rock"` |
|
| `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,58 +1,53 @@
|
|||||||
module go.aykhans.me/sarin
|
module go.aykhans.me/sarin
|
||||||
|
|
||||||
go 1.26.4
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/brianvoe/gofakeit/v7 v7.15.0
|
charm.land/bubbles/v2 v2.2.1
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
charm.land/bubbletea/v2 v2.0.9
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
charm.land/glamour/v2 v2.0.1
|
||||||
github.com/charmbracelet/glamour v1.0.0
|
charm.land/lipgloss/v2 v2.0.6
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
github.com/brianvoe/gofakeit/v7 v7.17.0
|
||||||
github.com/charmbracelet/x/term v0.2.2
|
github.com/charmbracelet/x/term v0.2.2
|
||||||
github.com/dop251/goja v0.0.0-20260603143327-1f200ca63355
|
github.com/dop251/goja v0.0.0-20260911104922-fabc3b8078ad
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/valyala/fasthttp v1.71.0
|
github.com/valyala/fasthttp v1.74.0
|
||||||
github.com/yuin/gopher-lua v1.1.2
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
go.aykhans.me/utils v1.0.7
|
go.aykhans.me/utils v1.0.7
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4
|
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
golang.org/x/net v0.55.0
|
golang.org/x/net v0.59.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/chroma/v2 v2.26.1 // indirect
|
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260602025833-85a30b5e440a // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260906004030-3986e9119cf9 // 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/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dlclark/regexp2/v2 v2.1.2 // indirect
|
github.com/dlclark/regexp2/v2 v2.8.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
|
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/klauspost/compress v1.18.6 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/molecule-man/go-brrr v1.1.0 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/reflow v0.3.0 // indirect
|
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v1.0.0 // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.8.6 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/term v0.43.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -1,124 +1,110 @@
|
|||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||||
|
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
||||||
|
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||||
|
charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
|
||||||
|
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
|
||||||
|
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||||
|
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||||
|
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||||
|
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
|
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||||
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
|
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 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.15.0 h1:kGLYAWN8tnmxq2PelKVK6zwpM7kMxdz9SGPH31mFkNs=
|
github.com/brianvoe/gofakeit/v7 v7.17.0 h1:usvHa73vmAjC1JmX4dpZAm1eQkR0riXaxvilScRTFb0=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.15.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
github.com/brianvoe/gofakeit/v7 v7.17.0/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.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
|
||||||
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 h1:/hSCZO1puetbGd2JikHKN0nAcb2mFevzxJElFoduhaw=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16/go.mod h1:JFvmQPZh3uAu7PpSZU91bcR0BrIy24HhW+0EsW1uGxE=
|
||||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260906004030-3986e9119cf9 h1:zW96vcZ3zDetccerbN7sS/sCydOE/l/9S+C94pozsaU=
|
||||||
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-20260906004030-3986e9119cf9/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260602025833-85a30b5e440a h1:wjp4RGuKcbK244BiURmgle3N9HMT1ParMh96omH6Lj4=
|
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260602025833-85a30b5e440a/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/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 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
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 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2/v2 v2.1.2 h1:RJjAeD/h91npDhTG4rMD4+gzGVnLT32qQy/q/ECbyUs=
|
github.com/dlclark/regexp2/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7BbdneDfw=
|
||||||
github.com/dlclark/regexp2/v2 v2.1.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
github.com/dop251/goja v0.0.0-20260603143327-1f200ca63355 h1:QCZbLvvKw3vwkMubwAHHBIecVHyLGfyty0yf5+cx5As=
|
github.com/dop251/goja v0.0.0-20260911104922-fabc3b8078ad h1:5gT9hQ9h8CplLZfAGgFMcoLqQS6I9T2sZwQOCXy8/3s=
|
||||||
github.com/dop251/goja v0.0.0-20260603143327-1f200ca63355/go.mod h1:YkABBhNI1qP6/NuyyPL5QqJ4L28ax4m+WAywAWr9fJk=
|
github.com/dop251/goja v0.0.0-20260911104922-fabc3b8078ad/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
|
||||||
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.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
|
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY=
|
||||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||||
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.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
|
||||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/molecule-man/go-brrr v1.1.0 h1:rLBGSXA393y+5ttCBLkW34CHESop5tahrOFxVv/XDEU=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/molecule-man/go-brrr v1.1.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
|
||||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
|
github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU=
|
||||||
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
|
github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
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 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
|
||||||
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
|
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+63
-40
@@ -20,30 +20,33 @@ Simple usage:
|
|||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
General Config:
|
General Config:
|
||||||
-h, -help Help for sarin
|
-h, -help Help for sarin
|
||||||
-v, -version Version for sarin
|
-v, -version Version for sarin
|
||||||
-s, -show-config bool Show the final config after parsing all sources (default %v)
|
-s, -show-config bool Show the final config after parsing all sources (default %v)
|
||||||
-f, -config-file string Path to the config file (local file / http URL)
|
-f, -config-file []string Path to the config file (local file / http URL)
|
||||||
-c, -concurrency uint Number of concurrent requests (default %d)
|
-c, -concurrency uint Number of concurrent requests (default %d)
|
||||||
-r, -requests uint Number of total requests
|
-r, -requests uint Number of total requests
|
||||||
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
||||||
-q, -quiet bool Hide the progress bar and runtime logs (default %v)
|
-l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s)
|
||||||
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
|
-w, -log-file string Write runtime logs to this file instead of the terminal/stderr
|
||||||
-z, -dry-run bool Run without sending requests (default %v)
|
-p, -progress string Progress display (possible values: bar, none) (default '%v')
|
||||||
|
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
|
||||||
|
-z, -dry-run bool Run without sending requests (default %v)
|
||||||
|
|
||||||
Request Config:
|
Request Config:
|
||||||
-U, -url string Target URL for the request
|
-U, -url string Target URL for the request
|
||||||
-M, -method []string HTTP method for the request (default %s)
|
-M, -method []string HTTP method for the request (default %s)
|
||||||
-B, -body []string Body for the request (e.g. "body text")
|
-B, -body []string Body for the request (e.g. "body text")
|
||||||
-P, -param []string URL parameter for the request (e.g. "key1=value1")
|
-P, -param []string URL parameter for the request (e.g. "key1=value1")
|
||||||
-H, -header []string Header for the request (e.g. "key1: value1")
|
-H, -header []string Header for the request (e.g. "key1: value1")
|
||||||
-C, -cookie []string Cookie for the request (e.g. "key1=value1")
|
-C, -cookie []string Cookie for the request (e.g. "key1=value1")
|
||||||
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
|
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
|
||||||
-V, -values []string List of values for templating (e.g. "key1=value1")
|
-V, -values []string List of values for templating (e.g. "key1=value1")
|
||||||
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
|
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
|
||||||
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)
|
-S, -max-response-body size Largest response body to read (e.g. 10MiB, 2MB), 0 for no limit (default %s)
|
||||||
-lua []string Lua script for request transformation (inline or @file/@url)
|
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)
|
||||||
-js []string JavaScript script for request transformation (inline or @file/@url)`
|
-lua []string Lua script for request transformation (inline or @file/@url)
|
||||||
|
-js []string JavaScript script for request transformation (inline or @file/@url)`
|
||||||
|
|
||||||
var _ IParser = ConfigCLIParser{}
|
var _ IParser = ConfigCLIParser{}
|
||||||
|
|
||||||
@@ -88,23 +91,26 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
concurrency uint
|
concurrency uint
|
||||||
requestCount uint64
|
requestCount uint64
|
||||||
duration time.Duration
|
duration time.Duration
|
||||||
quiet bool
|
logLevel string
|
||||||
|
logFile string
|
||||||
|
progress string
|
||||||
output string
|
output string
|
||||||
dryRun bool
|
dryRun bool
|
||||||
|
|
||||||
// Request config
|
// Request config
|
||||||
urlInput string
|
urlInput string
|
||||||
methods = stringSliceArg{}
|
methods = stringSliceArg{}
|
||||||
bodies = stringSliceArg{}
|
bodies = stringSliceArg{}
|
||||||
params = stringSliceArg{}
|
params = stringSliceArg{}
|
||||||
headers = stringSliceArg{}
|
headers = stringSliceArg{}
|
||||||
cookies = stringSliceArg{}
|
cookies = stringSliceArg{}
|
||||||
proxies = stringSliceArg{}
|
proxies = stringSliceArg{}
|
||||||
values = stringSliceArg{}
|
values = stringSliceArg{}
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
insecure bool
|
maxResponseBody byteSize
|
||||||
luaScripts = stringSliceArg{}
|
insecure bool
|
||||||
jsScripts = stringSliceArg{}
|
luaScripts = stringSliceArg{}
|
||||||
|
jsScripts = stringSliceArg{}
|
||||||
)
|
)
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -127,8 +133,14 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
|
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
|
||||||
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
|
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
|
||||||
|
|
||||||
flagSet.BoolVar(&quiet, "quiet", 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.BoolVar(&quiet, "q", false, "Hide the progress bar and runtime logs")
|
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, "output", "", "Output format (possible values: table, json, yaml, none)")
|
||||||
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
|
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
|
||||||
@@ -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, "timeout", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
||||||
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
flagSet.DurationVar(&timeout, "T", 0, "Timeout for the request (e.g. 400ms, 15s, 1m10s)")
|
||||||
|
|
||||||
|
flagSet.Var(&maxResponseBody, "max-response-body", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
|
||||||
|
flagSet.Var(&maxResponseBody, "S", "Largest response body to read (e.g. 1048576, 10MiB, 2MB), 0 for no limit")
|
||||||
|
|
||||||
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
|
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
|
||||||
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
|
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
|
||||||
|
|
||||||
@@ -205,8 +220,12 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
config.Requests = new(requestCount)
|
config.Requests = new(requestCount)
|
||||||
case "duration", "d":
|
case "duration", "d":
|
||||||
config.Duration = new(duration)
|
config.Duration = new(duration)
|
||||||
case "quiet", "q":
|
case "log-level", "l":
|
||||||
config.Quiet = new(quiet)
|
config.LogLevel = new(logLevel)
|
||||||
|
case "log-file", "w":
|
||||||
|
config.LogFile = new(logFile)
|
||||||
|
case "progress", "p":
|
||||||
|
config.Progress = new(ConfigProgressType(progress))
|
||||||
case "output", "o":
|
case "output", "o":
|
||||||
config.Output = new(ConfigOutputType(output))
|
config.Output = new(ConfigOutputType(output))
|
||||||
case "dry-run", "z":
|
case "dry-run", "z":
|
||||||
@@ -244,6 +263,8 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
config.Values = append(config.Values, values...)
|
config.Values = append(config.Values, values...)
|
||||||
case "timeout", "T":
|
case "timeout", "T":
|
||||||
config.Timeout = new(timeout)
|
config.Timeout = new(timeout)
|
||||||
|
case "max-response-body", "S":
|
||||||
|
config.MaxResponseBody = new(uint64(maxResponseBody))
|
||||||
case "insecure", "I":
|
case "insecure", "I":
|
||||||
config.Insecure = new(insecure)
|
config.Insecure = new(insecure)
|
||||||
case "lua":
|
case "lua":
|
||||||
@@ -265,12 +286,14 @@ func (parser ConfigCLIParser) PrintHelp() {
|
|||||||
cliUsageText+"\n",
|
cliUsageText+"\n",
|
||||||
Defaults.ShowConfig,
|
Defaults.ShowConfig,
|
||||||
Defaults.Concurrency,
|
Defaults.Concurrency,
|
||||||
Defaults.Quiet,
|
Defaults.LogLevel,
|
||||||
|
Defaults.Progress,
|
||||||
Defaults.Output,
|
Defaults.Output,
|
||||||
Defaults.DryRun,
|
Defaults.DryRun,
|
||||||
|
|
||||||
Defaults.Method,
|
Defaults.Method,
|
||||||
Defaults.RequestTimeout,
|
Defaults.RequestTimeout,
|
||||||
|
types.FormatByteSize(Defaults.MaxResponseBody),
|
||||||
Defaults.Insecure,
|
Defaults.Insecure,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+199
-87
@@ -6,17 +6,18 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"charm.land/bubbles/v2/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/glamour"
|
"charm.land/glamour/v2"
|
||||||
"github.com/charmbracelet/glamour/styles"
|
"charm.land/glamour/v2/styles"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/x/term"
|
"go.aykhans.me/sarin/internal/sarin"
|
||||||
"go.aykhans.me/sarin/internal/script"
|
"go.aykhans.me/sarin/internal/script"
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
"go.aykhans.me/sarin/internal/version"
|
"go.aykhans.me/sarin/internal/version"
|
||||||
@@ -26,30 +27,35 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var Defaults = struct {
|
var Defaults = struct {
|
||||||
UserAgent string
|
UserAgent string
|
||||||
Method string
|
Method string
|
||||||
RequestTimeout time.Duration
|
RequestTimeout time.Duration
|
||||||
Concurrency uint
|
MaxResponseBody uint64
|
||||||
ShowConfig bool
|
Concurrency uint
|
||||||
Quiet bool
|
ShowConfig bool
|
||||||
Insecure bool
|
Progress ConfigProgressType
|
||||||
Output ConfigOutputType
|
Insecure bool
|
||||||
DryRun bool
|
Output ConfigOutputType
|
||||||
|
DryRun bool
|
||||||
|
LogLevel string
|
||||||
}{
|
}{
|
||||||
UserAgent: "Sarin/" + version.Version,
|
UserAgent: "Sarin/" + version.Version,
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
RequestTimeout: time.Second * 10,
|
RequestTimeout: time.Second * 10,
|
||||||
Concurrency: 1,
|
MaxResponseBody: 10 << 20, // 10 MiB
|
||||||
ShowConfig: false,
|
Concurrency: 1,
|
||||||
Quiet: false,
|
ShowConfig: false,
|
||||||
Insecure: false,
|
Progress: ConfigProgressTypeBar,
|
||||||
Output: ConfigOutputTypeTable,
|
Insecure: false,
|
||||||
DryRun: false,
|
Output: ConfigOutputTypeTable,
|
||||||
|
DryRun: false,
|
||||||
|
LogLevel: "error",
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
|
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
|
||||||
ValidRequestURLSchemes = []string{"http", "https"}
|
ValidRequestURLSchemes = []string{"http", "https"}
|
||||||
|
ValidLogLevels = []string{"info", "error"}
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -70,27 +76,37 @@ var (
|
|||||||
ConfigOutputTypeNone ConfigOutputType = "none"
|
ConfigOutputTypeNone ConfigOutputType = "none"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ConfigProgressType string
|
||||||
|
|
||||||
|
var (
|
||||||
|
ConfigProgressTypeBar ConfigProgressType = "bar"
|
||||||
|
ConfigProgressTypeNone ConfigProgressType = "none"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
||||||
Files []types.ConfigFile `yaml:"files,omitempty"`
|
Files []types.ConfigFile `yaml:"files,omitempty"`
|
||||||
Methods []string `yaml:"methods,omitempty"`
|
Methods []string `yaml:"methods,omitempty"`
|
||||||
URL *url.URL `yaml:"url,omitempty"`
|
URL *url.URL `yaml:"url,omitempty"`
|
||||||
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
||||||
Concurrency *uint `yaml:"concurrency,omitempty"`
|
MaxResponseBody *uint64 `yaml:"maxResponseBody,omitempty"`
|
||||||
Requests *uint64 `yaml:"requests,omitempty"`
|
Concurrency *uint `yaml:"concurrency,omitempty"`
|
||||||
Duration *time.Duration `yaml:"duration,omitempty"`
|
Requests *uint64 `yaml:"requests,omitempty"`
|
||||||
Quiet *bool `yaml:"quiet,omitempty"`
|
Duration *time.Duration `yaml:"duration,omitempty"`
|
||||||
Output *ConfigOutputType `yaml:"output,omitempty"`
|
Progress *ConfigProgressType `yaml:"progress,omitempty"`
|
||||||
Insecure *bool `yaml:"insecure,omitempty"`
|
Output *ConfigOutputType `yaml:"output,omitempty"`
|
||||||
DryRun *bool `yaml:"dryRun,omitempty"`
|
Insecure *bool `yaml:"insecure,omitempty"`
|
||||||
Params types.Params `yaml:"params,omitempty"`
|
DryRun *bool `yaml:"dryRun,omitempty"`
|
||||||
Headers types.Headers `yaml:"headers,omitempty"`
|
Params types.Params `yaml:"params,omitempty"`
|
||||||
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
Headers types.Headers `yaml:"headers,omitempty"`
|
||||||
Bodies []string `yaml:"bodies,omitempty"`
|
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
||||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
Bodies []string `yaml:"bodies,omitempty"`
|
||||||
Values []string `yaml:"values,omitempty"`
|
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||||
Lua []string `yaml:"lua,omitempty"`
|
Values []string `yaml:"values,omitempty"`
|
||||||
Js []string `yaml:"js,omitempty"`
|
Lua []string `yaml:"lua,omitempty"`
|
||||||
|
Js []string `yaml:"js,omitempty"`
|
||||||
|
LogLevel *string `yaml:"logLevel,omitempty"`
|
||||||
|
LogFile *string `yaml:"logFile,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config Config) MarshalYAML() (any, error) {
|
func (config Config) MarshalYAML() (any, error) {
|
||||||
@@ -164,6 +180,9 @@ func (config Config) MarshalYAML() (any, error) {
|
|||||||
if config.Timeout != nil {
|
if config.Timeout != nil {
|
||||||
addField(content, "timeout", toNode(*config.Timeout), "")
|
addField(content, "timeout", toNode(*config.Timeout), "")
|
||||||
}
|
}
|
||||||
|
if config.MaxResponseBody != nil {
|
||||||
|
addField(content, "maxResponseBody", toNode(types.FormatByteSize(*config.MaxResponseBody)), "")
|
||||||
|
}
|
||||||
if config.Concurrency != nil {
|
if config.Concurrency != nil {
|
||||||
addField(content, "concurrency", toNode(*config.Concurrency), "")
|
addField(content, "concurrency", toNode(*config.Concurrency), "")
|
||||||
}
|
}
|
||||||
@@ -173,8 +192,8 @@ func (config Config) MarshalYAML() (any, error) {
|
|||||||
if config.Duration != nil {
|
if config.Duration != nil {
|
||||||
addField(content, "duration", toNode(*config.Duration), "")
|
addField(content, "duration", toNode(*config.Duration), "")
|
||||||
}
|
}
|
||||||
if config.Quiet != nil {
|
if config.Progress != nil {
|
||||||
addField(content, "quiet", toNode(*config.Quiet), "")
|
addField(content, "progress", toNode(string(*config.Progress)), "")
|
||||||
}
|
}
|
||||||
if config.Output != nil {
|
if config.Output != nil {
|
||||||
addField(content, "output", toNode(string(*config.Output)), "")
|
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, "values", config.Values, false)
|
||||||
addStringSlice(content, "lua", config.Lua, false)
|
addStringSlice(content, "lua", config.Lua, false)
|
||||||
addStringSlice(content, "js", config.Js, false)
|
addStringSlice(content, "js", config.Js, false)
|
||||||
|
if config.LogLevel != nil {
|
||||||
|
addField(content, "logLevel", toNode(*config.LogLevel), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.LogFile != nil {
|
||||||
|
addField(content, "logFile", toNode(*config.LogFile), "")
|
||||||
|
}
|
||||||
|
|
||||||
return root, nil
|
return root, nil
|
||||||
}
|
}
|
||||||
@@ -228,12 +254,12 @@ func (config Config) MarshalYAML() (any, error) {
|
|||||||
func (config Config) Print() bool {
|
func (config Config) Print() bool {
|
||||||
configYAML, err := yaml.Marshal(config)
|
configYAML, err := yaml.Marshal(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pipe mode: output raw content directly
|
// Pipe mode: output raw content directly
|
||||||
if !term.IsTerminal(os.Stdout.Fd()) {
|
if !sarin.IsInteractiveTerminal(os.Stdout.Fd()) {
|
||||||
fmt.Println(string(configYAML))
|
fmt.Println(string(configYAML))
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
@@ -247,25 +273,23 @@ func (config Config) Print() bool {
|
|||||||
glamour.WithWordWrap(0),
|
glamour.WithWordWrap(0),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := renderer.Render("```yaml\n" + string(configYAML) + "```")
|
content, err := renderer.Render("```yaml\n" + string(configYAML) + "```")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
p := tea.NewProgram(
|
p := tea.NewProgram(
|
||||||
printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML},
|
printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML},
|
||||||
tea.WithAltScreen(),
|
|
||||||
tea.WithMouseCellMotion(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
m, err := p.Run()
|
m, err := p.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +307,9 @@ func (config *Config) Merge(newConfig *Config) {
|
|||||||
if newConfig.Timeout != nil {
|
if newConfig.Timeout != nil {
|
||||||
config.Timeout = newConfig.Timeout
|
config.Timeout = newConfig.Timeout
|
||||||
}
|
}
|
||||||
|
if newConfig.MaxResponseBody != nil {
|
||||||
|
config.MaxResponseBody = newConfig.MaxResponseBody
|
||||||
|
}
|
||||||
if newConfig.Concurrency != nil {
|
if newConfig.Concurrency != nil {
|
||||||
config.Concurrency = newConfig.Concurrency
|
config.Concurrency = newConfig.Concurrency
|
||||||
}
|
}
|
||||||
@@ -295,8 +322,8 @@ func (config *Config) Merge(newConfig *Config) {
|
|||||||
if newConfig.ShowConfig != nil {
|
if newConfig.ShowConfig != nil {
|
||||||
config.ShowConfig = newConfig.ShowConfig
|
config.ShowConfig = newConfig.ShowConfig
|
||||||
}
|
}
|
||||||
if newConfig.Quiet != nil {
|
if newConfig.Progress != nil {
|
||||||
config.Quiet = newConfig.Quiet
|
config.Progress = newConfig.Progress
|
||||||
}
|
}
|
||||||
if newConfig.Output != nil {
|
if newConfig.Output != nil {
|
||||||
config.Output = newConfig.Output
|
config.Output = newConfig.Output
|
||||||
@@ -331,6 +358,12 @@ func (config *Config) Merge(newConfig *Config) {
|
|||||||
if len(newConfig.Js) != 0 {
|
if len(newConfig.Js) != 0 {
|
||||||
config.Js = append(config.Js, newConfig.Js...)
|
config.Js = append(config.Js, newConfig.Js...)
|
||||||
}
|
}
|
||||||
|
if newConfig.LogLevel != nil {
|
||||||
|
config.LogLevel = newConfig.LogLevel
|
||||||
|
}
|
||||||
|
if newConfig.LogFile != nil {
|
||||||
|
config.LogFile = newConfig.LogFile
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config *Config) SetDefaults() {
|
func (config *Config) SetDefaults() {
|
||||||
@@ -355,14 +388,17 @@ func (config *Config) SetDefaults() {
|
|||||||
if config.Timeout == nil {
|
if config.Timeout == nil {
|
||||||
config.Timeout = &Defaults.RequestTimeout
|
config.Timeout = &Defaults.RequestTimeout
|
||||||
}
|
}
|
||||||
|
if config.MaxResponseBody == nil {
|
||||||
|
config.MaxResponseBody = &Defaults.MaxResponseBody
|
||||||
|
}
|
||||||
if config.Concurrency == nil {
|
if config.Concurrency == nil {
|
||||||
config.Concurrency = new(Defaults.Concurrency)
|
config.Concurrency = new(Defaults.Concurrency)
|
||||||
}
|
}
|
||||||
if config.ShowConfig == nil {
|
if config.ShowConfig == nil {
|
||||||
config.ShowConfig = new(Defaults.ShowConfig)
|
config.ShowConfig = new(Defaults.ShowConfig)
|
||||||
}
|
}
|
||||||
if config.Quiet == nil {
|
if config.Progress == nil {
|
||||||
config.Quiet = new(Defaults.Quiet)
|
config.Progress = new(Defaults.Progress)
|
||||||
}
|
}
|
||||||
if config.Insecure == nil {
|
if config.Insecure == nil {
|
||||||
config.Insecure = new(Defaults.Insecure)
|
config.Insecure = new(Defaults.Insecure)
|
||||||
@@ -377,6 +413,14 @@ func (config *Config) SetDefaults() {
|
|||||||
if config.Output == nil {
|
if config.Output == nil {
|
||||||
config.Output = new(Defaults.Output)
|
config.Output = new(Defaults.Output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.LogLevel == nil {
|
||||||
|
config.LogLevel = new(Defaults.LogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.LogFile == nil {
|
||||||
|
config.LogFile = new("")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate validates the config fields.
|
// Validate validates the config fields.
|
||||||
@@ -422,12 +466,33 @@ func (config Config) Validate() error {
|
|||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.MaxResponseBody != nil && *config.MaxResponseBody > types.ByteSizeLimit {
|
||||||
|
validationErrors = append(validationErrors, types.NewFieldValidationError(
|
||||||
|
"MaxResponseBody",
|
||||||
|
types.FormatByteSize(*config.MaxResponseBody),
|
||||||
|
errors.New("max response body must not exceed "+types.FormatByteSize(types.ByteSizeLimit)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
if config.ShowConfig == nil {
|
if config.ShowConfig == nil {
|
||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Quiet == nil {
|
if config.Progress == nil {
|
||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Quiet", "", errors.New("quiet field is required")))
|
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 {
|
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")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.LogLevel != nil {
|
||||||
|
for i, level := range sarin.SplitLogLevels(*config.LogLevel) {
|
||||||
|
if !slices.Contains(ValidLogLevels, level) {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError(
|
||||||
|
fmt.Sprintf("LogLevel[%d]", i),
|
||||||
|
level,
|
||||||
|
fmt.Errorf("log level must be one of: %s", strings.Join(ValidLogLevels, ", ")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.LogFile != nil && *config.LogFile != "" {
|
||||||
|
dir := filepath.Dir(*config.LogFile)
|
||||||
|
if info, err := os.Stat(dir); err != nil {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent directory %q is not accessible", dir)),
|
||||||
|
)
|
||||||
|
} else if !info.IsDir() {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent path %q is not a directory", dir)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, proxy := range config.Proxies {
|
for i, proxy := range config.Proxies {
|
||||||
if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
|
if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
|
||||||
validationErrors = append(
|
validationErrors = append(
|
||||||
@@ -538,11 +633,11 @@ func ReadAllConfigs() *Config {
|
|||||||
_ = utilsErr.MustHandle(err,
|
_ = utilsErr.MustHandle(err,
|
||||||
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
|
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
|
||||||
cliParser.PrintHelp()
|
cliParser.PrintHelp()
|
||||||
fmt.Fprintln(os.Stderr,
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||||
StyleYellow.Render(
|
StyleYellow.Render(
|
||||||
"\nUnexpected CLI arguments provided: ",
|
"\nUnexpected CLI arguments provided: ",
|
||||||
)+strings.Join(err.Args, ", "),
|
)+strings.Join(err.Args, ", "),
|
||||||
)
|
))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
@@ -557,28 +652,35 @@ func ReadAllConfigs() *Config {
|
|||||||
|
|
||||||
for _, configFile := range append(envConfig.Files, cliConf.Files...) {
|
for _, configFile := range append(envConfig.Files, cliConf.Files...) {
|
||||||
fileConfig, err := parseConfigFile(configFile, 10)
|
fileConfig, err := parseConfigFile(configFile, 10)
|
||||||
|
|
||||||
|
// A nested configFile fails under its own name, not the one given on the command line.
|
||||||
|
path := configFile.Path()
|
||||||
|
if configFileErr, ok := errors.AsType[types.ConfigFileError](err); ok {
|
||||||
|
path = configFileErr.Path
|
||||||
|
}
|
||||||
|
|
||||||
_ = utilsErr.MustHandle(err,
|
_ = utilsErr.MustHandle(err,
|
||||||
utilsErr.OnType(func(err types.ConfigFileReadError) error {
|
utilsErr.OnType(func(err types.ConfigFileReadError) error {
|
||||||
cliParser.PrintHelp()
|
cliParser.PrintHelp()
|
||||||
fmt.Fprintln(os.Stderr,
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||||
StyleYellow.Render(
|
StyleYellow.Render(
|
||||||
fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(),
|
fmt.Sprintf("\nFailed to read config file (%s): ", path)+err.Error(),
|
||||||
),
|
),
|
||||||
)
|
))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
utilsErr.OnType(func(err types.UnmarshalError) error {
|
utilsErr.OnType(func(err types.UnmarshalError) error {
|
||||||
fmt.Fprintln(os.Stderr,
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||||
StyleYellow.Render(
|
StyleYellow.Render(
|
||||||
fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(),
|
fmt.Sprintf("\nFailed to parse config file (%s): ", path)+err.Error(),
|
||||||
),
|
),
|
||||||
)
|
))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
utilsErr.OnType(func(err types.FieldParseErrors) error {
|
utilsErr.OnType(func(err types.FieldParseErrors) error {
|
||||||
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", configFile.Path()), err.Errors...)
|
printParseErrors(fmt.Sprintf("CONFIG FILE '%s'", path), err.Errors...)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
@@ -594,7 +696,7 @@ func ReadAllConfigs() *Config {
|
|||||||
|
|
||||||
// parseConfigFile recursively parses a config file and its nested files up to maxDepth levels.
|
// parseConfigFile recursively parses a config file and its nested files up to maxDepth levels.
|
||||||
// Returns the merged configuration or an error if parsing fails.
|
// Returns the merged configuration or an error if parsing fails.
|
||||||
// It can return the following errors:
|
// It can return the following errors, each wrapped in a types.ConfigFileError naming the file:
|
||||||
// - types.ConfigFileReadError
|
// - types.ConfigFileReadError
|
||||||
// - types.UnmarshalError
|
// - types.UnmarshalError
|
||||||
// - types.FieldParseErrors
|
// - types.FieldParseErrors
|
||||||
@@ -602,7 +704,7 @@ func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error)
|
|||||||
configFileParser := NewConfigFileParser(configFile)
|
configFileParser := NewConfigFileParser(configFile)
|
||||||
fileConfig, err := configFileParser.Parse()
|
fileConfig, err := configFileParser.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewConfigFileError(configFile.Path(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if maxDepth <= 0 {
|
if maxDepth <= 0 {
|
||||||
@@ -640,7 +742,7 @@ func validateScriptSource(script string) error {
|
|||||||
return types.ErrScriptEmpty
|
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, "@") {
|
if !strings.HasPrefix(script, "@") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -650,7 +752,7 @@ func validateScriptSource(script string) error {
|
|||||||
return nil
|
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
|
source := script[1:] // Remove the @ prefix
|
||||||
|
|
||||||
if source == "" {
|
if source == "" {
|
||||||
@@ -669,20 +771,20 @@ func validateScriptSource(script string) error {
|
|||||||
return nil
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func printParseErrors(parserName string, errors ...types.FieldParseError) {
|
func printParseErrors(parserName string, errors ...types.FieldParseError) {
|
||||||
for _, fieldErr := range errors {
|
for _, fieldErr := range errors {
|
||||||
if fieldErr.Value == "" {
|
if fieldErr.Value == "" {
|
||||||
fmt.Fprintln(os.Stderr,
|
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||||
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(),
|
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(),
|
||||||
)
|
))
|
||||||
} else {
|
} else {
|
||||||
fmt.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(),
|
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
|
var cmd tea.Cmd
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyPressMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc":
|
case "ctrl+c", "esc":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -746,13 +848,22 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, 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 {
|
if !m.ready {
|
||||||
return "\n Initializing..."
|
return newView("\n Initializing...")
|
||||||
}
|
}
|
||||||
|
|
||||||
content := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbar())
|
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) {
|
func (m *printConfigModel) saveContent() (printConfigModel, tea.Cmd) {
|
||||||
@@ -772,12 +883,13 @@ func (m *printConfigModel) handleResize(msg tea.WindowSizeMsg) {
|
|||||||
width := msg.Width - scrollbarWidth
|
width := msg.Width - scrollbarWidth
|
||||||
|
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
m.viewport = viewport.New(width, height)
|
m.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height))
|
||||||
|
m.viewport.SetHorizontalStep(0)
|
||||||
m.viewport.SetContent(m.contentWithLineNumbers())
|
m.viewport.SetContent(m.contentWithLineNumbers())
|
||||||
m.ready = true
|
m.ready = true
|
||||||
} else {
|
} else {
|
||||||
m.viewport.Width = width
|
m.viewport.SetWidth(width)
|
||||||
m.viewport.Height = height
|
m.viewport.SetHeight(height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,12 +904,12 @@ func (m printConfigModel) headerView() string {
|
|||||||
printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit")
|
printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit")
|
||||||
title = printConfigHelpStyle.Render(help)
|
title = printConfigHelpStyle.Render(help)
|
||||||
}
|
}
|
||||||
line := strings.Repeat("─", max(0, m.viewport.Width+scrollbarWidth-lipgloss.Width(title)))
|
line := strings.Repeat("─", max(0, m.viewport.Width()+scrollbarWidth-lipgloss.Width(title)))
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
|
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m printConfigModel) footerView() string {
|
func (m printConfigModel) footerView() string {
|
||||||
return strings.Repeat("─", m.viewport.Width+scrollbarWidth)
|
return strings.Repeat("─", m.viewport.Width()+scrollbarWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m printConfigModel) contentWithLineNumbers() string {
|
func (m printConfigModel) contentWithLineNumbers() string {
|
||||||
@@ -819,7 +931,7 @@ func (m printConfigModel) contentWithLineNumbers() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m printConfigModel) scrollbar() string {
|
func (m printConfigModel) scrollbar() string {
|
||||||
height := m.viewport.Height
|
height := m.viewport.Height()
|
||||||
trackHeight := height - scrollbarBottomSpace
|
trackHeight := height - scrollbarBottomSpace
|
||||||
totalLines := m.viewport.TotalLineCount()
|
totalLines := m.viewport.TotalLineCount()
|
||||||
|
|
||||||
|
|||||||
+90
-78
@@ -49,74 +49,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
|
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
if quiet := parser.getEnv("QUIET"); quiet != "" {
|
|
||||||
quietParsed, err := utilsParse.ParseString[bool](quiet)
|
|
||||||
if err != nil {
|
|
||||||
fieldParseErrors = append(
|
|
||||||
fieldParseErrors,
|
|
||||||
types.NewFieldParseError(
|
|
||||||
parser.getFullEnvName("QUIET"),
|
|
||||||
quiet,
|
|
||||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
config.Quiet = &quietParsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if output := parser.getEnv("OUTPUT"); output != "" {
|
|
||||||
config.Output = new(ConfigOutputType(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
|
||||||
insecureParsed, err := utilsParse.ParseString[bool](insecure)
|
|
||||||
if err != nil {
|
|
||||||
fieldParseErrors = append(
|
|
||||||
fieldParseErrors,
|
|
||||||
types.NewFieldParseError(
|
|
||||||
parser.getFullEnvName("INSECURE"),
|
|
||||||
insecure,
|
|
||||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
config.Insecure = &insecureParsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
|
|
||||||
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
|
|
||||||
if err != nil {
|
|
||||||
fieldParseErrors = append(
|
|
||||||
fieldParseErrors,
|
|
||||||
types.NewFieldParseError(
|
|
||||||
parser.getFullEnvName("DRY_RUN"),
|
|
||||||
dryRun,
|
|
||||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
config.DryRun = &dryRunParsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if method := parser.getEnv("METHOD"); method != "" {
|
|
||||||
config.Methods = []string{method}
|
|
||||||
}
|
|
||||||
|
|
||||||
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
|
|
||||||
urlEnvParsed, err := url.Parse(urlEnv)
|
|
||||||
if err != nil {
|
|
||||||
fieldParseErrors = append(
|
|
||||||
fieldParseErrors,
|
|
||||||
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
config.URL = urlEnvParsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" {
|
if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" {
|
||||||
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
|
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -165,22 +97,58 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
|
if logLevel := parser.getEnv("LOG_LEVEL"); logLevel != "" {
|
||||||
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
|
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 {
|
if err != nil {
|
||||||
fieldParseErrors = append(
|
fieldParseErrors = append(
|
||||||
fieldParseErrors,
|
fieldParseErrors,
|
||||||
types.NewFieldParseError(
|
types.NewFieldParseError(
|
||||||
parser.getFullEnvName("TIMEOUT"),
|
parser.getFullEnvName("DRY_RUN"),
|
||||||
timeout,
|
dryRun,
|
||||||
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
|
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} 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 != "" {
|
if param := parser.getEnv("PARAM"); param != "" {
|
||||||
config.Params.Parse(param)
|
config.Params.Parse(param)
|
||||||
}
|
}
|
||||||
@@ -193,10 +161,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
config.Cookies.Parse(cookie)
|
config.Cookies.Parse(cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
if body := parser.getEnv("BODY"); body != "" {
|
|
||||||
config.Bodies = []string{body}
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxy := parser.getEnv("PROXY"); proxy != "" {
|
if proxy := parser.getEnv("PROXY"); proxy != "" {
|
||||||
err := config.Proxies.Parse(proxy)
|
err := config.Proxies.Parse(proxy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,6 +179,54 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
config.Values = []string{values}
|
config.Values = []string{values}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
|
||||||
|
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
|
||||||
|
if err != nil {
|
||||||
|
fieldParseErrors = append(
|
||||||
|
fieldParseErrors,
|
||||||
|
types.NewFieldParseError(
|
||||||
|
parser.getFullEnvName("TIMEOUT"),
|
||||||
|
timeout,
|
||||||
|
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
config.Timeout = &timeoutParsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if 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 != "" {
|
if lua := parser.getEnv("LUA"); lua != "" {
|
||||||
config.Lua = []string{lua}
|
config.Lua = []string{lua}
|
||||||
}
|
}
|
||||||
|
|||||||
+282
-56
@@ -1,13 +1,17 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -79,10 +83,18 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
|||||||
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
// A declared size above the limit is refused before anything is read.
|
||||||
|
if resp.ContentLength > types.RemoteFetchLimit {
|
||||||
|
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewHTTPFetchError(url, err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
if len(data) > types.RemoteFetchLimit {
|
||||||
|
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
@@ -126,7 +138,7 @@ func (ss *stringOrSliceField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
*ss = slice
|
*ss = slice
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("expected a string or a sequence of strings, but got %v", node.Kind)
|
return fmt.Errorf("expected a string or a sequence of strings, but got %s", yamlNodeName(node))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +159,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
// Handle sequence of maps: [{key1: value1}, {key2: value2}]
|
// Handle sequence of maps: [{key1: value1}, {key2: value2}]
|
||||||
for _, item := range node.Content {
|
for _, item := range node.Content {
|
||||||
if item.Kind != yaml.MappingNode {
|
if item.Kind != yaml.MappingNode {
|
||||||
return fmt.Errorf("expected a mapping in sequence, but got %v", item.Kind)
|
return fmt.Errorf("expected a mapping in sequence, but got %s", yamlNodeName(item))
|
||||||
}
|
}
|
||||||
if err := kv.unmarshalMapping(item); err != nil {
|
if err := kv.unmarshalMapping(item); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -155,7 +167,7 @@ func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("expected a mapping or sequence of mappings, but got %v", node.Kind)
|
return fmt.Errorf("expected a mapping or sequence of mappings, but got %s", yamlNodeName(node))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +178,7 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
|||||||
valueNode := node.Content[i+1]
|
valueNode := node.Content[i+1]
|
||||||
|
|
||||||
if keyNode.Kind != yaml.ScalarNode {
|
if keyNode.Kind != yaml.ScalarNode {
|
||||||
return fmt.Errorf("expected a string key, but got %v", keyNode.Kind)
|
return fmt.Errorf("expected a string key, but got %s", yamlNodeName(keyNode))
|
||||||
}
|
}
|
||||||
|
|
||||||
key := keyNode.Value
|
key := keyNode.Value
|
||||||
@@ -178,12 +190,12 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
|||||||
case yaml.SequenceNode:
|
case yaml.SequenceNode:
|
||||||
for _, v := range valueNode.Content {
|
for _, v := range valueNode.Content {
|
||||||
if v.Kind != yaml.ScalarNode {
|
if v.Kind != yaml.ScalarNode {
|
||||||
return fmt.Errorf("expected string values in array for key %q, but got %v", key, v.Kind)
|
return fmt.Errorf("expected string values in array for key %q, but got %s", key, yamlNodeName(v))
|
||||||
}
|
}
|
||||||
values = append(values, v.Value)
|
values = append(values, v.Value)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("expected a string or array of strings for key %q, but got %v", key, valueNode.Kind)
|
return fmt.Errorf("expected a string or array of strings for key %q, but got %s", key, yamlNodeName(valueNode))
|
||||||
}
|
}
|
||||||
|
|
||||||
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
|
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
|
||||||
@@ -192,26 +204,29 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type configYAML struct {
|
type configYAML struct {
|
||||||
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
ShowConfig *bool `yaml:"showConfig"`
|
||||||
Method stringOrSliceField `yaml:"method"`
|
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
||||||
URL *string `yaml:"url"`
|
Concurrency *uint `yaml:"concurrency"`
|
||||||
Timeout *time.Duration `yaml:"timeout"`
|
RequestCount *uint64 `yaml:"requests"`
|
||||||
Concurrency *uint `yaml:"concurrency"`
|
Duration *time.Duration `yaml:"duration"`
|
||||||
RequestCount *uint64 `yaml:"requests"`
|
LogLevel *string `yaml:"logLevel"`
|
||||||
Duration *time.Duration `yaml:"duration"`
|
LogFile *string `yaml:"logFile"`
|
||||||
Quiet *bool `yaml:"quiet"`
|
Progress *string `yaml:"progress"`
|
||||||
Output *string `yaml:"output"`
|
Output *string `yaml:"output"`
|
||||||
Insecure *bool `yaml:"insecure"`
|
DryRun *bool `yaml:"dryRun"`
|
||||||
ShowConfig *bool `yaml:"showConfig"`
|
URL *string `yaml:"url"`
|
||||||
DryRun *bool `yaml:"dryRun"`
|
Method stringOrSliceField `yaml:"method"`
|
||||||
Params keyValuesField `yaml:"params"`
|
Bodies stringOrSliceField `yaml:"body"`
|
||||||
Headers keyValuesField `yaml:"headers"`
|
Params keyValuesField `yaml:"params"`
|
||||||
Cookies keyValuesField `yaml:"cookies"`
|
Headers keyValuesField `yaml:"headers"`
|
||||||
Bodies stringOrSliceField `yaml:"body"`
|
Cookies keyValuesField `yaml:"cookies"`
|
||||||
Proxies stringOrSliceField `yaml:"proxy"`
|
Proxies stringOrSliceField `yaml:"proxy"`
|
||||||
Values stringOrSliceField `yaml:"values"`
|
Values stringOrSliceField `yaml:"values"`
|
||||||
Lua stringOrSliceField `yaml:"lua"`
|
Timeout *time.Duration `yaml:"timeout"`
|
||||||
Js stringOrSliceField `yaml:"js"`
|
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.
|
// ParseYAML parses YAML config file arguments into a Config object.
|
||||||
@@ -224,40 +239,31 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
parsedData = &configYAML{}
|
parsedData = &configYAML{}
|
||||||
)
|
)
|
||||||
|
|
||||||
err := yaml.Unmarshal(data, &parsedData)
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
if err != nil {
|
decoder.KnownFields(true)
|
||||||
return nil, types.NewUnmarshalError(err)
|
// The decoder returns a bare io.EOF for an empty file, errors.Is would also match a wrapped one.
|
||||||
|
if err := decoder.Decode(parsedData); err != nil && err != io.EOF { //nolint:errorlint
|
||||||
|
return nil, types.NewUnmarshalError(yamlError(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode reads one document, so anything after the first "---" would be dropped silently.
|
||||||
|
for {
|
||||||
|
var next yaml.Node
|
||||||
|
err := decoder.Decode(&next)
|
||||||
|
if err == io.EOF { //nolint:errorlint
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewUnmarshalError(yamlError(err))
|
||||||
|
}
|
||||||
|
if !isEmptyDocument(&next) {
|
||||||
|
return nil, types.NewUnmarshalError(types.ErrYAMLMultipleDocuments)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fieldParseErrors []types.FieldParseError
|
var fieldParseErrors []types.FieldParseError
|
||||||
|
|
||||||
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.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 {
|
if len(parsedData.ConfigFiles) > 0 {
|
||||||
for _, configFile := range parsedData.ConfigFiles {
|
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 {
|
if parsedData.URL != nil {
|
||||||
urlParsed, err := url.Parse(*parsedData.URL)
|
urlParsed, err := url.Parse(*parsedData.URL)
|
||||||
if err != nil {
|
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 {
|
for i, proxy := range parsedData.Proxies {
|
||||||
err := config.Proxies.Parse(proxy)
|
err := config.Proxies.Parse(proxy)
|
||||||
if err != nil {
|
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 {
|
if len(fieldParseErrors) > 0 {
|
||||||
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
||||||
}
|
}
|
||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configYAMLTypeName is the Go type name the yaml package puts in its messages.
|
||||||
|
var configYAMLTypeName = reflect.TypeFor[configYAML]().String()
|
||||||
|
|
||||||
|
// isEmptyDocument reports whether a document holds nothing, which a trailing "---" produces.
|
||||||
|
// A null tag alone is not enough, since a user can hang it on a mapping that does hold content.
|
||||||
|
func isEmptyDocument(node *yaml.Node) bool {
|
||||||
|
if len(node.Content) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
root := node.Content[0]
|
||||||
|
return root.Kind == yaml.ScalarNode && root.Tag == "!!null"
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlError rewrites yaml decode errors so they read like config file errors.
|
||||||
|
// Errors from building the config arrive collected, everything earlier arrives on its own.
|
||||||
|
func yamlError(err error) error {
|
||||||
|
var loadErrors *yaml.LoadErrors
|
||||||
|
if errors.As(err, &loadErrors) && len(loadErrors.Errors) > 0 {
|
||||||
|
var builder strings.Builder
|
||||||
|
for i, loadError := range loadErrors.Errors {
|
||||||
|
if i > 0 {
|
||||||
|
builder.WriteString("\n")
|
||||||
|
}
|
||||||
|
builder.WriteString(yamlErrorText(loadError))
|
||||||
|
}
|
||||||
|
return errors.New(builder.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadError, ok := errors.AsType[*yaml.LoadError](err); ok {
|
||||||
|
return errors.New(yamlErrorText(loadError))
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlErrorText renders one error as "line 3: while scanning a plain scalar: found a tab character".
|
||||||
|
// The mark is where reading gave up and the context mark starts the construct that broke.
|
||||||
|
func yamlErrorText(loadError *yaml.LoadError) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
if loadError.Mark.Line > 0 {
|
||||||
|
fmt.Fprintf(&builder, "line %d: ", loadError.Mark.Line)
|
||||||
|
}
|
||||||
|
if loadError.ContextMsg != "" {
|
||||||
|
builder.WriteString(loadError.ContextMsg + ": ")
|
||||||
|
}
|
||||||
|
builder.WriteString(yamlErrorMessage(loadError.Message))
|
||||||
|
|
||||||
|
start := loadError.ContextMark
|
||||||
|
if loadError.ContextMsg != "" && start.Line > 0 && start.Line != loadError.Mark.Line {
|
||||||
|
fmt.Fprintf(&builder, " (started at line %d)", start.Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlTypeNames maps the Go types and YAML tags the yaml package prints to words a user knows.
|
||||||
|
var yamlTypeNames = map[string]string{
|
||||||
|
"!!str": "text",
|
||||||
|
"!!int": "a number",
|
||||||
|
"!!float": "a number",
|
||||||
|
"!!bool": "true or false",
|
||||||
|
"!!seq": "a list",
|
||||||
|
"!!map": "a mapping",
|
||||||
|
"!!null": "an empty value",
|
||||||
|
"!!timestamp": "a date",
|
||||||
|
"!!binary": "binary data",
|
||||||
|
"string": "text",
|
||||||
|
"bool": "true or false",
|
||||||
|
"uint": "a whole number of 0 or more",
|
||||||
|
"uint64": "a whole number of 0 or more",
|
||||||
|
"time.Duration": "a duration such as 10s",
|
||||||
|
configYAMLTypeName: "a mapping of config keys",
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlNodeName names what a node holds, so an error can say "a list" instead of a kind number.
|
||||||
|
// A tag that contradicts the shape is kept, since the shape alone would read as a contradiction.
|
||||||
|
func yamlNodeName(node *yaml.Node) string {
|
||||||
|
if name, ok := yamlTypeNames[node.Tag]; ok && yamlTagKind(node.Tag) == node.Kind {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if node.Tag == "" {
|
||||||
|
return yamlKindName(node.Kind)
|
||||||
|
}
|
||||||
|
return yamlKindName(node.Kind) + " tagged " + node.Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlTagKind returns the node shape a tag belongs on, so a mismatch can be spotted.
|
||||||
|
func yamlTagKind(tag string) yaml.Kind {
|
||||||
|
switch tag {
|
||||||
|
case "!!seq":
|
||||||
|
return yaml.SequenceNode
|
||||||
|
case "!!map":
|
||||||
|
return yaml.MappingNode
|
||||||
|
default:
|
||||||
|
return yaml.ScalarNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlKindName names a node by its shape, for when its tag says nothing a user would know.
|
||||||
|
func yamlKindName(kind yaml.Kind) string {
|
||||||
|
switch kind {
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
return "a list"
|
||||||
|
case yaml.MappingNode:
|
||||||
|
return "a mapping"
|
||||||
|
case yaml.AliasNode:
|
||||||
|
return "an alias"
|
||||||
|
default:
|
||||||
|
return "a value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlErrorMessage turns "field x not found in type config.configYAML" into "unknown key "x"".
|
||||||
|
func yamlErrorMessage(message string) string {
|
||||||
|
if field, ok := strings.CutSuffix(message, " not found in type "+configYAMLTypeName); ok {
|
||||||
|
if name, ok := strings.CutPrefix(field, "field "); ok {
|
||||||
|
return fmt.Sprintf("unknown key %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user controls the value, so the rightmost separator is the only one that is ours.
|
||||||
|
// After "as a" comes the tag the user wrote, after "into" comes the type the key accepts.
|
||||||
|
if names, ok := strings.CutPrefix(message, "cannot construct "); ok {
|
||||||
|
at, width, tagged := -1, 0, false
|
||||||
|
for index, separator := range []string{" into ", " as a "} {
|
||||||
|
if found := strings.LastIndex(names, separator); found > at {
|
||||||
|
at, width, tagged = found, len(separator), index == 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at >= 0 {
|
||||||
|
got, want := yamlGotName(names[:at]), yamlWantedName(names[at+width:])
|
||||||
|
if tagged {
|
||||||
|
return fmt.Sprintf("%s is not %s", got, want)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("expected %s, but got %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.ReplaceAll(message, configYAMLTypeName, "the config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlWantedName names what a key accepts, which the yaml package prints as a Go type.
|
||||||
|
func yamlWantedName(text string) string {
|
||||||
|
if name, ok := yamlTypeNames[text]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlGotName names what the file held, which the yaml package prints as a tag and sometimes a value.
|
||||||
|
// A tag the user would not know stays in the text, since without it the message reads as nonsense.
|
||||||
|
func yamlGotName(text string) string {
|
||||||
|
// A collection tag carries no backticks, so a scalar wearing one arrives glued to it.
|
||||||
|
for _, tag := range []string{"!!seq", "!!map"} {
|
||||||
|
if value, ok := strings.CutPrefix(text, tag); ok && value != "" {
|
||||||
|
return yamlValueText(value) + " tagged " + tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, value, _ := strings.Cut(text, " ")
|
||||||
|
value = strings.TrimSuffix(strings.TrimPrefix(value, "`"), "`")
|
||||||
|
|
||||||
|
name, known := yamlTypeNames[tag]
|
||||||
|
switch {
|
||||||
|
case known && value != "":
|
||||||
|
return yamlValueText(value)
|
||||||
|
case known:
|
||||||
|
return name
|
||||||
|
case value != "":
|
||||||
|
return yamlValueText(value) + " tagged " + tag
|
||||||
|
default:
|
||||||
|
return "a value tagged " + tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yamlValueText quotes a value, saying so when the yaml package shortened it to seven characters.
|
||||||
|
func yamlValueText(value string) string {
|
||||||
|
if start, ok := strings.CutSuffix(value, "..."); ok && len(start) == 7 {
|
||||||
|
return "a value starting with " + strconv.Quote(start)
|
||||||
|
}
|
||||||
|
return strconv.Quote(value)
|
||||||
|
}
|
||||||
@@ -193,7 +193,7 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F
|
|||||||
|
|
||||||
func ValidateTemplates(config *Config) []types.FieldValidationError {
|
func ValidateTemplates(config *Config) []types.FieldValidationError {
|
||||||
// Create template function map using the same functions as sarin package
|
// Create template function map using the same functions as sarin package
|
||||||
// Use nil for fileCache during validation - templates are only parsed, not executed
|
// fileCache is nil because templates are only parsed here, not executed
|
||||||
randSource := sarin.NewDefaultRandSource()
|
randSource := sarin.NewDefaultRandSource()
|
||||||
funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
|
funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
|
||||||
|
|
||||||
|
|||||||
+123
-98
@@ -5,27 +5,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"math"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
utilsSlice "go.aykhans.me/utils/slice"
|
utilsSlice "go.aykhans.me/utils/slice"
|
||||||
"golang.org/x/net/proxy"
|
"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
|
|
||||||
}
|
|
||||||
return int(u)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
|
// NewHostClients creates a list of fasthttp.HostClient instances for the given proxies.
|
||||||
// If no proxies are provided, a single client without a proxy is returned.
|
// If no proxies are provided, a single client without a proxy is returned.
|
||||||
@@ -38,6 +30,7 @@ func NewHostClients(
|
|||||||
maxConns uint,
|
maxConns uint,
|
||||||
requestURL *url.URL,
|
requestURL *url.URL,
|
||||||
skipVerify bool,
|
skipVerify bool,
|
||||||
|
maxResponseBody uint64,
|
||||||
) ([]*fasthttp.HostClient, error) {
|
) ([]*fasthttp.HostClient, error) {
|
||||||
isTLS := requestURL.Scheme == "https"
|
isTLS := requestURL.Scheme == "https"
|
||||||
|
|
||||||
@@ -66,6 +59,9 @@ func NewHostClients(
|
|||||||
MaxConnDuration: timeout,
|
MaxConnDuration: timeout,
|
||||||
WriteTimeout: timeout,
|
WriteTimeout: timeout,
|
||||||
ReadTimeout: timeout,
|
ReadTimeout: timeout,
|
||||||
|
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||||
|
MaxIdemponentCallAttempts: 1,
|
||||||
|
ReadBufferSize: 64 << 10, // 64 KiB
|
||||||
DisableHeaderNamesNormalizing: true,
|
DisableHeaderNamesNormalizing: true,
|
||||||
DisablePathNormalizing: true,
|
DisablePathNormalizing: true,
|
||||||
NoDefaultUserAgentHeader: true,
|
NoDefaultUserAgentHeader: true,
|
||||||
@@ -77,8 +73,9 @@ func NewHostClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
client := &fasthttp.HostClient{
|
client := &fasthttp.HostClient{
|
||||||
MaxConns: safeUintToInt(maxConns),
|
MaxConns: safeUintToInt(maxConns),
|
||||||
IsTLS: isTLS,
|
IsTLS: isTLS,
|
||||||
|
DialDualStack: true,
|
||||||
TLSConfig: &tls.Config{
|
TLSConfig: &tls.Config{
|
||||||
InsecureSkipVerify: skipVerify, //nolint:gosec
|
InsecureSkipVerify: skipVerify, //nolint:gosec
|
||||||
},
|
},
|
||||||
@@ -87,6 +84,9 @@ func NewHostClients(
|
|||||||
MaxConnDuration: timeout,
|
MaxConnDuration: timeout,
|
||||||
WriteTimeout: timeout,
|
WriteTimeout: timeout,
|
||||||
ReadTimeout: timeout,
|
ReadTimeout: timeout,
|
||||||
|
MaxResponseBodySize: safeUint64ToInt(maxResponseBody),
|
||||||
|
MaxIdemponentCallAttempts: 1,
|
||||||
|
ReadBufferSize: 64 << 10, // 64 KiB
|
||||||
DisableHeaderNamesNormalizing: true,
|
DisableHeaderNamesNormalizing: true,
|
||||||
DisablePathNormalizing: true,
|
DisablePathNormalizing: true,
|
||||||
NoDefaultUserAgentHeader: true,
|
NoDefaultUserAgentHeader: true,
|
||||||
@@ -94,40 +94,45 @@ func NewHostClients(
|
|||||||
return []*fasthttp.HostClient{client}, nil
|
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:
|
// It can return the following errors:
|
||||||
// - types.ProxyUnsupportedSchemeError
|
// - types.ProxyUnsupportedSchemeError
|
||||||
|
// - types.ErrProxyNoContextDialer
|
||||||
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
||||||
var (
|
dial, err := NewProxyDialFuncWithTimeout(ctx, proxyURL)
|
||||||
dialer fasthttp.DialFunc
|
if err != nil {
|
||||||
err error
|
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 {
|
switch proxyURL.Scheme {
|
||||||
case "socks5":
|
case "socks5":
|
||||||
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, true)
|
return fasthttpSocksDialer(ctx, proxyURL, true)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
case "socks5h":
|
case "socks5h":
|
||||||
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, false)
|
return fasthttpSocksDialer(ctx, proxyURL, false)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
case "http":
|
case "http":
|
||||||
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxyURL.String(), timeout)
|
return fasthttpConnectDialer(proxyURL, false), nil
|
||||||
case "https":
|
case "https":
|
||||||
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
|
return fasthttpConnectDialer(proxyURL, true), nil
|
||||||
default:
|
default:
|
||||||
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
|
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
|
// - 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{}
|
netDialer := &net.Dialer{}
|
||||||
|
|
||||||
// Parse auth from proxy URL if present
|
// Parse auth from proxy URL if present
|
||||||
@@ -149,66 +154,92 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
|
|
||||||
proxyStr := proxyURL.String()
|
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)
|
contextDialer, ok := socksDialer.(proxy.ContextDialer)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Fallback without timeout (should not happen with net.Dialer)
|
return nil, types.ErrProxyNoContextDialer
|
||||||
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 dial function that uses context with timeout
|
// 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)
|
deadline := time.Now().Add(timeout)
|
||||||
|
|
||||||
|
addrs := []string{addr}
|
||||||
if resolveLocally {
|
if resolveLocally {
|
||||||
host, port, err := net.SplitHostPort(addr)
|
var err error
|
||||||
|
addrs, err = resolveIPv4First(ctx, addr, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
|
// Try every resolved address, so one unreachable family does not fail the dial.
|
||||||
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
var lastErr error
|
||||||
dnsCancel()
|
for _, candidate := range addrs {
|
||||||
if err != nil {
|
remaining := time.Until(deadline)
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
if remaining <= 0 {
|
||||||
}
|
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||||
if len(ips) == 0 {
|
|
||||||
return nil, types.NewProxyDialError(proxyStr, types.NewProxyResolveError(host))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the first resolved IP
|
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
||||||
addr = net.JoinHostPort(ips[0].String(), port)
|
conn, err := contextDialer.DialContext(dialCtx, "tcp", candidate)
|
||||||
|
dialCancel()
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
}
|
}
|
||||||
|
return nil, types.NewProxyDialError(proxyStr, lastErr)
|
||||||
// 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
|
|
||||||
}, nil
|
}, 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:
|
// The returned dial function can return the following errors:
|
||||||
// - types.ProxyDialError
|
// - 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
|
proxyAddr := proxyURL.Host
|
||||||
if proxyURL.Port() == "" {
|
if proxyURL.Port() == "" {
|
||||||
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), "443")
|
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), defaultPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build Proxy-Authorization header if auth is present
|
// Build Proxy-Authorization header if auth is present
|
||||||
@@ -222,7 +253,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
|
|
||||||
proxyStr := proxyURL.String()
|
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
|
// Establish TCP connection to proxy with timeout
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
||||||
@@ -242,13 +273,15 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade to TLS
|
if useTLS {
|
||||||
tlsConn := tls.Client(conn, &tls.Config{
|
tlsConn := tls.Client(conn, &tls.Config{
|
||||||
ServerName: proxyURL.Hostname(),
|
ServerName: proxyURL.Hostname(),
|
||||||
})
|
})
|
||||||
if err := tlsConn.Handshake(); err != nil {
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
|
}
|
||||||
|
conn = tlsConn
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and send CONNECT request
|
// Build and send CONNECT request
|
||||||
@@ -262,35 +295,35 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
connectReq.Header.Set("Proxy-Authorization", proxyAuth)
|
connectReq.Header.Set("Proxy-Authorization", proxyAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := connectReq.Write(tlsConn); err != nil {
|
if err := connectReq.Write(conn); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read response using buffered reader, but return wrapped connection
|
// Read response using buffered reader, but return wrapped connection
|
||||||
// to preserve any buffered data
|
// to preserve any buffered data
|
||||||
bufReader := bufio.NewReader(tlsConn)
|
bufReader := bufio.NewReader(conn)
|
||||||
resp, err := http.ReadResponse(bufReader, connectReq)
|
resp, err := http.ReadResponse(bufReader, connectReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
resp.Body.Close() //nolint:errcheck,gosec
|
resp.Body.Close() //nolint:errcheck,gosec
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
|
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear deadline for the tunneled connection
|
// Clear deadline for the tunneled connection
|
||||||
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
|
if err := conn.SetDeadline(time.Time{}); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return wrapped connection that uses the buffered reader
|
// Return wrapped connection that uses the buffered reader
|
||||||
// to avoid losing any data that was read ahead
|
// 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 +339,10 @@ func (c *bufferedConn) Read(b []byte) (int, error) {
|
|||||||
return c.reader.Read(b)
|
return c.reader.Read(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHostClientGenerator(clients ...*fasthttp.HostClient) HostClientGenerator {
|
func newClientIndexGenerator(count int) clientIndexGenerator {
|
||||||
switch len(clients) {
|
indexes := make([]int, count)
|
||||||
case 0:
|
for i := range indexes {
|
||||||
hostClient := &fasthttp.HostClient{}
|
indexes[i] = i
|
||||||
return func() *fasthttp.HostClient {
|
|
||||||
return hostClient
|
|
||||||
}
|
|
||||||
case 1:
|
|
||||||
return func() *fasthttp.HostClient {
|
|
||||||
return clients[0]
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return utilsSlice.RandomCycle(nil, clients...)
|
|
||||||
}
|
}
|
||||||
|
return utilsSlice.RandomCycle(nil, indexes...)
|
||||||
}
|
}
|
||||||
@@ -58,8 +58,7 @@ func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
|
|||||||
|
|
||||||
file := &CachedFile{Content: content, Filename: filename}
|
file := &CachedFile{Content: content, Filename: filename}
|
||||||
|
|
||||||
// LoadOrStore handles race condition - if another goroutine
|
// If another goroutine cached it first, LoadOrStore returns theirs
|
||||||
// cached it first, we get theirs (no duplicate storage)
|
|
||||||
actual, _ := fc.cache.LoadOrStore(source, file)
|
actual, _ := fc.cache.LoadOrStore(source, file)
|
||||||
return actual.(*CachedFile), nil
|
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)
|
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := io.ReadAll(resp.Body)
|
// A declared size above the limit is refused before anything is read.
|
||||||
|
if resp.ContentLength > types.RemoteFetchLimit {
|
||||||
|
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", types.NewHTTPFetchError(url, err)
|
return nil, "", types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
if len(content) > types.RemoteFetchLimit {
|
||||||
|
return nil, "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
// Extract filename from URL path
|
// Extract filename from URL path
|
||||||
filename := path.Base(url)
|
filename := path.Base(url)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sarin
|
package sarin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -19,3 +20,17 @@ func firstOrEmpty(values []string) string {
|
|||||||
}
|
}
|
||||||
return values[0]
|
return values[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func safeUintToInt(u uint) int {
|
||||||
|
if u > math.MaxInt {
|
||||||
|
return math.MaxInt
|
||||||
|
}
|
||||||
|
return int(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeUint64ToInt(u uint64) int {
|
||||||
|
if u > math.MaxInt {
|
||||||
|
return math.MaxInt
|
||||||
|
}
|
||||||
|
return int(u)
|
||||||
|
}
|
||||||
+94
-37
@@ -56,9 +56,9 @@ func NewRequestGenerator(
|
|||||||
|
|
||||||
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, lazyTemplateRoot)
|
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, lazyTemplateRoot)
|
||||||
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, lazyTemplateRoot)
|
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, lazyTemplateRoot)
|
||||||
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
|
paramsGenerator, isParamsGeneratorDynamic, paramKeysAreStatic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
|
||||||
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
|
headersGenerator, isHeadersGeneratorDynamic, headerKeysAreStatic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
|
||||||
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
|
cookiesGenerator, isCookiesGeneratorDynamic, cookieKeysAreStatic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
|
||||||
|
|
||||||
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
||||||
var bodyTemplateRoot *template.Template
|
var bodyTemplateRoot *template.Template
|
||||||
@@ -83,13 +83,26 @@ func NewRequestGenerator(
|
|||||||
Cookies: make(map[string][]string),
|
Cookies: make(map[string][]string),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a map's key set is fixed, every request refills exactly the same keys, so
|
||||||
|
// the value slices can be truncated and reused instead of being reallocated after
|
||||||
|
// a clear(). Scripts are excluded: they may swap the maps out or add keys of their
|
||||||
|
// own, and they would observe a leftover empty slice where a key used to be absent.
|
||||||
|
reuseParamSlices := paramKeysAreStatic && !hasScripts
|
||||||
|
reuseHeaderSlices := headerKeysAreStatic && !hasScripts
|
||||||
|
reuseCookieSlices := cookieKeysAreStatic && !hasScripts
|
||||||
|
|
||||||
var (
|
var (
|
||||||
data valuesData
|
data valuesData
|
||||||
path string
|
path string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request) error {
|
return func(req *fasthttp.Request) error {
|
||||||
resetRequestData(reqData)
|
resetStringSliceMap(reqData.Headers, reuseHeaderSlices)
|
||||||
|
resetStringSliceMap(reqData.Params, reuseParamSlices)
|
||||||
|
resetStringSliceMap(reqData.Cookies, reuseCookieSlices)
|
||||||
|
reqData.Method = ""
|
||||||
|
reqData.Path = ""
|
||||||
|
reqData.Body = ""
|
||||||
|
|
||||||
data, err = valuesGenerator()
|
data, err = valuesGenerator()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,20 +156,24 @@ func NewRequestGenerator(
|
|||||||
hasScripts
|
hasScripts
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetRequestData(reqData *script.RequestData) {
|
// resetStringSliceMap empties m for the next render. With reuse it truncates the
|
||||||
reqData.Method = ""
|
// value slices in place so the following appends land in the existing backing
|
||||||
reqData.Path = ""
|
// arrays, without it the map is cleared so no stale key can survive.
|
||||||
reqData.Body = ""
|
func resetStringSliceMap(m map[string][]string, reuse bool) {
|
||||||
clear(reqData.Headers)
|
if !reuse {
|
||||||
clear(reqData.Params)
|
clear(m)
|
||||||
clear(reqData.Cookies)
|
return
|
||||||
|
}
|
||||||
|
for k, v := range m {
|
||||||
|
m[k] = v[:0]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) {
|
func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) {
|
||||||
req.Header.SetHost(host)
|
req.Header.SetHost(host)
|
||||||
req.SetRequestURI(reqData.Path)
|
req.SetRequestURI(reqData.Path)
|
||||||
req.Header.SetMethod(reqData.Method)
|
req.Header.SetMethod(reqData.Method)
|
||||||
req.SetBody([]byte(reqData.Body))
|
req.SetBodyString(reqData.Body)
|
||||||
|
|
||||||
for k, values := range reqData.Headers {
|
for k, values := range reqData.Headers {
|
||||||
for _, v := range values {
|
for _, v := range values {
|
||||||
@@ -164,20 +181,17 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, values := range reqData.Params {
|
if len(reqData.Params) > 0 {
|
||||||
for _, v := range values {
|
args := req.URI().QueryArgs()
|
||||||
req.URI().QueryArgs().Add(k, v)
|
for k, values := range reqData.Params {
|
||||||
|
for _, v := range values {
|
||||||
|
args.Add(k, v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(reqData.Cookies) > 0 {
|
if len(reqData.Cookies) > 0 {
|
||||||
cookieStrings := make([]string, 0, len(reqData.Cookies))
|
req.Header.Add("Cookie", cookieHeaderValue(reqData.Cookies))
|
||||||
for k, values := range reqData.Cookies {
|
|
||||||
for _, v := range values {
|
|
||||||
cookieStrings = append(cookieStrings, k+"="+v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if scheme == "https" {
|
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) {
|
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||||
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot)
|
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot)
|
||||||
|
|
||||||
@@ -221,8 +251,8 @@ func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func()
|
|||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, params, lazyRoot)
|
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, params, lazyRoot)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
@@ -243,11 +273,11 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot
|
|||||||
reqData.Params[key] = append(reqData.Params[key], value)
|
reqData.Params[key] = append(reqData.Params[key], value)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic, keysAreStatic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, headers, lazyRoot)
|
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, headers, lazyRoot)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
@@ -268,11 +298,11 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRo
|
|||||||
reqData.Headers[key] = append(reqData.Headers[key], value)
|
reqData.Headers[key] = append(reqData.Headers[key], value)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic, keysAreStatic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
|
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
@@ -293,14 +323,26 @@ func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRo
|
|||||||
reqData.Cookies[key] = append(reqData.Cookies[key], value)
|
reqData.Cookies[key] = append(reqData.Cookies[key], value)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic, keysAreStatic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) {
|
func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) {
|
||||||
|
// No values configured: hand back one shared empty map instead of allocating a
|
||||||
|
// fresh one for every request. Nothing ever writes to it.
|
||||||
|
if len(values) == 0 {
|
||||||
|
empty := valuesData{Values: map[string]string{}}
|
||||||
|
return func() (valuesData, error) { return empty, nil }
|
||||||
|
}
|
||||||
|
|
||||||
generators := make([]func(_ any) (string, error), len(values))
|
generators := make([]func(_ any) (string, error), len(values))
|
||||||
|
|
||||||
|
isDynamic := false
|
||||||
for i, v := range values {
|
for i, v := range values {
|
||||||
generators[i], _ = createTemplateFunc(v, lazyRoot)
|
var valueIsDynamic bool
|
||||||
|
generators[i], valueIsDynamic = createTemplateFunc(v, lazyRoot)
|
||||||
|
if valueIsDynamic {
|
||||||
|
isDynamic = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -308,7 +350,7 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
|
|||||||
data map[string]string
|
data map[string]string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func() (valuesData, error) {
|
generate := func() (valuesData, error) {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
for _, generator := range generators {
|
for _, generator := range generators {
|
||||||
rendered, err = generator(nil)
|
rendered, err = generator(nil)
|
||||||
@@ -326,6 +368,15 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
|
|||||||
|
|
||||||
return valuesData{Values: result}, nil
|
return valuesData{Values: result}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every value is a literal, so the parsed result is identical on every request:
|
||||||
|
// render and parse once instead of re-running godotenv per request.
|
||||||
|
if !isDynamic {
|
||||||
|
staticResult, staticErr := generate()
|
||||||
|
return func() (valuesData, error) { return staticResult, staticErr }
|
||||||
|
}
|
||||||
|
|
||||||
|
return generate
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) {
|
func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) {
|
||||||
@@ -335,15 +386,19 @@ func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(
|
|||||||
|
|
||||||
tmpl, err := lazyRoot().New("").Parse(value)
|
tmpl, err := lazyRoot().New("").Parse(value)
|
||||||
if err == nil && hasTemplateActions(tmpl) {
|
if err == nil && hasTemplateActions(tmpl) {
|
||||||
var err error
|
var (
|
||||||
|
buf bytes.Buffer
|
||||||
|
err error
|
||||||
|
)
|
||||||
return func(data any) (string, error) {
|
return func(data any) (string, error) {
|
||||||
var buf bytes.Buffer
|
buf.Reset()
|
||||||
if err = tmpl.Execute(&buf, data); err != nil {
|
if err = tmpl.Execute(&buf, data); err != nil {
|
||||||
return "", types.NewTemplateRenderError(err)
|
return "", types.NewTemplateRenderError(err)
|
||||||
}
|
}
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(_ any) (string, error) { return value, nil }, false
|
return func(_ any) (string, error) { return value, nil }, false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,8 +415,9 @@ func buildKeyValueGenerators[T keyValueItem](
|
|||||||
localRand *rand.Rand,
|
localRand *rand.Rand,
|
||||||
items []T,
|
items []T,
|
||||||
lazyRoot func() *template.Template,
|
lazyRoot func() *template.Template,
|
||||||
) ([]keyValueGenerator, bool) {
|
) ([]keyValueGenerator, bool, bool) {
|
||||||
isDynamic := false
|
isDynamic := false
|
||||||
|
keysAreStatic := true
|
||||||
generators := make([]keyValueGenerator, len(items))
|
generators := make([]keyValueGenerator, len(items))
|
||||||
|
|
||||||
for generatorIndex, item := range items {
|
for generatorIndex, item := range items {
|
||||||
@@ -372,6 +428,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
|||||||
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot)
|
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot)
|
||||||
if keyIsDynamic {
|
if keyIsDynamic {
|
||||||
isDynamic = true
|
isDynamic = true
|
||||||
|
keysAreStatic = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate value functions
|
// Generate value functions
|
||||||
@@ -394,7 +451,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return generators, isDynamic
|
return generators, isDynamic, keysAreStatic
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildStringSliceGenerator(
|
func buildStringSliceGenerator(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package sarin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -10,14 +9,22 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
"github.com/charmbracelet/lipgloss/table"
|
"charm.land/lipgloss/v2/table"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultResponseDurationAccuracy uint32 = 1
|
const DefaultResponseDurationAccuracy uint32 = 1
|
||||||
const DefaultResponseColumnMaxWidth = 50
|
const DefaultResponseColumnMaxWidth = 50
|
||||||
|
|
||||||
|
// maxResponseKeys bounds the distinct response keys, so error messages carrying
|
||||||
|
// per-request text cannot grow the map without limit. It stays above the number of
|
||||||
|
// HTTP status codes, so real ones are never merged.
|
||||||
|
const maxResponseKeys = 1000
|
||||||
|
|
||||||
|
// otherResponseKey collects everything that arrives after the key limit is reached.
|
||||||
|
const otherResponseKey = "other"
|
||||||
|
|
||||||
// Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings.
|
// Duration wraps time.Duration to provide consistent JSON/YAML marshaling as human-readable strings.
|
||||||
type Duration time.Duration
|
type Duration time.Duration
|
||||||
|
|
||||||
@@ -96,6 +103,11 @@ func (data *SarinResponseData) Add(responseKey string, responseTime time.Duratio
|
|||||||
defer data.Unlock()
|
defer data.Unlock()
|
||||||
|
|
||||||
response, ok := data.Responses[responseKey]
|
response, ok := data.Responses[responseKey]
|
||||||
|
if !ok && len(data.Responses) >= maxResponseKeys {
|
||||||
|
responseKey = otherResponseKey
|
||||||
|
response, ok = data.Responses[responseKey]
|
||||||
|
}
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
data.Responses[responseKey] = &Response{
|
data.Responses[responseKey] = &Response{
|
||||||
durations: map[time.Duration]uint64{
|
durations: map[time.Duration]uint64{
|
||||||
@@ -159,7 +171,7 @@ func (data *SarinResponseData) PrintTable() {
|
|||||||
return cellStyle
|
return cellStyle
|
||||||
})
|
})
|
||||||
|
|
||||||
fmt.Println(tbl)
|
lipgloss.Println(tbl)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (data *SarinResponseData) PrintJSON() {
|
func (data *SarinResponseData) PrintJSON() {
|
||||||
|
|||||||
+328
-102
@@ -2,8 +2,13 @@ package sarin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,20 +19,165 @@ import (
|
|||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type runtimeMessageLevel uint8
|
type runtimeLogLevel uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
runtimeMessageLevelWarning runtimeMessageLevel = iota
|
runtimeLogLevelInfo runtimeLogLevel = iota
|
||||||
runtimeMessageLevelError
|
runtimeLogLevelError
|
||||||
)
|
)
|
||||||
|
|
||||||
type runtimeMessage struct {
|
type runtimeLog struct {
|
||||||
timestamp time.Time
|
timestamp time.Time
|
||||||
level runtimeMessageLevel
|
level runtimeLogLevel
|
||||||
text string
|
text string
|
||||||
}
|
}
|
||||||
|
|
||||||
type messageSender func(level runtimeMessageLevel, 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 {
|
type sarin struct {
|
||||||
workers uint
|
workers uint
|
||||||
@@ -40,16 +190,24 @@ type sarin struct {
|
|||||||
totalRequests *uint64
|
totalRequests *uint64
|
||||||
totalDuration *time.Duration
|
totalDuration *time.Duration
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
quiet bool
|
showProgress bool
|
||||||
skipCertVerify bool
|
skipCertVerify bool
|
||||||
values []string
|
values []string
|
||||||
collectStats bool
|
collectStats bool
|
||||||
dryRun bool
|
dryRun bool
|
||||||
|
logInfo bool
|
||||||
|
logError bool
|
||||||
|
logFile string
|
||||||
|
|
||||||
hostClients []*fasthttp.HostClient
|
// jobsCtx ends the run, either when the duration expires or on Ctrl+C.
|
||||||
responses *SarinResponseData
|
jobsCtx context.Context //nolint:containedctx
|
||||||
fileCache *FileCache
|
jobsCancel context.CancelFunc
|
||||||
scriptChain *script.Chain
|
|
||||||
|
hostClients []*fasthttp.HostClient
|
||||||
|
scriptHTTPClients []*scriptHTTPClient
|
||||||
|
responses *SarinResponseData
|
||||||
|
fileCache *FileCache
|
||||||
|
scriptChain *script.Chain
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSarin creates a new sarin instance for load testing.
|
// NewSarin creates a new sarin instance for load testing.
|
||||||
@@ -65,8 +223,9 @@ func NewSarin(
|
|||||||
workers uint,
|
workers uint,
|
||||||
totalRequests *uint64,
|
totalRequests *uint64,
|
||||||
totalDuration *time.Duration,
|
totalDuration *time.Duration,
|
||||||
quiet bool,
|
showProgress bool,
|
||||||
skipCertVerify bool,
|
skipCertVerify bool,
|
||||||
|
maxResponseBody uint64,
|
||||||
params types.Params,
|
params types.Params,
|
||||||
headers types.Headers,
|
headers types.Headers,
|
||||||
cookies types.Cookies,
|
cookies types.Cookies,
|
||||||
@@ -75,6 +234,8 @@ func NewSarin(
|
|||||||
values []string,
|
values []string,
|
||||||
collectStats bool,
|
collectStats bool,
|
||||||
dryRun bool,
|
dryRun bool,
|
||||||
|
logLevel string,
|
||||||
|
logFile string,
|
||||||
luaScripts []string,
|
luaScripts []string,
|
||||||
jsScripts []string,
|
jsScripts []string,
|
||||||
) (*sarin, error) {
|
) (*sarin, error) {
|
||||||
@@ -82,7 +243,23 @@ func NewSarin(
|
|||||||
workers = 1
|
workers = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -100,25 +277,42 @@ func NewSarin(
|
|||||||
|
|
||||||
scriptChain := script.NewChain(luaSources, jsSources)
|
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{
|
srn := &sarin{
|
||||||
workers: workers,
|
jobsCtx: jobsCtx,
|
||||||
requestURL: requestURL,
|
jobsCancel: jobsCancel,
|
||||||
methods: methods,
|
workers: workers,
|
||||||
params: params,
|
requestURL: requestURL,
|
||||||
headers: headers,
|
methods: methods,
|
||||||
cookies: cookies,
|
params: params,
|
||||||
bodies: bodies,
|
headers: headers,
|
||||||
totalRequests: totalRequests,
|
cookies: cookies,
|
||||||
totalDuration: totalDuration,
|
bodies: bodies,
|
||||||
timeout: timeout,
|
totalRequests: totalRequests,
|
||||||
quiet: quiet,
|
totalDuration: totalDuration,
|
||||||
skipCertVerify: skipCertVerify,
|
timeout: timeout,
|
||||||
values: values,
|
showProgress: showProgress,
|
||||||
collectStats: collectStats,
|
skipCertVerify: skipCertVerify,
|
||||||
dryRun: dryRun,
|
values: values,
|
||||||
hostClients: hostClients,
|
collectStats: collectStats,
|
||||||
fileCache: NewFileCache(time.Second * 10),
|
dryRun: dryRun,
|
||||||
scriptChain: scriptChain,
|
logInfo: logInfo,
|
||||||
|
logError: logError,
|
||||||
|
logFile: logFile,
|
||||||
|
hostClients: hostClients,
|
||||||
|
scriptHTTPClients: scriptHTTPClients,
|
||||||
|
fileCache: NewFileCache(time.Second * 30),
|
||||||
|
scriptChain: scriptChain,
|
||||||
}
|
}
|
||||||
|
|
||||||
if collectStats {
|
if collectStats {
|
||||||
@@ -128,73 +322,92 @@ func NewSarin(
|
|||||||
return srn, nil
|
return srn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) GetResponses() *SarinResponseData {
|
func (s sarin) GetResponses() *SarinResponseData {
|
||||||
return q.responses
|
return s.responses
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
func (s sarin) Start(stopCtrl *StopController) {
|
||||||
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
|
||||||
|
|
||||||
var workersWG sync.WaitGroup
|
var workersWG sync.WaitGroup
|
||||||
jobsCh := make(chan struct{}, max(q.workers, 1))
|
jobsCh := make(chan struct{}, max(s.workers, 1))
|
||||||
|
|
||||||
var counter atomic.Uint64
|
var counter atomic.Uint64
|
||||||
|
|
||||||
totalRequests := uint64(0)
|
totalRequests := uint64(0)
|
||||||
if q.totalRequests != nil {
|
if s.totalRequests != nil {
|
||||||
totalRequests = *q.totalRequests
|
totalRequests = *s.totalRequests
|
||||||
}
|
}
|
||||||
|
|
||||||
var streamCtx context.Context
|
onTerminal := IsInteractiveTerminal(os.Stdout.Fd())
|
||||||
var streamCancel context.CancelFunc
|
// The progress bar needs an interactive terminal to render.
|
||||||
var streamCh chan struct{}
|
showProgressBar := s.showProgress && onTerminal
|
||||||
var messageChannel chan runtimeMessage
|
// The bubbletea TUI hosts the bar and/or the live log box, so it runs whenever
|
||||||
var sendMessage messageSender
|
// 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 == "")
|
||||||
|
|
||||||
if !q.quiet && !term.IsTerminal(os.Stdout.Fd()) {
|
// Open the log file up front, before registering any defer, so a bad path
|
||||||
q.quiet = true
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
if q.quiet {
|
var (
|
||||||
sendMessage = func(level runtimeMessageLevel, text string) {}
|
streamCtx context.Context
|
||||||
} else {
|
streamCancel context.CancelFunc
|
||||||
|
streamCh chan struct{}
|
||||||
|
tuiLogChannel chan runtimeLog
|
||||||
|
)
|
||||||
|
if runTUI {
|
||||||
streamCtx, streamCancel = context.WithCancel(context.Background())
|
streamCtx, streamCancel = context.WithCancel(context.Background())
|
||||||
defer streamCancel()
|
defer streamCancel()
|
||||||
streamCh = make(chan struct{})
|
streamCh = make(chan struct{})
|
||||||
messageChannel = make(chan runtimeMessage, max(q.workers, 1))
|
tuiLogChannel = make(chan runtimeLog, max(s.workers, 1))
|
||||||
sendMessage = func(level runtimeMessageLevel, text string) {
|
}
|
||||||
messageChannel <- runtimeMessage{
|
|
||||||
timestamp: time.Now(),
|
// Route logs: to the file if given, else to the TUI log box while it runs,
|
||||||
level: level,
|
// otherwise to stderr.
|
||||||
text: text,
|
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
|
// Start workers
|
||||||
q.startWorkers(&workersWG, jobsCh, q.hostClients, &counter, sendMessage)
|
s.startWorkers(&workersWG, jobsCh, &counter, sendLog, sendRespLog)
|
||||||
|
|
||||||
if !q.quiet {
|
if runTUI {
|
||||||
// Start streaming to terminal
|
go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
|
||||||
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
|
|
||||||
go q.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, messageChannel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup duration-based cancellation
|
// Setup duration-based cancellation
|
||||||
q.setupDurationTimeout(ctx, jobsCancel)
|
s.setupDurationTimeout()
|
||||||
// Distribute jobs to workers.
|
// Distribute jobs to workers.
|
||||||
// This blocks until all jobs are sent or the context is canceled.
|
// This blocks until all jobs are sent or the context is canceled.
|
||||||
q.sendJobs(jobsCtx, jobsCh)
|
s.sendJobs(jobsCh)
|
||||||
|
|
||||||
// Close the jobs channel so workers stop after completing their current job
|
// Close the jobs channel so workers stop after completing their current job
|
||||||
close(jobsCh)
|
close(jobsCh)
|
||||||
// Wait until all workers stopped
|
// Wait until all workers stopped
|
||||||
workersWG.Wait()
|
workersWG.Wait()
|
||||||
if messageChannel != nil {
|
if tuiLogChannel != nil {
|
||||||
close(messageChannel)
|
close(tuiLogChannel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !q.quiet {
|
if runTUI {
|
||||||
// Stop the progress streaming
|
// Stop the progress streaming
|
||||||
streamCancel()
|
streamCancel()
|
||||||
// Wait until progress streaming has completely stopped
|
// Wait until progress streaming has completely stopped
|
||||||
@@ -202,65 +415,78 @@ func (q sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newHostClients initializes HTTP clients for the given configuration.
|
// newWriterLog builds the loggers that write formatted lines to w (a log file or
|
||||||
// It can return the following errors:
|
// stderr). sendLog stays general (it filters by each log's level), while sendRespLog
|
||||||
// - types.ProxyDialError
|
// only ever emits info, so its decision is baked once into a no-op when off.
|
||||||
func newHostClients(
|
func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
|
||||||
ctx context.Context,
|
// log.Logger serializes writes with its own mutex, so concurrent workers
|
||||||
timeout time.Duration,
|
// won't interleave lines.
|
||||||
proxies types.Proxies,
|
logger := log.New(w, "", 0)
|
||||||
workers uint,
|
|
||||||
requestURL *url.URL,
|
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
|
||||||
skipCertVerify bool,
|
logger.Println(formatRuntimeLogLine(time.Now(), level, text))
|
||||||
) ([]*fasthttp.HostClient, error) {
|
})
|
||||||
proxiesRaw := make([]url.URL, len(proxies))
|
|
||||||
for i, proxy := range proxies {
|
var sendRespLog respLogger = noopRespLog
|
||||||
proxiesRaw[i] = url.URL(proxy)
|
if s.logInfo {
|
||||||
|
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
|
||||||
|
logger.Println(formatRuntimeLogLine(time.Now(), runtimeLogLevelInfo, respToLogJSON(duration, resp)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NewHostClients(
|
return sendLog, sendRespLog
|
||||||
ctx,
|
|
||||||
timeout,
|
|
||||||
proxiesRaw,
|
|
||||||
workers,
|
|
||||||
requestURL,
|
|
||||||
skipCertVerify,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendMessage messageSender) {
|
// newChannelLog builds the loggers that feed the TUI log box through ch, with the
|
||||||
for range max(q.workers, 1) {
|
// 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() {
|
wg.Go(func() {
|
||||||
q.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendMessage)
|
s.Worker(jobs, counter, sendLog, sendRespLog)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
|
func (s sarin) setupDurationTimeout() {
|
||||||
if q.totalDuration != nil {
|
if s.totalDuration != nil {
|
||||||
go func() {
|
go func() {
|
||||||
timer := time.NewTimer(*q.totalDuration)
|
timer := time.NewTimer(*s.totalDuration)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
select {
|
select {
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
cancel()
|
s.jobsCancel()
|
||||||
case <-ctx.Done():
|
case <-s.jobsCtx.Done():
|
||||||
// Context cancelled, cleanup
|
// Context cancelled, cleanup
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
|
func (s sarin) sendJobs(jobs chan<- struct{}) {
|
||||||
if q.totalRequests != nil && *q.totalRequests > 0 {
|
if s.totalRequests != nil && *s.totalRequests > 0 {
|
||||||
for range *q.totalRequests {
|
for range *s.totalRequests {
|
||||||
if ctx.Err() != nil {
|
if s.jobsCtx.Err() != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
jobs <- struct{}{}
|
jobs <- struct{}{}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for ctx.Err() == nil {
|
for s.jobsCtx.Err() == nil {
|
||||||
jobs <- struct{}{}
|
jobs <- struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package sarin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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, independent of -T.
|
||||||
|
const scriptHTTPDefaultTimeout = 30 * 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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, fasthttp.DialDualStackTimeout, 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := resp.Body()
|
||||||
|
|
||||||
|
return &script.HTTPResponse{
|
||||||
|
Status: resp.StatusCode(),
|
||||||
|
Headers: collectRespHeaders(resp),
|
||||||
|
Body: string(body),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const forceExitCode = 130
|
const forceExitCode = 130
|
||||||
|
|||||||
+55
-21
@@ -97,7 +97,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
return string(data), nil
|
return string(data), nil
|
||||||
},
|
},
|
||||||
// json_Object builds a JSON object from interleaved key-value pairs and returns it
|
// 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.
|
// as a JSON string. Keys must be strings, values may be any JSON-encodable type.
|
||||||
// Usage: {{ json_Object "name" "Alice" "age" 30 }}
|
// Usage: {{ json_Object "name" "Alice" "age" 30 }}
|
||||||
"json_Object": func(pairs ...any) (string, error) {
|
"json_Object": func(pairs ...any) (string, error) {
|
||||||
if len(pairs)%2 != 0 {
|
if len(pairs)%2 != 0 {
|
||||||
@@ -207,18 +207,20 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
|
|
||||||
// Fakeit / Person
|
// Fakeit / Person
|
||||||
// "fakeit_Person": fakeit.Person() *PersonInfo,
|
// "fakeit_Person": fakeit.Person() *PersonInfo,
|
||||||
"fakeit_Name": fakeit.Name,
|
"fakeit_Name": fakeit.Name,
|
||||||
"fakeit_NamePrefix": fakeit.NamePrefix,
|
"fakeit_NamePrefix": fakeit.NamePrefix,
|
||||||
"fakeit_NameSuffix": fakeit.NameSuffix,
|
"fakeit_NameSuffix": fakeit.NameSuffix,
|
||||||
"fakeit_FirstName": fakeit.FirstName,
|
"fakeit_FirstName": fakeit.FirstName,
|
||||||
"fakeit_MiddleName": fakeit.MiddleName,
|
"fakeit_MiddleName": fakeit.MiddleName,
|
||||||
"fakeit_LastName": fakeit.LastName,
|
"fakeit_LastName": fakeit.LastName,
|
||||||
"fakeit_Gender": fakeit.Gender,
|
"fakeit_Gender": fakeit.Gender,
|
||||||
"fakeit_Age": fakeit.Age,
|
"fakeit_Age": fakeit.Age,
|
||||||
"fakeit_Ethnicity": fakeit.Ethnicity,
|
"fakeit_Ethnicity": fakeit.Ethnicity,
|
||||||
"fakeit_SSN": fakeit.SSN,
|
"fakeit_SSN": fakeit.SSN,
|
||||||
"fakeit_EIN": fakeit.EIN,
|
"fakeit_EIN": fakeit.EIN,
|
||||||
"fakeit_Hobby": fakeit.Hobby,
|
"fakeit_Hobby": fakeit.Hobby,
|
||||||
|
"fakeit_SocialMedia": fakeit.SocialMedia,
|
||||||
|
"fakeit_Bio": fakeit.Bio,
|
||||||
// "fakeit_Contact": fakeit.Contact() *ContactInfo,
|
// "fakeit_Contact": fakeit.Contact() *ContactInfo,
|
||||||
"fakeit_Email": fakeit.Email,
|
"fakeit_Email": fakeit.Email,
|
||||||
"fakeit_Phone": fakeit.Phone,
|
"fakeit_Phone": fakeit.Phone,
|
||||||
@@ -232,6 +234,9 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
// "fakeit_Generate": fakeit.Generate(value string) string,
|
// "fakeit_Generate": fakeit.Generate(value string) string,
|
||||||
"fakeit_Regex": fakeit.Regex,
|
"fakeit_Regex": fakeit.Regex,
|
||||||
|
|
||||||
|
// Fakeit / Database
|
||||||
|
// "fakeit_SQL": fakeit.SQL(so *SQLOptions) (string, error),
|
||||||
|
|
||||||
// Fakeit / Auth
|
// Fakeit / Auth
|
||||||
"fakeit_Username": fakeit.Username,
|
"fakeit_Username": fakeit.Username,
|
||||||
"fakeit_Password": fakeit.Password,
|
"fakeit_Password": fakeit.Password,
|
||||||
@@ -269,6 +274,15 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
return value
|
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 / Game
|
||||||
"fakeit_Gamertag": fakeit.Gamertag,
|
"fakeit_Gamertag": fakeit.Gamertag,
|
||||||
// "fakeit_Dice": fakeit.Dice(numDice uint, sides []uint) []uint,
|
// "fakeit_Dice": fakeit.Dice(numDice uint, sides []uint) []uint,
|
||||||
@@ -302,12 +316,16 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_NounCollectiveThing": fakeit.NounCollectiveThing,
|
"fakeit_NounCollectiveThing": fakeit.NounCollectiveThing,
|
||||||
"fakeit_NounCountable": fakeit.NounCountable,
|
"fakeit_NounCountable": fakeit.NounCountable,
|
||||||
"fakeit_NounUncountable": fakeit.NounUncountable,
|
"fakeit_NounUncountable": fakeit.NounUncountable,
|
||||||
|
"fakeit_NounProper": fakeit.NounProper,
|
||||||
|
"fakeit_NounDeterminer": fakeit.NounDeterminer,
|
||||||
|
|
||||||
// Verbs
|
// Verbs
|
||||||
"fakeit_Verb": fakeit.Verb,
|
"fakeit_Verb": fakeit.Verb,
|
||||||
"fakeit_VerbAction": fakeit.VerbAction,
|
"fakeit_VerbAction": fakeit.VerbAction,
|
||||||
"fakeit_VerbLinking": fakeit.VerbLinking,
|
"fakeit_VerbLinking": fakeit.VerbLinking,
|
||||||
"fakeit_VerbHelping": fakeit.VerbHelping,
|
"fakeit_VerbHelping": fakeit.VerbHelping,
|
||||||
|
"fakeit_VerbTransitive": fakeit.VerbTransitive,
|
||||||
|
"fakeit_VerbIntransitive": fakeit.VerbIntransitive,
|
||||||
|
|
||||||
// Adverbs
|
// Adverbs
|
||||||
"fakeit_Adverb": fakeit.Adverb,
|
"fakeit_Adverb": fakeit.Adverb,
|
||||||
@@ -344,6 +362,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_PronounDemonstrative": fakeit.PronounDemonstrative,
|
"fakeit_PronounDemonstrative": fakeit.PronounDemonstrative,
|
||||||
"fakeit_PronounInterrogative": fakeit.PronounInterrogative,
|
"fakeit_PronounInterrogative": fakeit.PronounInterrogative,
|
||||||
"fakeit_PronounRelative": fakeit.PronounRelative,
|
"fakeit_PronounRelative": fakeit.PronounRelative,
|
||||||
|
"fakeit_PronounIndefinite": fakeit.PronounIndefinite,
|
||||||
|
|
||||||
// Connectives
|
// Connectives
|
||||||
"fakeit_Connective": fakeit.Connective,
|
"fakeit_Connective": fakeit.Connective,
|
||||||
@@ -355,7 +374,8 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_ConnectiveExamplify": fakeit.ConnectiveExamplify,
|
"fakeit_ConnectiveExamplify": fakeit.ConnectiveExamplify,
|
||||||
|
|
||||||
// Words
|
// Words
|
||||||
"fakeit_Word": fakeit.Word,
|
"fakeit_Word": fakeit.Word,
|
||||||
|
"fakeit_Interjection": fakeit.Interjection,
|
||||||
|
|
||||||
// Text
|
// Text
|
||||||
"fakeit_Sentence": fakeit.Sentence,
|
"fakeit_Sentence": fakeit.Sentence,
|
||||||
@@ -366,6 +386,11 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_Question": fakeit.Question,
|
"fakeit_Question": fakeit.Question,
|
||||||
"fakeit_Quote": fakeit.Quote,
|
"fakeit_Quote": fakeit.Quote,
|
||||||
"fakeit_Phrase": fakeit.Phrase,
|
"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 / Foods
|
||||||
"fakeit_Fruit": fakeit.Fruit,
|
"fakeit_Fruit": fakeit.Fruit,
|
||||||
@@ -375,6 +400,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_Dinner": fakeit.Dinner,
|
"fakeit_Dinner": fakeit.Dinner,
|
||||||
"fakeit_Snack": fakeit.Snack,
|
"fakeit_Snack": fakeit.Snack,
|
||||||
"fakeit_Dessert": fakeit.Dessert,
|
"fakeit_Dessert": fakeit.Dessert,
|
||||||
|
"fakeit_Drink": fakeit.Drink,
|
||||||
|
|
||||||
// Fakeit / Misc
|
// Fakeit / Misc
|
||||||
"fakeit_Bool": fakeit.Bool,
|
"fakeit_Bool": fakeit.Bool,
|
||||||
@@ -387,6 +413,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_Color": fakeit.Color,
|
"fakeit_Color": fakeit.Color,
|
||||||
"fakeit_HexColor": fakeit.HexColor,
|
"fakeit_HexColor": fakeit.HexColor,
|
||||||
"fakeit_RGBColor": fakeit.RGBColor,
|
"fakeit_RGBColor": fakeit.RGBColor,
|
||||||
|
"fakeit_HSLColor": fakeit.HSLColor,
|
||||||
"fakeit_SafeColor": fakeit.SafeColor,
|
"fakeit_SafeColor": fakeit.SafeColor,
|
||||||
"fakeit_NiceColors": fakeit.NiceColors,
|
"fakeit_NiceColors": fakeit.NiceColors,
|
||||||
|
|
||||||
@@ -530,6 +557,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
// Fakeit / Language
|
// Fakeit / Language
|
||||||
"fakeit_Language": fakeit.Language,
|
"fakeit_Language": fakeit.Language,
|
||||||
"fakeit_LanguageAbbreviation": fakeit.LanguageAbbreviation,
|
"fakeit_LanguageAbbreviation": fakeit.LanguageAbbreviation,
|
||||||
|
"fakeit_LanguageBCP": fakeit.LanguageBCP,
|
||||||
"fakeit_ProgrammingLanguage": fakeit.ProgrammingLanguage,
|
"fakeit_ProgrammingLanguage": fakeit.ProgrammingLanguage,
|
||||||
|
|
||||||
// Fakeit / Number
|
// Fakeit / Number
|
||||||
@@ -540,25 +568,29 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_Int16": fakeit.Int16,
|
"fakeit_Int16": fakeit.Int16,
|
||||||
"fakeit_Int32": fakeit.Int32,
|
"fakeit_Int32": fakeit.Int32,
|
||||||
"fakeit_Int64": fakeit.Int64,
|
"fakeit_Int64": fakeit.Int64,
|
||||||
|
"fakeit_IntRange": fakeit.IntRange,
|
||||||
"fakeit_Uint": fakeit.Uint,
|
"fakeit_Uint": fakeit.Uint,
|
||||||
"fakeit_UintN": fakeit.UintN,
|
"fakeit_UintN": fakeit.UintN,
|
||||||
"fakeit_Uint8": fakeit.Uint8,
|
"fakeit_Uint8": fakeit.Uint8,
|
||||||
"fakeit_Uint16": fakeit.Uint16,
|
"fakeit_Uint16": fakeit.Uint16,
|
||||||
"fakeit_Uint32": fakeit.Uint32,
|
"fakeit_Uint32": fakeit.Uint32,
|
||||||
"fakeit_Uint64": fakeit.Uint64,
|
"fakeit_Uint64": fakeit.Uint64,
|
||||||
|
"fakeit_UintRange": fakeit.UintRange,
|
||||||
"fakeit_Float32": fakeit.Float32,
|
"fakeit_Float32": fakeit.Float32,
|
||||||
"fakeit_Float32Range": fakeit.Float32Range,
|
"fakeit_Float32Range": fakeit.Float32Range,
|
||||||
"fakeit_Float64": fakeit.Float64,
|
"fakeit_Float64": fakeit.Float64,
|
||||||
"fakeit_Float64Range": fakeit.Float64Range,
|
"fakeit_Float64Range": fakeit.Float64Range,
|
||||||
// "fakeit_ShuffleInts": fakeit.ShuffleInts,
|
// "fakeit_ShuffleInts": fakeit.ShuffleInts,
|
||||||
"fakeit_RandomInt": fakeit.RandomInt,
|
"fakeit_RandomInt": fakeit.RandomInt,
|
||||||
"fakeit_HexUint": fakeit.HexUint,
|
"fakeit_RandomUint": fakeit.RandomUint,
|
||||||
|
"fakeit_HexUint": fakeit.HexUint,
|
||||||
|
|
||||||
// Fakeit / String
|
// Fakeit / String
|
||||||
"fakeit_Digit": fakeit.Digit,
|
"fakeit_Digit": fakeit.Digit,
|
||||||
"fakeit_DigitN": fakeit.DigitN,
|
"fakeit_DigitN": fakeit.DigitN,
|
||||||
"fakeit_Letter": fakeit.Letter,
|
"fakeit_Letter": fakeit.Letter,
|
||||||
"fakeit_LetterN": fakeit.LetterN,
|
"fakeit_LetterN": fakeit.LetterN,
|
||||||
|
"fakeit_Vowel": fakeit.Vowel,
|
||||||
"fakeit_Lexify": fakeit.Lexify,
|
"fakeit_Lexify": fakeit.Lexify,
|
||||||
"fakeit_Numerify": fakeit.Numerify,
|
"fakeit_Numerify": fakeit.Numerify,
|
||||||
// "fakeit_ShuffleStrings": fakeit.ShuffleStrings,
|
// "fakeit_ShuffleStrings": fakeit.ShuffleStrings,
|
||||||
@@ -602,12 +634,14 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
|
|
||||||
// Fakeit / Error
|
// Fakeit / Error
|
||||||
"fakeit_Error": func() string { return fakeit.Error().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_ErrorDatabase": func() string { return fakeit.ErrorDatabase().Error() },
|
||||||
"fakeit_ErrorGRPC": func() string { return fakeit.ErrorGRPC().Error() },
|
"fakeit_ErrorGRPC": func() string { return fakeit.ErrorGRPC().Error() },
|
||||||
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
|
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
|
||||||
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
|
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
|
||||||
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
|
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
|
||||||
"fakeit_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": fakeit.School,
|
"fakeit_School": fakeit.School,
|
||||||
@@ -717,7 +751,7 @@ func NewDefaultBodyTemplateFuncMap(
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(val, "@@"):
|
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 {
|
if err := writer.WriteField(key, val[1:]); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
+162
-116
@@ -2,38 +2,86 @@ package sarin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/progress"
|
"charm.land/bubbles/v2/progress"
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"charm.land/bubbles/v2/spinner"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type tickMsg time.Time
|
type tickMsg time.Time
|
||||||
|
|
||||||
var (
|
// logBatch carries the log lines accumulated since the last repaint.
|
||||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
|
type logBatch []runtimeLog
|
||||||
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
|
|
||||||
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D")).Bold(true)
|
const (
|
||||||
messageChannelStyle = lipgloss.NewStyle().
|
// logBoxLines is how many lines the log box shows at once.
|
||||||
Border(lipgloss.ThickBorder(), false, false, false, true).
|
logBoxLines = 8
|
||||||
BorderForeground(lipgloss.Color("#757575")).
|
// logBatchInterval is how often accumulated logs are handed to the program.
|
||||||
PaddingLeft(1).
|
logBatchInterval = 250 * time.Millisecond
|
||||||
Margin(1, 0, 0, 0).
|
|
||||||
Foreground(lipgloss.Color("#888888"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 {
|
type progressModel struct {
|
||||||
progress progress.Model
|
progress progress.Model
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
messages []string
|
logs []string
|
||||||
counter *atomic.Uint64
|
counter *atomic.Uint64
|
||||||
current uint64
|
|
||||||
maxValue uint64
|
maxValue uint64
|
||||||
|
showBar bool
|
||||||
ctx context.Context //nolint:containedctx
|
ctx context.Context //nolint:containedctx
|
||||||
stop func()
|
stop func()
|
||||||
cancelling bool
|
cancelling bool
|
||||||
@@ -45,33 +93,24 @@ func (m progressModel) Init() tea.Cmd {
|
|||||||
|
|
||||||
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyPressMsg:
|
||||||
if msg.Type == tea.KeyCtrlC {
|
if msg.String() == "ctrl+c" {
|
||||||
m.cancelling = true
|
m.cancelling = true
|
||||||
m.stop()
|
m.stop()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.progress.Width = max(10, msg.Width-1)
|
m.progress.SetWidth(max(10, msg.Width-1))
|
||||||
if m.ctx.Err() != nil {
|
if m.ctx.Err() != nil {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case runtimeMessage:
|
case logBatch:
|
||||||
var msgBuilder strings.Builder
|
for _, entry := range msg {
|
||||||
msgBuilder.WriteString("[")
|
m.logs = append(m.logs[1:], renderRuntimeLog(entry))
|
||||||
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 {
|
if m.ctx.Err() != nil {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
@@ -91,39 +130,28 @@ func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m progressModel) View() string {
|
func (m progressModel) View() tea.View {
|
||||||
var messagesBuilder strings.Builder
|
var b strings.Builder
|
||||||
for i, msg := range m.messages {
|
if box := renderLogBox(m.logs); box != "" {
|
||||||
if len(msg) > 0 {
|
b.WriteString(box)
|
||||||
messagesBuilder.WriteString(msg)
|
b.WriteString("\n")
|
||||||
if i < len(m.messages)-1 {
|
|
||||||
messagesBuilder.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalBuilder strings.Builder
|
if m.showBar {
|
||||||
if messagesBuilder.Len() > 0 {
|
current := m.counter.Load()
|
||||||
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
|
b.WriteString("\n ")
|
||||||
finalBuilder.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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
m.current = m.counter.Load()
|
b.WriteString("\n\n ")
|
||||||
finalBuilder.WriteString("\n ")
|
b.WriteString(helpLine(m.cancelling))
|
||||||
finalBuilder.WriteString(strconv.FormatUint(m.current, 10))
|
return tea.NewView(b.String())
|
||||||
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... (Ctrl+C again to force)"))
|
|
||||||
} else {
|
|
||||||
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
|
|
||||||
}
|
|
||||||
return finalBuilder.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func progressTickCmd() tea.Cmd {
|
func progressTickCmd() tea.Cmd {
|
||||||
@@ -138,7 +166,8 @@ type infiniteProgressModel struct {
|
|||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
counter *atomic.Uint64
|
counter *atomic.Uint64
|
||||||
messages []string
|
logs []string
|
||||||
|
showBar bool
|
||||||
ctx context.Context //nolint:containedctx
|
ctx context.Context //nolint:containedctx
|
||||||
quit bool
|
quit bool
|
||||||
stop func()
|
stop func()
|
||||||
@@ -151,26 +180,17 @@ func (m infiniteProgressModel) Init() tea.Cmd {
|
|||||||
|
|
||||||
func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyPressMsg:
|
||||||
if msg.Type == tea.KeyCtrlC {
|
if msg.String() == "ctrl+c" {
|
||||||
m.cancelling = true
|
m.cancelling = true
|
||||||
m.stop()
|
m.stop()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case runtimeMessage:
|
case logBatch:
|
||||||
var msgBuilder strings.Builder
|
for _, entry := range msg {
|
||||||
msgBuilder.WriteString("[")
|
m.logs = append(m.logs[1:], renderRuntimeLog(entry))
|
||||||
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 {
|
if m.ctx.Err() != nil {
|
||||||
m.quit = true
|
m.quit = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -188,65 +208,64 @@ func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m infiniteProgressModel) View() string {
|
func (m infiniteProgressModel) View() tea.View {
|
||||||
var messagesBuilder strings.Builder
|
var b strings.Builder
|
||||||
for i, msg := range m.messages {
|
if box := renderLogBox(m.logs); box != "" {
|
||||||
if len(msg) > 0 {
|
b.WriteString(box)
|
||||||
messagesBuilder.WriteString(msg)
|
b.WriteString("\n")
|
||||||
if i < len(m.messages)-1 {
|
|
||||||
messagesBuilder.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalBuilder strings.Builder
|
// Without a spinner, the view is just the log box (plus help until quit).
|
||||||
if messagesBuilder.Len() > 0 {
|
if !m.showBar {
|
||||||
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
|
if !m.quit {
|
||||||
finalBuilder.WriteString("\n")
|
b.WriteString("\n\n ")
|
||||||
|
b.WriteString(helpLine(m.cancelling))
|
||||||
|
}
|
||||||
|
return tea.NewView(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.quit {
|
if m.quit {
|
||||||
finalBuilder.WriteString("\n ")
|
b.WriteString("\n ")
|
||||||
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
||||||
finalBuilder.WriteString(" ")
|
b.WriteString(" ")
|
||||||
finalBuilder.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
|
b.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
|
||||||
finalBuilder.WriteString(" ")
|
b.WriteString(" ")
|
||||||
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||||
finalBuilder.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
} else {
|
} else {
|
||||||
finalBuilder.WriteString("\n ")
|
b.WriteString("\n ")
|
||||||
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
||||||
finalBuilder.WriteString(" ")
|
b.WriteString(" ")
|
||||||
finalBuilder.WriteString(m.spinner.View())
|
b.WriteString(m.spinner.View())
|
||||||
finalBuilder.WriteString(" ")
|
b.WriteString(" ")
|
||||||
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||||
finalBuilder.WriteString("\n\n ")
|
b.WriteString("\n\n ")
|
||||||
if m.cancelling {
|
b.WriteString(helpLine(m.cancelling))
|
||||||
finalBuilder.WriteString(helpStyle.Render("Stopping... (Ctrl+C again to force)"))
|
|
||||||
} else {
|
|
||||||
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return finalBuilder.String()
|
return tea.NewView(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) streamProgress(
|
func (s sarin) streamProgress(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
stopCtrl *StopController,
|
stopCtrl *StopController,
|
||||||
done chan<- struct{},
|
done chan<- struct{},
|
||||||
total uint64,
|
total uint64,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
messageChannel <-chan runtimeMessage,
|
logChannel <-chan runtimeLog,
|
||||||
|
showBar bool,
|
||||||
) {
|
) {
|
||||||
var program *tea.Program
|
var program *tea.Program
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
model := progressModel{
|
model := progressModel{
|
||||||
progress: progress.New(progress.WithGradient("#151594", "#00D4FF")),
|
progress: progress.New(
|
||||||
|
progress.WithColors(lipgloss.Color("#151594"), lipgloss.Color("#00D4FF")),
|
||||||
|
progress.WithFillCharacters(progress.DefaultFullCharFullBlock, progress.DefaultEmptyCharBlock),
|
||||||
|
),
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
messages: make([]string, 8),
|
logs: make([]string, logBoxLines),
|
||||||
counter: counter,
|
counter: counter,
|
||||||
current: 0,
|
|
||||||
maxValue: total,
|
maxValue: total,
|
||||||
|
showBar: showBar,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
stop: stopCtrl.Stop,
|
stop: stopCtrl.Stop,
|
||||||
}
|
}
|
||||||
@@ -274,7 +293,8 @@ func (q sarin) streamProgress(
|
|||||||
),
|
),
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
counter: counter,
|
counter: counter,
|
||||||
messages: make([]string, 8),
|
logs: make([]string, logBoxLines),
|
||||||
|
showBar: showBar,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
stop: stopCtrl.Stop,
|
stop: stopCtrl.Stop,
|
||||||
quit: false,
|
quit: false,
|
||||||
@@ -286,9 +306,35 @@ func (q sarin) streamProgress(
|
|||||||
stopCtrl.AttachProgram(program)
|
stopCtrl.AttachProgram(program)
|
||||||
defer stopCtrl.AttachProgram(nil)
|
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() {
|
go func() {
|
||||||
for msg := range messageChannel {
|
ticker := time.NewTicker(logBatchInterval)
|
||||||
program.Send(msg)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
+154
-79
@@ -1,39 +1,71 @@
|
|||||||
package sarin
|
package sarin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"go.aykhans.me/sarin/internal/script"
|
"go.aykhans.me/sarin/internal/script"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
const dryRunResponseKey = "dry-run"
|
const dryRunResponseKey = "dry-run"
|
||||||
|
|
||||||
// statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599.
|
// statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599.
|
||||||
var statusCodeStrings = func() map[int]string {
|
var statusCodeStrings = func() [500]string {
|
||||||
m := make(map[int]string, 500)
|
var codes [500]string
|
||||||
for i := 100; i < 600; i++ {
|
for i := range codes {
|
||||||
m[i] = strconv.Itoa(i)
|
codes[i] = strconv.Itoa(i + 100)
|
||||||
}
|
}
|
||||||
return m
|
return codes
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// statusCodeToString returns a string representation of the HTTP status code.
|
// 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.
|
// Uses a pre-computed table for codes 100-599, falls back to strconv.Itoa for others.
|
||||||
func statusCodeToString(code int) string {
|
func statusCodeToString(code int) string {
|
||||||
if s, ok := statusCodeStrings[code]; ok {
|
if i := code - 100; i >= 0 && i < len(statusCodeStrings) {
|
||||||
return s
|
return statusCodeStrings[i]
|
||||||
}
|
}
|
||||||
return strconv.Itoa(code)
|
return strconv.Itoa(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) Worker(
|
// 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{},
|
jobs <-chan struct{},
|
||||||
hostClientGenerator HostClientGenerator,
|
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
req := fasthttp.AcquireRequest()
|
req := fasthttp.AcquireRequest()
|
||||||
resp := fasthttp.AcquireResponse()
|
resp := fasthttp.AcquireResponse()
|
||||||
@@ -43,143 +75,174 @@ func (q sarin) Worker(
|
|||||||
// Create script transformer for this worker (engines are not thread-safe)
|
// Create script transformer for this worker (engines are not thread-safe)
|
||||||
// Scripts are pre-validated in NewSarin, so this should not fail
|
// Scripts are pre-validated in NewSarin, so this should not fail
|
||||||
var scriptTransformer *script.Transformer
|
var scriptTransformer *script.Transformer
|
||||||
if !q.scriptChain.IsEmpty() {
|
if !s.scriptChain.IsEmpty() {
|
||||||
var err error
|
var err error
|
||||||
scriptTransformer, err = q.scriptChain.NewTransformer()
|
scriptTransformer, err = s.scriptChain.NewTransformer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
defer scriptTransformer.Close()
|
defer scriptTransformer.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextClientIndex := newClientIndexGenerator(len(s.hostClients))
|
||||||
|
bindScriptProxy := s.newScriptProxyBinder(scriptTransformer)
|
||||||
|
|
||||||
requestGenerator, isDynamic := NewRequestGenerator(
|
requestGenerator, isDynamic := NewRequestGenerator(
|
||||||
q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache, scriptTransformer,
|
s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer,
|
||||||
)
|
)
|
||||||
|
|
||||||
if q.dryRun {
|
if s.dryRun {
|
||||||
switch {
|
switch {
|
||||||
case q.collectStats && isDynamic:
|
case s.collectStats && isDynamic:
|
||||||
q.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
|
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
|
||||||
case q.collectStats && !isDynamic:
|
case s.collectStats && !isDynamic:
|
||||||
q.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
|
s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||||
case !q.collectStats && isDynamic:
|
case !s.collectStats && isDynamic:
|
||||||
q.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
|
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
|
||||||
default:
|
default:
|
||||||
q.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
|
s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
switch {
|
switch {
|
||||||
case q.collectStats && isDynamic:
|
case s.collectStats && isDynamic:
|
||||||
q.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
|
||||||
case q.collectStats && !isDynamic:
|
case s.collectStats && !isDynamic:
|
||||||
q.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
|
||||||
case !q.collectStats && isDynamic:
|
case !s.collectStats && isDynamic:
|
||||||
q.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
|
||||||
default:
|
default:
|
||||||
q.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerStatsWithDynamic(
|
// 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{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
nextClientIndex clientIndexGenerator,
|
||||||
|
bindScriptProxy func(int),
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
resp.Reset()
|
|
||||||
|
|
||||||
|
// Bound before generation so scripts' http.* calls use the main request's proxy.
|
||||||
|
clientIndex := nextClientIndex()
|
||||||
|
bindScriptProxy(clientIndex)
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
q.responses.Add(err.Error(), 0)
|
s.responses.Add(requestErrorKey(err), 0)
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
|
||||||
|
respDuration := time.Since(startTime)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
q.responses.Add(err.Error(), time.Since(startTime))
|
s.responses.Add(responseErrorKey(err), respDuration)
|
||||||
} else {
|
} else {
|
||||||
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
|
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||||
|
sendRespLog(respDuration, resp)
|
||||||
}
|
}
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerStatsWithStatic(
|
func (s sarin) workerStatsWithStatic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
nextClientIndex clientIndexGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
// Static request generation failed - record all jobs as errors
|
// Static request generation failed, so record all jobs as errors
|
||||||
|
errorKey := requestErrorKey(err)
|
||||||
for range jobs {
|
for range jobs {
|
||||||
q.responses.Add(err.Error(), 0)
|
s.responses.Add(errorKey, 0)
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for range jobs {
|
for range jobs {
|
||||||
resp.Reset()
|
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
|
||||||
|
respDuration := time.Since(startTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
q.responses.Add(err.Error(), time.Since(startTime))
|
s.responses.Add(responseErrorKey(err), respDuration)
|
||||||
} else {
|
} else {
|
||||||
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
|
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||||
|
sendRespLog(respDuration, resp)
|
||||||
}
|
}
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerNoStatsWithDynamic(
|
func (s sarin) workerNoStatsWithDynamic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
nextClientIndex clientIndexGenerator,
|
||||||
|
bindScriptProxy func(int),
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
resp.Reset()
|
// Bound before generation so scripts' http.* calls use the main request's proxy.
|
||||||
|
clientIndex := nextClientIndex()
|
||||||
|
bindScriptProxy(clientIndex)
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
startTime := time.Now()
|
||||||
|
err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
|
||||||
|
if err == nil {
|
||||||
|
sendRespLog(time.Since(startTime), resp)
|
||||||
|
}
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerNoStatsWithStatic(
|
func (s sarin) workerNoStatsWithStatic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
nextClientIndex clientIndexGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
|
|
||||||
// Static request generation failed - just count the jobs without sending
|
// Static request generation failed, so just count the jobs without sending
|
||||||
for range jobs {
|
for range jobs {
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
@@ -187,81 +250,93 @@ func (q sarin) workerNoStatsWithStatic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for range jobs {
|
for range jobs {
|
||||||
resp.Reset()
|
startTime := time.Now()
|
||||||
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
|
||||||
|
if err == nil {
|
||||||
|
sendRespLog(time.Since(startTime), resp)
|
||||||
|
}
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerDryRunStatsWithDynamic(
|
func (s sarin) workerDryRunStatsWithDynamic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
|
nextClientIndex clientIndexGenerator,
|
||||||
|
bindScriptProxy func(int),
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
) {
|
) {
|
||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
|
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
|
||||||
|
bindScriptProxy(nextClientIndex())
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
q.responses.Add(err.Error(), time.Since(startTime))
|
s.responses.Add(requestErrorKey(err), time.Since(startTime))
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
q.responses.Add(dryRunResponseKey, time.Since(startTime))
|
s.responses.Add(dryRunResponseKey, time.Since(startTime))
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerDryRunStatsWithStatic(
|
func (s sarin) workerDryRunStatsWithStatic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
// Static request generation failed - record all jobs as errors
|
// Static request generation failed, so record all jobs as errors
|
||||||
|
errorKey := requestErrorKey(err)
|
||||||
for range jobs {
|
for range jobs {
|
||||||
q.responses.Add(err.Error(), 0)
|
s.responses.Add(errorKey, 0)
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for range jobs {
|
for range jobs {
|
||||||
q.responses.Add(dryRunResponseKey, 0)
|
s.responses.Add(dryRunResponseKey, 0)
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerDryRunNoStatsWithDynamic(
|
func (s sarin) workerDryRunNoStatsWithDynamic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
|
nextClientIndex clientIndexGenerator,
|
||||||
|
bindScriptProxy func(int),
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
) {
|
) {
|
||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
|
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
|
||||||
|
bindScriptProxy(nextClientIndex())
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
}
|
}
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerDryRunNoStatsWithStatic(
|
func (s sarin) workerDryRunNoStatsWithStatic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
for range jobs {
|
for range jobs {
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ func (t *Transformer) Transform(req *RequestData) error {
|
|||||||
return nil
|
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.
|
// Close releases all engine resources.
|
||||||
func (t *Transformer) Close() {
|
func (t *Transformer) Close() {
|
||||||
for _, engine := range t.luaEngines {
|
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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
@@ -11,8 +14,15 @@ import (
|
|||||||
type JsEngine struct {
|
type JsEngine struct {
|
||||||
runtime *goja.Runtime
|
runtime *goja.Runtime
|
||||||
transform goja.Callable
|
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.
|
// NewJsEngine creates a new JavaScript script engine with the given script content.
|
||||||
// The script must define a global `transform` function that takes a request object
|
// The script must define a global `transform` function that takes a request object
|
||||||
// and returns the modified request object.
|
// and returns the modified request object.
|
||||||
@@ -29,11 +39,16 @@ type JsEngine struct {
|
|||||||
// - types.ScriptExecutionError
|
// - types.ScriptExecutionError
|
||||||
func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
||||||
vm := goja.New()
|
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
|
// Execute the script to define the transform function
|
||||||
_, err := vm.RunString(scriptContent)
|
if _, err := vm.RunString(scriptContent); err != nil {
|
||||||
if err != nil {
|
return nil, types.NewScriptExecutionError("JavaScript", engine.exceptionError(err))
|
||||||
return nil, types.NewScriptExecutionError("JavaScript", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the transform function
|
// 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 nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &JsEngine{
|
// A generator returns an iterator, which is an ordinary object to Go, so the
|
||||||
runtime: vm,
|
// call cannot tell it apart and the declaration is the only thing left to read.
|
||||||
transform: transform,
|
if isGeneratorFunction(vm, transformVal) {
|
||||||
}, nil
|
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.
|
// Transform executes the JavaScript transform function with the given request data.
|
||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ScriptExecutionError
|
// - types.ScriptExecutionError
|
||||||
func (e *JsEngine) Transform(req *RequestData) error {
|
func (e *JsEngine) Transform(req *RequestData) error {
|
||||||
|
e.http.active = true
|
||||||
|
defer func() { e.http.active = false }()
|
||||||
|
|
||||||
// Convert RequestData to JavaScript object
|
// Convert RequestData to JavaScript object
|
||||||
reqObj := e.requestDataToObject(req)
|
reqObj := e.requestDataToObject(req)
|
||||||
|
|
||||||
// Call transform(req)
|
// Call transform(req)
|
||||||
result, err := e.transform(goja.Undefined(), reqObj)
|
result, err := e.transform(goja.Undefined(), reqObj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewScriptExecutionError("JavaScript", err)
|
return types.NewScriptExecutionError("JavaScript", e.exceptionError(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// An async or generator transform returns a promise nothing ever awaits, and a promise
|
||||||
|
// carries none of the request fields, so the request would go out untransformed.
|
||||||
|
// ExportType reads the type alone, while Export would run every getter on the result.
|
||||||
|
if result.ExportType() == promiseType {
|
||||||
|
return types.ErrScriptTransformAsync
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update RequestData from the returned object
|
// Update RequestData from the returned object
|
||||||
@@ -79,6 +114,8 @@ func (e *JsEngine) Close() {
|
|||||||
// goja doesn't have an explicit close method, but we can help GC
|
// goja doesn't have an explicit close method, but we can help GC
|
||||||
e.runtime = nil
|
e.runtime = nil
|
||||||
e.transform = nil
|
e.transform = nil
|
||||||
|
e.jsonParse = nil
|
||||||
|
e.errorConstructor = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestDataToObject converts RequestData to a goja Value (JavaScript object).
|
// 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
|
return types.ErrScriptTransformReturnObject
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method
|
// Reading a field runs the script's own getters and toString, which throw as a Go
|
||||||
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) {
|
// panic outside goja's execution context, so the whole read runs inside Try.
|
||||||
req.Method = v.String()
|
var fieldErr error
|
||||||
|
if thrown := e.runtime.Try(func() {
|
||||||
|
// Method
|
||||||
|
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Method = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path
|
||||||
|
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Path = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body
|
||||||
|
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Body = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
values, err := objectField("headers", v)
|
||||||
|
if err != nil {
|
||||||
|
fieldErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Headers = e.objectToStringSliceMap(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params
|
||||||
|
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
values, err := objectField("params", v)
|
||||||
|
if err != nil {
|
||||||
|
fieldErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Params = e.objectToStringSliceMap(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookies
|
||||||
|
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
values, err := objectField("cookies", v)
|
||||||
|
if err != nil {
|
||||||
|
fieldErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Cookies = e.objectToStringSliceMap(values)
|
||||||
|
}
|
||||||
|
}); thrown != nil {
|
||||||
|
return e.exceptionError(thrown)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path
|
return fieldErr
|
||||||
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) {
|
}
|
||||||
req.Path = v.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Body
|
// objectField rejects anything that is not a plain object, since reading keys off an
|
||||||
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) {
|
// array or a boxed string yields headers named after indexes and off a Date yields none.
|
||||||
req.Body = v.String()
|
// A Map, a Set and a typed array still pass, so the field is emptied rather than refused.
|
||||||
|
func objectField(name string, value goja.Value) (*goja.Object, error) {
|
||||||
|
obj, ok := value.(*goja.Object)
|
||||||
|
if !ok || obj.ClassName() != "Object" {
|
||||||
|
return nil, fmt.Errorf("%s must be an object", name)
|
||||||
}
|
}
|
||||||
|
return obj, nil
|
||||||
// Headers
|
|
||||||
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
|
||||||
req.Headers = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Params
|
|
||||||
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
|
||||||
req.Params = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cookies
|
|
||||||
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
|
||||||
req.Cookies = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stringSliceToArray converts a Go []string to a JavaScript array.
|
// stringSliceToArray converts a Go []string to a JavaScript array.
|
||||||
@@ -176,23 +246,78 @@ func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string
|
|||||||
result := make(map[string][]string)
|
result := make(map[string][]string)
|
||||||
for _, key := range obj.Keys() {
|
for _, key := range obj.Keys() {
|
||||||
v := obj.Get(key)
|
v := obj.Get(key)
|
||||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's an array
|
// Check if it's an array
|
||||||
if arr, ok := v.Export().([]any); ok {
|
if arr, ok := v.(*goja.Object); ok && arr.ClassName() == "Array" {
|
||||||
var values []string
|
length := int(arr.Get("length").ToInteger())
|
||||||
for _, item := range arr {
|
values := make([]string, 0, length)
|
||||||
if s, ok := item.(string); ok {
|
for i := range length {
|
||||||
values = append(values, s)
|
if text, ok := jsStringValue(arr.Get(strconv.Itoa(i))); ok {
|
||||||
|
values = append(values, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result[key] = values
|
result[key] = values
|
||||||
} else {
|
continue
|
||||||
// Single value - wrap in slice
|
}
|
||||||
result[key] = []string{v.String()}
|
|
||||||
|
// Single value, wrap it in a slice
|
||||||
|
if text, ok := jsStringValue(v); ok {
|
||||||
|
result[key] = []string{text}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
type LuaEngine struct {
|
||||||
state *lua.LState
|
state *lua.LState
|
||||||
transform *lua.LFunction
|
transform *lua.LFunction
|
||||||
|
http httpBridge
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLuaEngine creates a new Lua script engine with the given script content.
|
// NewLuaEngine creates a new Lua script engine with the given script content.
|
||||||
@@ -29,6 +30,11 @@ type LuaEngine struct {
|
|||||||
// - types.ScriptExecutionError
|
// - types.ScriptExecutionError
|
||||||
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
||||||
L := lua.NewState()
|
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
|
// Execute the script to define the transform function
|
||||||
if err := L.DoString(scriptContent); err != nil {
|
if err := L.DoString(scriptContent); err != nil {
|
||||||
@@ -42,17 +48,23 @@ func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
|||||||
L.Close()
|
L.Close()
|
||||||
return nil, types.ErrScriptTransformMissing
|
return nil, types.ErrScriptTransformMissing
|
||||||
}
|
}
|
||||||
|
engine.transform = transform.(*lua.LFunction)
|
||||||
|
|
||||||
return &LuaEngine{
|
return engine, nil
|
||||||
state: L,
|
}
|
||||||
transform: transform.(*lua.LFunction),
|
|
||||||
}, 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.
|
// Transform executes the Lua transform function with the given request data.
|
||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ScriptExecutionError
|
// - types.ScriptExecutionError
|
||||||
func (e *LuaEngine) Transform(req *RequestData) error {
|
func (e *LuaEngine) Transform(req *RequestData) error {
|
||||||
|
e.http.active = true
|
||||||
|
defer func() { e.http.active = false }()
|
||||||
|
|
||||||
// Convert RequestData to Lua table
|
// Convert RequestData to Lua table
|
||||||
reqTable := e.requestDataToTable(req)
|
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("path", lua.LString(req.Path))
|
||||||
t.RawSetString("body", lua.LString(req.Body))
|
t.RawSetString("body", lua.LString(req.Body))
|
||||||
|
|
||||||
// Headers (map[string][]string -> table of arrays)
|
t.RawSetString("headers", stringSliceMapToTable(L, req.Headers))
|
||||||
headers := L.NewTable()
|
t.RawSetString("params", stringSliceMapToTable(L, req.Params))
|
||||||
for k, values := range req.Headers {
|
t.RawSetString("cookies", stringSliceMapToTable(L, req.Cookies))
|
||||||
arr := L.NewTable()
|
|
||||||
|
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 {
|
for _, v := range values {
|
||||||
arr.Append(lua.LString(v))
|
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
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,20 +168,33 @@ func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
|
|||||||
}
|
}
|
||||||
key := string(k.(lua.LString))
|
key := string(k.(lua.LString))
|
||||||
|
|
||||||
switch v.Type() {
|
if v.Type() == lua.LTTable {
|
||||||
case lua.LTString:
|
// Array of values, keys outside 1..n are skipped
|
||||||
// Single string value
|
arr := v.(*lua.LTable)
|
||||||
result[key] = []string{string(v.(lua.LString))}
|
length := arr.Len()
|
||||||
case lua.LTTable:
|
values := make([]string, 0, length)
|
||||||
// Array of strings
|
for i := 1; i <= length; i++ {
|
||||||
var values []string
|
if text, ok := luaScalarString(arr.RawGetInt(i)); ok {
|
||||||
v.(*lua.LTable).ForEach(func(_, item lua.LValue) {
|
values = append(values, text)
|
||||||
if item.Type() == lua.LTString {
|
|
||||||
values = append(values, string(item.(lua.LString)))
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
result[key] = values
|
result[key] = values
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if text, ok := luaScalarString(v); ok {
|
||||||
|
result[key] = []string{text}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// luaScalarString renders strings, numbers and booleans as text.
|
||||||
|
func luaScalarString(v lua.LValue) (string, bool) {
|
||||||
|
switch v.Type() {
|
||||||
|
case lua.LTString, lua.LTNumber, lua.LTBool:
|
||||||
|
return v.String(), true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,148 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// luaToGo converts a Lua value to a JSON-encodable Go value.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptJSONCycle
|
||||||
|
// - 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[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.
|
// The script should modify the RequestData and return it.
|
||||||
Transform(req *RequestData) error
|
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 releases any resources held by the engine.
|
||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
@@ -69,7 +72,7 @@ func LoadSource(ctx context.Context, source string, engineType EngineType) (*Sou
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(source, "@@"):
|
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
|
content = source[1:] // Remove first @, keep the rest
|
||||||
case strings.HasPrefix(source, "@"):
|
case strings.HasPrefix(source, "@"):
|
||||||
// File or URL reference
|
// File or URL reference
|
||||||
@@ -125,7 +128,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
|
|||||||
return err
|
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
|
var engine Engine
|
||||||
switch engineType {
|
switch engineType {
|
||||||
case EngineTypeLua:
|
case EngineTypeLua:
|
||||||
@@ -140,7 +143,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the engine - we only needed it for validation
|
// The engine was only needed for validation
|
||||||
engine.Close()
|
engine.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -168,10 +171,18 @@ func fetchURL(ctx context.Context, url string) (string, error) {
|
|||||||
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
// A declared size above the limit is refused before anything is read.
|
||||||
|
if resp.ContentLength > types.RemoteFetchLimit {
|
||||||
|
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", types.NewHTTPFetchError(url, err)
|
return "", types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
if len(data) > types.RemoteFetchLimit {
|
||||||
|
return "", types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ByteSizeLimit is the largest size any option may ask for. A limit is also the most
|
||||||
|
// memory one response may take, so it stays well under what a machine can hold.
|
||||||
|
const ByteSizeLimit = 1 << 30 // 1 GiB
|
||||||
|
|
||||||
|
// RemoteFetchLimit bounds a file sarin downloads for itself, such as a config file or a script.
|
||||||
|
const RemoteFetchLimit = 64 << 20 // 64 MiB
|
||||||
|
|
||||||
|
// byteSizeUnits run from the largest scale down, so formatting picks the largest unit
|
||||||
|
// that fits. A bare "K" is out: the common libraries disagree on whether it counts
|
||||||
|
// 1000 or 1024 bytes.
|
||||||
|
var byteSizeUnits = []struct {
|
||||||
|
suffix string
|
||||||
|
scale uint64
|
||||||
|
}{
|
||||||
|
{"TiB", 1 << 40},
|
||||||
|
{"TB", 1000 * 1000 * 1000 * 1000},
|
||||||
|
{"GiB", 1 << 30},
|
||||||
|
{"GB", 1000 * 1000 * 1000},
|
||||||
|
{"MiB", 1 << 20},
|
||||||
|
{"MB", 1000 * 1000},
|
||||||
|
{"KiB", 1 << 10},
|
||||||
|
{"KB", 1000},
|
||||||
|
{"B", 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseByteSize reads a byte count, with or without a unit suffix.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - ErrByteSizeInvalid
|
||||||
|
func ParseByteSize(value string) (uint64, error) {
|
||||||
|
text := strings.TrimSpace(value)
|
||||||
|
|
||||||
|
// The suffix is split off by index, so no case mapping can shift the digits.
|
||||||
|
digitsEnd := len(text)
|
||||||
|
for digitsEnd > 0 && isASCIILetter(text[digitsEnd-1]) {
|
||||||
|
digitsEnd--
|
||||||
|
}
|
||||||
|
|
||||||
|
scale := uint64(1)
|
||||||
|
if suffix := text[digitsEnd:]; suffix != "" {
|
||||||
|
unitScale, ok := byteSizeScale(suffix)
|
||||||
|
if !ok {
|
||||||
|
return 0, ErrByteSizeInvalid
|
||||||
|
}
|
||||||
|
scale = unitScale
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := strconv.ParseUint(strings.TrimSpace(text[:digitsEnd]), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, ErrByteSizeInvalid
|
||||||
|
}
|
||||||
|
if size > math.MaxUint64/scale {
|
||||||
|
return 0, ErrByteSizeInvalid
|
||||||
|
}
|
||||||
|
return size * scale, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatByteSize renders a byte count so that ParseByteSize reads back the same value.
|
||||||
|
func FormatByteSize(size uint64) string {
|
||||||
|
for _, unit := range byteSizeUnits {
|
||||||
|
if unit.scale > 1 && size >= unit.scale && size%unit.scale == 0 {
|
||||||
|
return strconv.FormatUint(size/unit.scale, 10) + unit.suffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(size, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// byteSizeScale looks up a unit suffix, ignoring case.
|
||||||
|
func byteSizeScale(suffix string) (uint64, bool) {
|
||||||
|
for _, unit := range byteSizeUnits {
|
||||||
|
if strings.EqualFold(suffix, unit.suffix) {
|
||||||
|
return unit.scale, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIILetter(char byte) bool {
|
||||||
|
return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')
|
||||||
|
}
|
||||||
@@ -10,6 +10,17 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
errNoError = errors.New("no error (internal)")
|
errNoError = errors.New("no error (internal)")
|
||||||
|
|
||||||
|
ErrByteSizeInvalid = errors.New(
|
||||||
|
"invalid value for size, expected a whole number of bytes or a whole number with a unit: " +
|
||||||
|
"B, KB, MB, GB, TB (1000 based) or KiB, MiB, GiB, TiB (1024 based), e.g. '1048576', '10MiB', '2MB'",
|
||||||
|
)
|
||||||
|
|
||||||
|
ErrRemoteFileTooLarge = errors.New("remote file exceeds " + FormatByteSize(RemoteFetchLimit))
|
||||||
|
|
||||||
|
ErrResponseBodyTooLarge = errors.New("response body exceeds the body limit")
|
||||||
|
|
||||||
|
ErrYAMLMultipleDocuments = errors.New("expected a single YAML document, but found more than one")
|
||||||
)
|
)
|
||||||
|
|
||||||
type FieldParseError struct {
|
type FieldParseError struct {
|
||||||
@@ -297,6 +308,27 @@ func (e CLIUnexpectedArgsError) Error() string {
|
|||||||
|
|
||||||
// ======================================== Config File ========================================
|
// ======================================== Config File ========================================
|
||||||
|
|
||||||
|
// ConfigFileError names the file a parse error came from, which nested configFile chains lose.
|
||||||
|
type ConfigFileError struct {
|
||||||
|
Path string
|
||||||
|
error error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConfigFileError(path string, err error) ConfigFileError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ConfigFileError{path, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ConfigFileError) Error() string {
|
||||||
|
return e.error.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ConfigFileError) Unwrap() error {
|
||||||
|
return e.error
|
||||||
|
}
|
||||||
|
|
||||||
type ConfigFileReadError struct {
|
type ConfigFileReadError struct {
|
||||||
error error
|
error error
|
||||||
}
|
}
|
||||||
@@ -318,6 +350,8 @@ func (e ConfigFileReadError) Unwrap() error {
|
|||||||
|
|
||||||
// ======================================== Proxy ========================================
|
// ======================================== Proxy ========================================
|
||||||
|
|
||||||
|
var ErrProxyNoContextDialer = errors.New("SOCKS5 dialer does not support timeouts")
|
||||||
|
|
||||||
type ProxyUnsupportedSchemeError struct {
|
type ProxyUnsupportedSchemeError struct {
|
||||||
Scheme string
|
Scheme string
|
||||||
}
|
}
|
||||||
@@ -400,9 +434,90 @@ var (
|
|||||||
ErrScriptSourceEmpty = errors.New("script source cannot be empty after @")
|
ErrScriptSourceEmpty = errors.New("script source cannot be empty after @")
|
||||||
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
|
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
|
||||||
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
|
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
|
||||||
|
ErrScriptTransformAsync = errors.New("transform function must be synchronous, async and generator functions are not supported")
|
||||||
ErrScriptURLNoHost = errors.New("script URL must have a host")
|
ErrScriptURLNoHost = errors.New("script URL must have a host")
|
||||||
|
ErrScriptHTTPOutsideTransform = errors.New("http functions can only be called while transform is running")
|
||||||
|
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")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 {
|
type ScriptLoadError struct {
|
||||||
Source string
|
Source string
|
||||||
Err error
|
Err error
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
buildGoModule,
|
||||||
|
go_1_27,
|
||||||
|
rev ? "unknown",
|
||||||
|
buildDate ? "unknown",
|
||||||
|
}:
|
||||||
|
|
||||||
|
(buildGoModule.override { go = go_1_27; }) (finalAttrs: {
|
||||||
|
pname = "sarin";
|
||||||
|
version = "1.4.4"; # bump per release
|
||||||
|
|
||||||
|
src = lib.cleanSource ../.;
|
||||||
|
|
||||||
|
vendorHash = "sha256-KUxzFDxYbKvKASMze+f7jxfpCQbs01ezeG6a1VboHxw=";
|
||||||
|
|
||||||
|
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