Compare commits

...
45 Commits
Author SHA1 Message Date
aykhans 5d91141f80 fix(script): stringify thrown JavaScript values through Runtime.Try 2026-09-24 22:34:59 +04:00
aykhans 878ca6dfbd fix(script): bound maxRedirects to 100 and reject out of range values 2026-09-24 19:08:58 +04:00
aykhans 9b0e7c4304 fix: dial IPv6 hosts by enabling dual-stack and trying every resolved address 2026-09-23 01:03:42 +04:00
aykhans d4c7dcc42c fix: apply per-request timeouts to script HTTP proxy dials 2026-09-23 00:45:44 +04:00
aykhans 00226b0eb7 fix: size script HTTP client connection pool to worker count 2026-09-22 19:29:51 +04:00
aykhans a60287b78b docs: document http() and Lua json in scripts 2026-09-20 23:30:16 +04:00
aykhans 529420ee5a feat(script): replace followRedirects with maxRedirects http option 2026-09-20 23:18:21 +04:00
aykhans 8b437b487c feat(script): add http() and json to Lua and JS scripts 2026-09-20 23:02:51 +04:00
aykhans 5ca9cda2b9 docs(cli): show config-file flag type as []string in help text 2026-09-19 00:14:48 +04:00
aykhans 6b03ba9579 refactor(sarin): extract cookie header building into cookieHeaderValue 2026-09-18 22:39:56 +04:00
aykhans 8b6e88bb74 Merge pull request #231 from aykhans/chore/upgrade-go-1.27
chore: upgrade Go to 1.27.1
2026-09-12 00:28:07 +04:00
aykhans 163de9ffc9 fix(nix): update vendorHash for dependency updates 2026-09-12 00:20:11 +04:00
aykhans 969337f4ec chore: bump package version to 1.4.4 2026-09-12 00:15:37 +04:00
aykhans ce6b60520f chore(deps): update goja, x/net, and indirect dependencies 2026-09-12 00:14:04 +04:00
aykhans ae34ae037a chore: upgrade Go to 1.27.1 2026-09-12 00:11:30 +04:00
aykhans 27c00581a9 Merge pull request #230 from aykhans/feat/expand-fakeit-template-functions
feat: expose additional gofakeit functions
2026-09-11 22:04:41 +04:00
aykhans db9a65f35f docs: align templating reference with actual runtime behavior 2026-09-11 22:03:05 +04:00
aykhans 082851d25e feat: expose additional gofakeit functions 2026-09-11 20:03:42 +04:00
aykhans 62afa38370 Merge pull request #228 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.74.0
build(deps): bump github.com/valyala/fasthttp from 1.73.0 to 1.74.0
2026-09-11 15:48:05 +04:00
github-actions[bot] 373bc39f8e fix(nix): update vendorHash [dependabot skip] 2026-09-11 11:13:28 +00:00
dependabot[bot] 2b7c08519e build(deps): bump github.com/valyala/fasthttp from 1.73.0 to 1.74.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.73.0 to 1.74.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.73.0...v1.74.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.74.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-11 11:12:37 +00:00
aykhans f974b5d708 Merge pull request #229 from aykhans/dependabot/go_modules/github.com/brianvoe/gofakeit/v7-7.17.0
build(deps): bump github.com/brianvoe/gofakeit/v7 from 7.16.0 to 7.17.0
2026-09-11 15:10:43 +04:00
github-actions[bot] 74ac84eb31 fix(nix): update vendorHash [dependabot skip] 2026-09-11 00:14:56 +00:00
dependabot[bot] 9cd3ec0539 build(deps): bump github.com/brianvoe/gofakeit/v7 from 7.16.0 to 7.17.0
Bumps [github.com/brianvoe/gofakeit/v7](https://github.com/brianvoe/gofakeit) from 7.16.0 to 7.17.0.
- [Release notes](https://github.com/brianvoe/gofakeit/releases)
- [Commits](https://github.com/brianvoe/gofakeit/compare/v7.16.0...v7.17.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-11 00:14:00 +00:00
aykhans 42de488430 Merge pull request #227 from aykhans/chore/update-go-toolchain-and-nix
chore: update Go toolchain and Nix configuration
2026-09-03 12:58:55 +04:00
aykhans 6e984f039c chore: update Go toolchain and Nix configuration 2026-09-03 12:55:35 +04:00
aykhans 73285b66b5 Merge pull request #226 from aykhans/ci/release-branch-pointer
ci: point Nix users at a release branch instead of main
2026-09-02 23:19:47 +04:00
aykhans f003bd1061 chore: bump package version to 1.4.3 2026-09-02 23:16:24 +04:00
aykhans 07b2bb706e chore(deps): update goja and indirect deps, refresh nix vendorHash 2026-09-02 23:05:45 +04:00
aykhans 67db8ac0e6 chore: bump golangci-lint to v2.13.2 2026-09-02 23:02:56 +04:00
aykhans d99c76d175 ci: point Nix users at a release branch instead of main 2026-09-02 23:01:05 +04:00
aykhans 623c1c4d97 Merge pull request #224 from aykhans/dependabot/go_modules/charm.land/bubbles/v2-2.2.1
build(deps): bump charm.land/bubbles/v2 from 2.2.0 to 2.2.1
2026-08-28 16:32:50 +04:00
github-actions[bot] f5eaf76fc4 fix(nix): update vendorHash [dependabot skip] 2026-08-28 00:13:44 +00:00
dependabot[bot] 7ec63a2a43 build(deps): bump charm.land/bubbles/v2 from 2.2.0 to 2.2.1
Bumps [charm.land/bubbles/v2](https://github.com/charmbracelet/bubbles) from 2.2.0 to 2.2.1.
- [Release notes](https://github.com/charmbracelet/bubbles/releases)
- [Commits](https://github.com/charmbracelet/bubbles/compare/v2.2.0...v2.2.1)

---
updated-dependencies:
- dependency-name: charm.land/bubbles/v2
  dependency-version: 2.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 00:12:48 +00:00
aykhans 492c3f1449 Merge pull request #223 from aykhans/ci/auto-update-vendorhash
ci(nix): recover a stale vendorHash automatically
2026-08-25 14:26:59 +04:00
aykhans d03d34576e ci(nix): recover a stale vendorHash automatically 2026-08-25 14:24:23 +04:00
aykhans 8beff615bb Merge pull request #221 from aykhans/dependabot/go_modules/charm.land/bubbles/v2-2.2.0
build(deps): bump charm.land/bubbles/v2 from 2.1.1 to 2.2.0
2026-08-25 10:43:40 +04:00
aykhans a85c82e751 fix(nix): update vendorHash for bubbles 2.2.0 2026-08-25 10:28:29 +04:00
dependabot[bot] ad5cfadaf2 build(deps): bump charm.land/bubbles/v2 from 2.1.1 to 2.2.0
Bumps [charm.land/bubbles/v2](https://github.com/charmbracelet/bubbles) from 2.1.1 to 2.2.0.
- [Release notes](https://github.com/charmbracelet/bubbles/releases)
- [Commits](https://github.com/charmbracelet/bubbles/compare/v2.1.1...v2.2.0)

---
updated-dependencies:
- dependency-name: charm.land/bubbles/v2
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 00:12:58 +00:00
aykhans 8078b2c1fe Merge pull request #220 from aykhans/dependabot/go_modules/charm.land/bubbletea/v2-2.0.9
build(deps): bump charm.land/bubbletea/v2 from 2.0.8 to 2.0.9
2026-08-24 16:07:49 +04:00
aykhans 3c4f449c97 fix(nix): update vendorHash for bubbletea 2.0.9 2026-08-24 15:46:34 +04:00
dependabot[bot] deba048e3c build(deps): bump charm.land/bubbletea/v2 from 2.0.8 to 2.0.9
Bumps [charm.land/bubbletea/v2](https://github.com/charmbracelet/bubbletea) from 2.0.8 to 2.0.9.
- [Release notes](https://github.com/charmbracelet/bubbletea/releases)
- [Commits](https://github.com/charmbracelet/bubbletea/compare/v2.0.8...v2.0.9)

---
updated-dependencies:
- dependency-name: charm.land/bubbletea/v2
  dependency-version: 2.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 11:23:33 +00:00
aykhans 77e8bc1eb8 Merge pull request #219 from aykhans/dependabot/go_modules/github.com/brianvoe/gofakeit/v7-7.16.0
build(deps): bump github.com/brianvoe/gofakeit/v7 from 7.15.0 to 7.16.0
2026-08-24 15:21:40 +04:00
aykhans a81d95ef6d fix(nix): update vendorHash for gofakeit 7.16.0 2026-08-24 15:20:10 +04:00
dependabot[bot] 8195b92547 build(deps): bump github.com/brianvoe/gofakeit/v7 from 7.15.0 to 7.16.0
Bumps [github.com/brianvoe/gofakeit/v7](https://github.com/brianvoe/gofakeit) from 7.15.0 to 7.16.0.
- [Release notes](https://github.com/brianvoe/gofakeit/releases)
- [Commits](https://github.com/brianvoe/gofakeit/compare/v7.15.0...v7.16.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 00:15:38 +00:00
34 changed files with 1991 additions and 504 deletions

No files matched your search

+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: 1.26.5
go-version-file: go.mod
- name: go fix
run: |
go fix ./...
@@ -24,4 +24,4 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
version: v2.13.2
+137 -1
View File
@@ -21,16 +21,152 @@ on:
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:
ref: ${{ inputs.ref || github.ref }}
# 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
+93 -33
View File
@@ -2,20 +2,7 @@ name: Build and Release
on:
release:
types: [created]
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g., v1.0.0)"
required: true
build_binaries:
description: "Build and upload binaries"
type: boolean
default: true
build_docker:
description: "Build and push Docker image"
type: boolean
default: true
types: [published]
permissions:
contents: write
@@ -24,27 +11,57 @@ jobs:
nix:
name: Verify Nix package
uses: ./.github/workflows/nix.yaml
permissions:
contents: read
pull-requests: write
with:
ref: ${{ inputs.tag || github.ref }}
ref: ${{ github.ref }}
# nix/package.nix carries its own version string that has to be bumped by
# hand; catch a forgotten bump before the tag ships.
# 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 package 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: ${{ inputs.tag || github.ref }}
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
env:
TAG: ${{ inputs.tag || github.ref_name }}
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"
@@ -65,23 +82,21 @@ jobs:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ inputs.tag || github.ref }}
ref: ${{ github.ref }}
- name: Set build metadata
run: |
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "GO_VERSION=1.26.5" >> $GITHUB_ENV
echo "GO_VERSION=$(sed -n 's/^go //p' go.mod)" >> $GITHUB_ENV
- name: Set up Go
if: github.event_name == 'release' || inputs.build_binaries
uses: actions/setup-go@v7
with:
go-version: ${{ env.GO_VERSION }}
go-version-file: go.mod
cache: true
- name: Build binaries
if: github.event_name == 'release' || inputs.build_binaries
run: |
LDFLAGS="-X 'go.aykhans.me/sarin/internal/version.Version=${{ env.VERSION }}' \
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${{ env.GIT_COMMIT }}' \
@@ -99,29 +114,24 @@ jobs:
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.exe ./cmd/cli/main.go
- name: Upload Release Assets
if: github.event_name == 'release' || inputs.build_binaries
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ inputs.tag || github.ref_name }}
tag_name: ${{ github.ref_name }}
files: ./sarin-*
- name: Set up QEMU
if: github.event_name == 'release' || inputs.build_docker
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
if: github.event_name == 'release' || inputs.build_docker
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
if: github.event_name == 'release' || inputs.build_docker
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
if: github.event_name == 'release' || inputs.build_docker
uses: docker/build-push-action@v7
with:
context: .
@@ -131,6 +141,56 @@ jobs:
VERSION=${{ env.VERSION }}
GIT_COMMIT=${{ env.GIT_COMMIT }}
GO_VERSION=${{ env.GO_VERSION }}
# A pre-release must not take over the tag people get by default.
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/sarin:${{ env.VERSION }}
${{ secrets.DOCKERHUB_USERNAME }}/sarin:latest
${{ !github.event.release.prerelease && format('{0}/sarin:latest', secrets.DOCKERHUB_USERNAME) || '' }}
# Moving pointer at the newest stable release, so flake users can follow
# releases instead of main. Fast-forward only.
release-branch:
name: Advance release branch
needs: build
if: ${{ !github.event.release.prerelease }}
runs-on: ubuntu-latest
permissions:
contents: write
# Serialize: concurrent releases would otherwise race the push.
concurrency:
group: release-branch
cancel-in-progress: false
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
# The checks below compare against $GITHUB_SHA; check out exactly that.
ref: ${{ github.sha }}
fetch-depth: 0
- name: Point release at the tag
env:
TAG: ${{ github.ref_name }}
run: |
# Refresh: checkout fetched this, but as of a few seconds ago.
git fetch --no-tags origin "+refs/heads/release:refs/remotes/origin/release" || true
if git rev-parse --verify --quiet refs/remotes/origin/release >/dev/null; then
# At or behind the pointer: a re-run of this or an older release.
if git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/release; then
if [ "$(git rev-parse refs/remotes/origin/release)" = "$GITHUB_SHA" ]; then
echo "::notice::release is already at $TAG"
else
echo "::warning::release is ahead of $TAG; leaving it alone"
fi
exit 0
fi
# The version job put this commit on main, so a pointer that is neither
# behind nor ahead is off main history and can never fast-forward again.
if ! git merge-base --is-ancestor refs/remotes/origin/release "$GITHUB_SHA"; then
echo "::error::release ($(git rev-parse --short refs/remotes/origin/release)) has diverged from main and can no longer fast-forward to $TAG; reset it by hand"
exit 1
fi
fi
git push origin "$GITHUB_SHA:refs/heads/release"
echo "::notice::release branch now points at $TAG"
+1 -1
View File
@@ -1,7 +1,7 @@
version: "2"
run:
go: "1.26"
go: "1.27"
concurrency: 12
linters:
+1 -1
View File
@@ -1,4 +1,4 @@
ARG GO_VERSION=1.26.5
ARG GO_VERSION=1.27.1
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
+8 -6
View File
@@ -27,7 +27,7 @@ Sarin is designed for efficient HTTP load testing with minimal resource consumpt
| ✅ Supported | ❌ Not Supported |
| ---------------------------------------------------------- | ------------------------------- |
| High-performance with low memory footprint | Web UI or complex TUI |
| Dynamic requests via 340+ template functions | Detailed response body analysis |
| Dynamic requests via 370+ template functions | Detailed response body analysis |
| Request scripting with Lua and JavaScript | Distributed load testing |
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
| Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem |
@@ -61,20 +61,20 @@ docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
Run directly without installing (requires flakes enabled):
```sh
nix run github:aykhans/sarin -- -U http://example.com -r 100 -c 10
nix run github:aykhans/sarin/release -- -U http://example.com -r 100 -c 10
```
Install into your profile:
```sh
nix profile install github:aykhans/sarin
nix profile install github:aykhans/sarin/release
```
Or add it to your own flake via the overlay:
```nix
{
inputs.sarin.url = "github:aykhans/sarin";
inputs.sarin.url = "github:aykhans/sarin/release";
# In your outputs, apply the overlay to nixpkgs:
# nixpkgs.overlays = [ inputs.sarin.overlays.default ];
@@ -82,6 +82,8 @@ Or add it to your own flake via the overlay:
}
```
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>
@@ -94,7 +96,7 @@ Download the latest binaries from the [releases](https://github.com/aykhans/sari
<details>
<summary><b>Building from Source</b></summary>
Requires [Go 1.26+](https://golang.org/dl/).
Requires the [Go version declared in `go.mod`](go.mod).
```sh
git clone https://github.com/aykhans/sarin.git && cd sarin
@@ -144,7 +146,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c
## Templating
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 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:**
+9 -3
View File
@@ -3,7 +3,9 @@ version: "3"
vars:
BIN_DIR: ./bin
GOLANGCI_LINT_VERSION: v2.12.2
GO_VERSION:
sh: sed -n 's/^go //p' go.mod
GOLANGCI_LINT_VERSION: v2.13.2
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
tasks:
@@ -39,6 +41,11 @@ tasks:
cmds:
- "{{.GOLANGCI}} run"
nix-hash:
desc: Recompute nix/package.nix vendorHash after a dependency change.
cmds:
- nix run nixpkgs#nix-update -- --flake --version=skip --no-src default
test:
desc: Run Go tests.
cmds:
@@ -82,7 +89,6 @@ tasks:
vars:
IMAGE_NAME: '{{.IMAGE_NAME | default "sarin"}}'
TAG: '{{.TAG | default "latest"}}'
GO_VERSION: '{{.GO_VERSION | default ""}}'
VERSION:
sh: git describe --tags --always
GIT_COMMIT:
@@ -90,7 +96,7 @@ tasks:
cmds:
- >-
docker build
{{if .GO_VERSION}}--build-arg GO_VERSION={{.GO_VERSION}}{{end}}
--build-arg GO_VERSION={{.GO_VERSION}}
--build-arg VERSION={{.VERSION}}
--build-arg GIT_COMMIT={{.GIT_COMMIT}}
-t {{.IMAGE_NAME}}:{{.TAG}}
+101
View File
@@ -458,6 +458,8 @@ 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:** 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
@@ -523,6 +525,8 @@ 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:** JavaScript scripts can send their own HTTP requests with `http`. See [HTTP Requests in Scripts](#http-requests-in-scripts).
**YAML example:**
```yaml
@@ -554,3 +558,100 @@ js:
```sh
SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
```
## HTTP Requests in Scripts
Lua and JavaScript scripts can send their own HTTP requests and use the response in the main request, for example to fetch a fresh token or CSRF value from another endpoint.
**Function:**
```
http(url, opts)
```
`url` must be an absolute `http` or `https` URL. `opts` is optional.
**Options:**
| Option | Type | Default | Description |
| -------------- | --------------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
| `method` | string | `GET` | HTTP method, sent as given |
| `headers` | table/object | - | Request headers, same shape as `req.headers` |
| `params` | table/object | - | Query parameters, added to any already in the URL |
| `cookies` | table/object | - | Cookies, sent as a single `Cookie` header |
| `body` | string | `""` | Request body |
| `timeout` | duration string (`"500ms"`) | `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 |
In Lua, call the helpers as `res:header("name")` or `res.header("name")`.
**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).
- **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;
}
```
+152 -1
View File
@@ -373,7 +373,7 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
</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
@@ -1087,3 +1087,154 @@ js: "@./scripts/body.js"
```
</details>
**Fetch a value from another endpoint before each request:**
Scripts can send their own requests with `http`. Here each request first fetches a CSRF token and sends it in a header and cookie. See [HTTP Requests in Scripts](configuration.md#http-requests-in-scripts) for all options.
```sh
sarin -U http://example.com/api/orders -r 1000 -c 10 \
-M POST \
-lua 'function transform(req)
local res = http("http://example.com/csrf")
req.headers["X-CSRF-Token"] = json.decode(res.body).token
req.cookies["session"] = res:cookie("session")
return req
end'
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com/api/orders
requests: 1000
concurrency: 10
method: POST
lua: |
function transform(req)
local res = http("http://example.com/csrf")
req.headers["X-CSRF-Token"] = json.decode(res.body).token
req.cookies["session"] = res:cookie("session")
return req
end
```
</details>
**Log in once per worker and reuse the token (JavaScript):**
```yaml
url: http://example.com/api/profile
requests: 1000
concurrency: 10
js: |
let token;
function login() {
const res = http("http://example.com/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user: "test", password: "secret" }),
});
if (res.status !== 200) throw new Error("login failed: " + res.status);
return res.json().token;
}
function transform(req) {
if (!token) token = login();
req.headers["Authorization"] = "Bearer " + token;
return req;
}
```
**Chain two calls, then use the result in the URL path (Lua):**
```yaml
url: http://example.com/api/placeholder
requests: 500
concurrency: 10
lua: |
function transform(req)
local token = json.decode(http("http://example.com/login", {
method = "POST",
body = json.encode({ user = "test", password = "secret" }),
headers = { ["Content-Type"] = "application/json" },
}).body).token
local orders = json.decode(http("http://example.com/api/orders", {
headers = { Authorization = "Bearer " .. token },
}).body)
req.path = "/api/orders/" .. orders[1].id
req.headers["Authorization"] = "Bearer " .. token
return req
end
```
**Refresh a token only when it expires (JavaScript):**
```yaml
url: http://example.com/api/profile
requests: 5000
concurrency: 20
js: |
let token;
let expiresAt = 0;
function transform(req) {
if (Date.now() >= expiresAt) {
const res = http("http://example.com/login", { method: "POST", body: "{}" });
token = res.json().token;
expiresAt = Date.now() + 60000; // refresh once a minute per worker
}
req.headers["Authorization"] = "Bearer " + token;
return req;
}
```
**Follow redirects and use a slower timeout for a login endpoint:**
```sh
sarin -U http://example.com/api/me -r 200 -c 5 \
-lua 'function transform(req)
local res = http("http://example.com/login", {
method = "POST",
maxRedirects = 5,
timeout = "10s",
})
req.cookies["session"] = res:cookie("session")
return req
end'
```
**Keep sending the main request even when the extra call fails:**
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;
}'
```
+175 -139
View File
@@ -249,7 +249,7 @@ The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `ca
### 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 |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
@@ -259,7 +259,7 @@ Functions for the [2Captcha](https://2captcha.com) service. Note: 2Captcha **doe
### 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 |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
@@ -270,7 +270,7 @@ Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is curr
### 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 |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
@@ -302,7 +302,7 @@ body: |
```
```yaml
# hCaptcha via Anti-Captcha (the only service that still supports it)
# hCaptcha via Anti-Captcha
method: POST
url: https://example.com/protected
body: |
@@ -325,10 +325,10 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### File
| Function | Description | Example Output |
| ---------------------- | -------------- | -------------------- |
| `fakeit_FileExtension` | File extension | `"nes"` |
| `fakeit_FileMimeType` | MIME type | `"application/json"` |
| Function | Description | Example Output |
| ---------------------- | -------------- | --------------------- |
| `fakeit_FileExtension` | File extension | `"nes"` |
| `fakeit_FileMimeType` | MIME type | `"application/x-wri"` |
### ID
@@ -347,7 +347,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_ProductFeature` | Product feature | `"ultra-lightweight"` |
| `fakeit_ProductMaterial` | Product material | `"brass"` |
| `fakeit_ProductUPC` | UPC code | `"012780949980"` |
| `fakeit_ProductAudience` | Target audience | `["adults"]` |
| `fakeit_ProductAudience` | Target audience | `[adults]` |
| `fakeit_ProductDimension` | Product dimension | `"medium"` |
| `fakeit_ProductUseCase` | Use case | `"home"` |
| `fakeit_ProductBenefit` | Product benefit | `"comfort"` |
@@ -356,23 +356,25 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Person
| Function | Description | Example Output |
| ----------------------- | ---------------------- | ------------------------ |
| `fakeit_Name` | Full name | `"Markus Moen"` |
| `fakeit_NamePrefix` | Name prefix | `"Mr."` |
| `fakeit_NameSuffix` | Name suffix | `"Jr."` |
| `fakeit_FirstName` | First name | `"Markus"` |
| `fakeit_MiddleName` | Middle name | `"Belinda"` |
| `fakeit_LastName` | Last name | `"Daniel"` |
| `fakeit_Gender` | Gender | `"male"` |
| `fakeit_Age` | Age | `40` |
| `fakeit_Ethnicity` | Ethnicity | `"German"` |
| `fakeit_SSN` | Social Security Number | `"296446360"` |
| `fakeit_EIN` | Employer ID Number | `"12-3456789"` |
| `fakeit_Hobby` | Hobby | `"Swimming"` |
| `fakeit_Email` | Email address | `"markusmoen@pagac.net"` |
| `fakeit_Phone` | Phone number | `"6136459948"` |
| `fakeit_PhoneFormatted` | Formatted phone | `"136-459-9489"` |
| Function | Description | Example Output |
| ----------------------- | ---------------------- | ------------------------------------------------------------------- |
| `fakeit_Name` | Full name | `"Priscilla Thornton"` |
| `fakeit_NamePrefix` | Name prefix | `"Mr."` |
| `fakeit_NameSuffix` | Name suffix | `"Jr."` |
| `fakeit_FirstName` | First name | `"Priscilla"` |
| `fakeit_MiddleName` | Middle name | `"Belinda"` |
| `fakeit_LastName` | Last name | `"Daniel"` |
| `fakeit_Gender` | Gender | `"male"` |
| `fakeit_Age` | Age | `40` |
| `fakeit_Ethnicity` | Ethnicity | `"German"` |
| `fakeit_SSN` | Social Security Number | `"296446360"` |
| `fakeit_EIN` | Employer ID Number | `"12-3456789"` |
| `fakeit_Hobby` | Hobby | `"Swimming"` |
| `fakeit_SocialMedia` | Social media profile | `"https://twitter.com/ImpossibleTrousers"` |
| `fakeit_Bio` | Biography | `"Born in New York, John grew up to become a Software Engineer..."` |
| `fakeit_Email` | Email address | `"priscilla.thornton@duncan.biz"` |
| `fakeit_Phone` | Phone number | `"6136459948"` |
| `fakeit_PhoneFormatted` | Formatted phone | `"136-459-9489"` |
### Generate
@@ -391,7 +393,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| Function | Description | Example Output |
| --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- |
| `fakeit_City` | City name | `"Marcelside"` |
| `fakeit_City` | City name | `"Reno"` |
| `fakeit_Country` | Country name | `"United States of America"` |
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
| `fakeit_State` | State name | `"Illinois"` |
@@ -400,7 +402,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_StreetName` | Street name | `"View"` |
| `fakeit_StreetNumber` | Street number | `"13645"` |
| `fakeit_StreetPrefix` | Street prefix | `"East"` |
| `fakeit_StreetSuffix` | Street suffix | `"Ave"` |
| `fakeit_StreetSuffix` | Street suffix | `"side"` |
| `fakeit_Unit` | Unit | `"Apt 123"` |
| `fakeit_Zip` | ZIP code | `"13645"` |
| `fakeit_Latitude` | Random latitude | `-73.534056` |
@@ -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_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}` → `122.471830` |
### Airline
| Function | Description | Example Output |
| ----------------------------- | ----------------- | ---------------------------------------------------- |
| `fakeit_AirlineAircraftType` | Aircraft type | `"narrowbody"` |
| `fakeit_AirlineAirplane` | Airplane model | `"Airbus A320"` |
| `fakeit_AirlineAirport` | Airport name | `"Hartsfield-Jackson Atlanta International Airport"` |
| `fakeit_AirlineAirportIATA` | Airport IATA code | `"ATL"` |
| `fakeit_AirlineFlightNumber` | Flight number | `"AA1234"` |
| `fakeit_AirlineRecordLocator` | Booking reference | `"ABCDEF"` |
| `fakeit_AirlineSeat` | Seat assignment | `"12A"` |
### Game
| Function | Description | Example Output |
| ----------------- | ----------- | ------------------- |
| `fakeit_Gamertag` | Gamer tag | `"footinterpret63"` |
| Function | Description | Example Output |
| ----------------- | ----------- | ----------------- |
| `fakeit_Gamertag` | Gamer tag | `"TurkeyThinker"` |
### Beer
@@ -443,18 +457,22 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| ---------------------------------- | --------------------------- | ---------------- |
| `fakeit_Word` | Random word | `"example"` |
| `fakeit_Noun` | Random noun | `"computer"` |
| `fakeit_NounCommon` | Common noun | `"table"` |
| `fakeit_NounCommon` | Common noun | `"group"` |
| `fakeit_NounConcrete` | Concrete noun | `"chair"` |
| `fakeit_NounAbstract` | Abstract noun | `"freedom"` |
| `fakeit_NounCollectivePeople` | Collective noun (people) | `"team"` |
| `fakeit_NounCollectiveAnimal` | Collective noun (animal) | `"herd"` |
| `fakeit_NounCollectiveAnimal` | Collective noun (animal) | `"leap"` |
| `fakeit_NounCollectiveThing` | Collective noun (thing) | `"bunch"` |
| `fakeit_NounCountable` | Countable noun | `"book"` |
| `fakeit_NounUncountable` | Uncountable noun | `"water"` |
| `fakeit_Verb` | Random verb | `"run"` |
| `fakeit_NounProper` | Proper noun | `"John"` |
| `fakeit_NounDeterminer` | Noun determiner | `"your"` |
| `fakeit_Verb` | Random verb | `"would"` |
| `fakeit_VerbAction` | Action verb | `"jump"` |
| `fakeit_VerbLinking` | Linking verb | `"is"` |
| `fakeit_VerbHelping` | Helping verb | `"can"` |
| `fakeit_VerbTransitive` | Transitive verb | `"follow"` |
| `fakeit_VerbIntransitive` | Intransitive verb | `"laugh"` |
| `fakeit_Adverb` | Random adverb | `"quickly"` |
| `fakeit_AdverbManner` | Manner adverb | `"carefully"` |
| `fakeit_AdverbDegree` | Degree adverb | `"very"` |
@@ -468,7 +486,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_PrepositionDouble` | Double preposition | `"out of"` |
| `fakeit_PrepositionCompound` | Compound preposition | `"according to"` |
| `fakeit_Adjective` | Random adjective | `"beautiful"` |
| `fakeit_AdjectiveDescriptive` | Descriptive adjective | `"large"` |
| `fakeit_AdjectiveDescriptive` | Descriptive adjective | `"tired"` |
| `fakeit_AdjectiveQuantitative` | Quantitative adjective | `"many"` |
| `fakeit_AdjectiveProper` | Proper adjective | `"American"` |
| `fakeit_AdjectiveDemonstrative` | Demonstrative adjective | `"this"` |
@@ -483,26 +501,33 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_PronounDemonstrative` | Demonstrative pronoun | `"that"` |
| `fakeit_PronounInterrogative` | Interrogative pronoun | `"who"` |
| `fakeit_PronounRelative` | Relative pronoun | `"which"` |
| `fakeit_PronounIndefinite` | Indefinite pronoun | `"few"` |
| `fakeit_Connective` | Random connective | `"however"` |
| `fakeit_ConnectiveTime` | Time connective | `"then"` |
| `fakeit_ConnectiveComparative` | Comparative connective | `"similarly"` |
| `fakeit_ConnectiveComplaint` | Complaint connective | `"although"` |
| `fakeit_ConnectiveComparative` | Comparative connective | `"after all"` |
| `fakeit_ConnectiveComplaint` | Complaint connective | `"i.e."` |
| `fakeit_ConnectiveListing` | Listing connective | `"firstly"` |
| `fakeit_ConnectiveCasual` | Casual connective | `"because"` |
| `fakeit_ConnectiveExamplify` | Examplify connective | `"for example"` |
| `fakeit_Interjection` | Interjection | `"wow"` |
### Text
| Function | Description | Example |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------- |
| `fakeit_Sentence(wordCount ...int)` | Random sentence (optional word count) | `{{ fakeit_Sentence }}` or `{{ fakeit_Sentence 10 }}` |
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` |
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
| `fakeit_Question` | Random question | `"What is your name?"` |
| `fakeit_Quote` | Random quote | `"Life is what happens..."` |
| `fakeit_Phrase` | Random phrase | `"a piece of cake"` |
| Function | Description | Example |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
| `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` |
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"eveniet"` |
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
| `fakeit_Question` | Random question | `"What is the marginal gain from fear?"` |
| `fakeit_Quote` | Random quote | `"Energy is contagious—so is fear"` |
| `fakeit_Phrase` | Random phrase | `"how many siblings do you have"` |
| `fakeit_PhraseNoun` | Noun phrase | `"a tribe"` |
| `fakeit_PhraseVerb` | Verb phrase | `"fully integrate a system"` |
| `fakeit_PhraseAdverb` | Adverb phrase | `"fully gladly"` |
| `fakeit_PhrasePreposition` | Preposition phrase | `"out the black thing"` |
| `fakeit_Comment` | Comment | `"add some a little bit team"` |
### Foods
@@ -515,6 +540,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_Dinner` | Dinner food | `"Wild addicting dip"` |
| `fakeit_Snack` | Snack | `"Trail mix"` |
| `fakeit_Dessert` | Dessert | `"French napoleons"` |
| `fakeit_Drink` | Drink | `"Soda"` |
### Misc
@@ -525,20 +551,23 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Color
| Function | Description | Example Output |
| ------------------- | ------------------ | --------------------------------------------------------- |
| `fakeit_Color` | Color name | `"MediumOrchid"` |
| `fakeit_HexColor` | Hex color | `"#a99fb4"` |
| `fakeit_RGBColor` | RGB color | `[85, 224, 195]` |
| `fakeit_SafeColor` | Safe color | `"black"` |
| `fakeit_NiceColors` | Nice color palette | `["#cfffdd", "#b4dec1", "#5c5863", "#a85163", "#ff1f4c"]` |
| Function | Description | Example Output |
| ------------------- | ------------------ | ------------------------------------------- |
| `fakeit_Color` | Color name | `"MediumOrchid"` |
| `fakeit_HexColor` | Hex color | `"#a99fb4"` |
| `fakeit_RGBColor` | RGB color | `[85 224 195]` |
| `fakeit_HSLColor` | HSL color | `[210 85 50]` |
| `fakeit_SafeColor` | Safe color | `"black"` |
| `fakeit_NiceColors` | Nice color palette | `[#cfffdd #b4dec1 #5c5863 #a85163 #ff1f4c]` |
### Image
| Function | Description | Example |
| ----------------------------------------- | ------------------------- | -------------------------------- |
| `fakeit_ImageJpeg(width int, height int)` | Generate JPEG image bytes | `{{ fakeit_ImageJpeg 100 100 }}` |
| `fakeit_ImagePng(width int, height int)` | Generate PNG image bytes | `{{ fakeit_ImagePng 100 100 }}` |
| Function | Description | Example |
| ----------------------------------------- | -------------------------- | -------------------------------- |
| `fakeit_ImageJpeg(width int, height int)` | Generate a JPEG byte slice | `{{ fakeit_ImageJpeg 100 100 }}` |
| `fakeit_ImagePng(width int, height int)` | Generate a PNG byte slice | `{{ fakeit_ImagePng 100 100 }}` |
> **Note:** Go templates render byte slices as decimal lists, not raw binary image data. Use `{{ json_Encode (fakeit_ImagePng 100 100) }}` to produce a Base64-encoded JSON string. These functions cannot write raw image bytes directly through body templating.
### Internet
@@ -567,16 +596,16 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| Function | Description | Example Output |
| ------------------ | --------------- | ------------------ |
| `fakeit_InputName` | HTML input name | `"email"` |
| `fakeit_InputName` | HTML input name | `"message"` |
| `fakeit_Svg` | SVG image | `"<svg>...</svg>"` |
### Date/Time
| Function | Description | Example |
| -------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ |
| `fakeit_Date` | Random date | `2023-06-15 14:30:00` |
| `fakeit_PastDate` | Past date | `2022-03-10 09:15:00` |
| `fakeit_FutureDate` | Future date | `2025-12-20 18:45:00` |
| `fakeit_Date` | Random date | `2013-11-07 04:31:13.726582492 +0000 UTC` |
| `fakeit_PastDate` | Date 1–12 hours before now | Current time − 1–12 hours |
| `fakeit_FutureDate` | Date 1–12 hours after now | Current time + 1–12 hours |
| `fakeit_DateRange(start time.Time, end time.Time)` | Random date between start and end | `{{ fakeit_DateRange (strings_ToDate "2020-01-01") (strings_ToDate "2025-12-31") }}` |
| `fakeit_NanoSecond` | Nanosecond | `123456789` |
| `fakeit_Second` | Second (0-59) | `45` |
@@ -587,11 +616,11 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_Day` | Day (1-31) | `15` |
| `fakeit_WeekDay` | Weekday | `"Monday"` |
| `fakeit_Year` | Year | `2024` |
| `fakeit_TimeZone` | Timezone | `"America/New_York"` |
| `fakeit_TimeZone` | Timezone name | `"West Pacific Standard Time"` |
| `fakeit_TimeZoneAbv` | Timezone abbreviation | `"EST"` |
| `fakeit_TimeZoneFull` | Full timezone | `"Eastern Standard Time"` |
| `fakeit_TimeZoneFull` | Full timezone | `"(UTC+10:00) Guam, Port Moresby"` |
| `fakeit_TimeZoneOffset` | Timezone offset | `-5` |
| `fakeit_TimeZoneRegion` | Timezone region | `"America"` |
| `fakeit_TimeZoneRegion` | Timezone region | `"Indian/Chagos"` |
### Payment
@@ -607,8 +636,8 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_AchRouting` | ACH routing number | `"513715684"` |
| `fakeit_AchAccount` | ACH account number | `"491527954328"` |
| `fakeit_BitcoinAddress` | Bitcoin address | `"1BoatSLRHtKNngkdXEeobR76b53LETtpyT"` |
| `fakeit_BitcoinPrivateKey` | Bitcoin private key | `"5HueCGU8rMjxEXxiPuD5BDuG6o5xjA7QkbPp"` |
| `fakeit_BankName` | Bank name | `"Wells Fargo"` |
| `fakeit_BitcoinPrivateKey` | Bitcoin private key | `"5JMZxkQX2PgaasaHc8wnWLNdMu7rxeU7xS64ev7RWNinacicPfm"` |
| `fakeit_BankName` | Bank name | `"Toronto Dominion Bank"` |
| `fakeit_BankType` | Bank type | `"Investment Bank"` |
### Finance
@@ -620,17 +649,17 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Company
| Function | Description | Example Output |
| ---------------------- | -------------- | ------------------------------------------ |
| `fakeit_BS` | Business speak | `"front-end"` |
| `fakeit_Blurb` | Company blurb | `"word"` |
| `fakeit_BuzzWord` | Buzzword | `"disintermediate"` |
| `fakeit_Company` | Company name | `"Moen, Pagac and Wuckert"` |
| `fakeit_CompanySuffix` | Company suffix | `"Inc"` |
| `fakeit_JobDescriptor` | Job descriptor | `"Central"` |
| `fakeit_JobLevel` | Job level | `"Assurance"` |
| `fakeit_JobTitle` | Job title | `"Director"` |
| `fakeit_Slogan` | Company slogan | `"Universal seamless Focus, interactive."` |
| Function | Description | Example Output |
| ---------------------- | -------------- | ---------------------- |
| `fakeit_BS` | Business speak | `"front-end"` |
| `fakeit_Blurb` | Company blurb | `"Teamwork"` |
| `fakeit_BuzzWord` | Buzzword | `"disintermediate"` |
| `fakeit_Company` | Company name | `"TransparaGov"` |
| `fakeit_CompanySuffix` | Company suffix | `"Inc"` |
| `fakeit_JobDescriptor` | Job descriptor | `"Central"` |
| `fakeit_JobLevel` | Job level | `"Assurance"` |
| `fakeit_JobTitle` | Job title | `"Director"` |
| `fakeit_Slogan` | Company slogan | `"Made of styrofoam."` |
### Hacker
@@ -695,9 +724,9 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_EmojiTools` | Tools emoji | `"🔧"` |
| `fakeit_EmojiWeather` | Weather emoji | `"☀️"` |
| `fakeit_EmojiJob` | Job emoji | `"👨‍💻"` |
| `fakeit_EmojiPerson` | Person emoji | `"👤"` |
| `fakeit_EmojiGesture` | Gesture emoji | `"🙌"` |
| `fakeit_EmojiCostume` | Costume emoji | `"🎃"` |
| `fakeit_EmojiPerson` | Person emoji | `"🧓"` |
| `fakeit_EmojiGesture` | Gesture emoji | `"🤦‍♀️"` |
| `fakeit_EmojiCostume` | Costume emoji | `"🧞"` |
| `fakeit_EmojiSentence` | Emoji sentence with random emojis interspersed | `"Weekends reserve time for 🖼️ Disc 🏨 golf and day."` |
### Language
@@ -706,31 +735,35 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| ----------------------------- | --------------------- | -------------- |
| `fakeit_Language` | Language | `"English"` |
| `fakeit_LanguageAbbreviation` | Language abbreviation | `"en"` |
| `fakeit_LanguageBCP` | BCP language tag | `"en-US"` |
| `fakeit_ProgrammingLanguage` | Programming language | `"Go"` |
### Number
| Function | Description | Example |
| ----------------------------------------------- | ----------------------------------- | ------------------------------------------ |
| `fakeit_Number(min int, max int)` | Random number in range | `{{ fakeit_Number 1 100 }}` → `42` |
| `fakeit_Int` | Random int | `{{ fakeit_Int }}` |
| `fakeit_IntN(n int)` | Random int from 0 to n | `{{ fakeit_IntN 100 }}` |
| `fakeit_Int8` | Random int8 | `{{ fakeit_Int8 }}` |
| `fakeit_Int16` | Random int16 | `{{ fakeit_Int16 }}` |
| `fakeit_Int32` | Random int32 | `{{ fakeit_Int32 }}` |
| `fakeit_Int64` | Random int64 | `{{ fakeit_Int64 }}` |
| `fakeit_Uint` | Random uint | `{{ fakeit_Uint }}` |
| `fakeit_UintN(n uint)` | Random uint from 0 to n | `{{ fakeit_UintN 100 }}` |
| `fakeit_Uint8` | Random uint8 | `{{ fakeit_Uint8 }}` |
| `fakeit_Uint16` | Random uint16 | `{{ fakeit_Uint16 }}` |
| `fakeit_Uint32` | Random uint32 | `{{ fakeit_Uint32 }}` |
| `fakeit_Uint64` | Random uint64 | `{{ fakeit_Uint64 }}` |
| `fakeit_Float32` | Random float32 | `{{ fakeit_Float32 }}` |
| `fakeit_Float32Range(min float32, max float32)` | Random float32 in range | `{{ fakeit_Float32Range 0 100 }}` |
| `fakeit_Float64` | Random float64 | `{{ fakeit_Float64 }}` |
| `fakeit_Float64Range(min float64, max float64)` | Random float64 in range | `{{ fakeit_Float64Range 0 100 }}` |
| `fakeit_RandomInt(slice []int)` | Random int from slice | `{{ fakeit_RandomInt (slice_Int 1 2 3) }}` |
| `fakeit_HexUint(bits int)` | Random hex uint with specified bits | `{{ fakeit_HexUint 8 }}` → `"0xff"` |
| Function | Description | Example |
| ----------------------------------------------- | ----------------------------------- | -------------------------------------------- |
| `fakeit_Number(min int, max int)` | Random number in range | `{{ fakeit_Number 1 100 }}` → `42` |
| `fakeit_Int` | Random int | `{{ fakeit_Int }}` |
| `fakeit_IntN(n int)` | Random int in `[0, n)` | `{{ fakeit_IntN 100 }}` |
| `fakeit_Int8` | Random int8 | `{{ fakeit_Int8 }}` |
| `fakeit_Int16` | Random int16 | `{{ fakeit_Int16 }}` |
| `fakeit_Int32` | Random int32 | `{{ fakeit_Int32 }}` |
| `fakeit_Int64` | Random int64 | `{{ fakeit_Int64 }}` |
| `fakeit_IntRange(min int, max int)` | Random int in range | `{{ fakeit_IntRange -10 10 }}` |
| `fakeit_Uint` | Random uint | `{{ fakeit_Uint }}` |
| `fakeit_UintN(n uint)` | Random uint in `[0, n)` | `{{ fakeit_UintN 100 }}` |
| `fakeit_Uint8` | Random uint8 | `{{ fakeit_Uint8 }}` |
| `fakeit_Uint16` | Random uint16 | `{{ fakeit_Uint16 }}` |
| `fakeit_Uint32` | Random uint32 | `{{ fakeit_Uint32 }}` |
| `fakeit_Uint64` | Random uint64 | `{{ fakeit_Uint64 }}` |
| `fakeit_UintRange(min uint, max uint)` | Random uint in range | `{{ fakeit_UintRange 10 100 }}` |
| `fakeit_Float32` | Random float32 | `{{ fakeit_Float32 }}` |
| `fakeit_Float32Range(min float32, max float32)` | Random float32 in range | `{{ fakeit_Float32Range 0 100 }}` |
| `fakeit_Float64` | Random float64 | `{{ fakeit_Float64 }}` |
| `fakeit_Float64Range(min float64, max float64)` | Random float64 in range | `{{ fakeit_Float64Range 0 100 }}` |
| `fakeit_RandomInt(slice []int)` | Random int from slice | `{{ fakeit_RandomInt (slice_Int 1 2 3) }}` |
| `fakeit_RandomUint(slice []uint)` | Random uint from slice | `{{ fakeit_RandomUint (slice_Uint 1 2 3) }}` |
| `fakeit_HexUint(bits int)` | Random hex uint with specified bits | `{{ fakeit_HexUint 8 }}` → `"0xff"` |
### String
@@ -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_Letter` | Single random letter | `"g"` |
| `fakeit_LetterN(n uint)` | Generate `n` random letters | `{{ fakeit_LetterN 10 }}` → `"gbRMaRxHki"` |
| `fakeit_Vowel` | Single lowercase vowel | `"a"` |
| `fakeit_Lexify(pattern string)` | Replace `?` with random letters | `{{ fakeit_Lexify "?????@??????.com" }}` → `"billy@mister.com"` |
| `fakeit_Numerify(pattern string)` | Replace `#` with random digits | `{{ fakeit_Numerify "(###)###-####" }}` → `"(555)867-5309"` |
| `fakeit_RandomString(slice []string)` | Random string from slice | `{{ fakeit_RandomString (slice_Str "a" "b" "c") }}` |
@@ -754,26 +788,26 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Minecraft
| Function | Description | Example Output |
| --------------------------------- | ----------------- | ---------------- |
| `fakeit_MinecraftOre` | Minecraft ore | `"coal"` |
| `fakeit_MinecraftWood` | Minecraft wood | `"oak"` |
| `fakeit_MinecraftArmorTier` | Armor tier | `"iron"` |
| `fakeit_MinecraftArmorPart` | Armor part | `"helmet"` |
| `fakeit_MinecraftWeapon` | Minecraft weapon | `"bow"` |
| `fakeit_MinecraftTool` | Minecraft tool | `"shovel"` |
| `fakeit_MinecraftDye` | Minecraft dye | `"white"` |
| `fakeit_MinecraftFood` | Minecraft food | `"apple"` |
| `fakeit_MinecraftAnimal` | Minecraft animal | `"chicken"` |
| `fakeit_MinecraftVillagerJob` | Villager job | `"farmer"` |
| `fakeit_MinecraftVillagerStation` | Villager station | `"furnace"` |
| `fakeit_MinecraftVillagerLevel` | Villager level | `"master"` |
| `fakeit_MinecraftMobPassive` | Passive mob | `"cow"` |
| `fakeit_MinecraftMobNeutral` | Neutral mob | `"bee"` |
| `fakeit_MinecraftMobHostile` | Hostile mob | `"spider"` |
| `fakeit_MinecraftMobBoss` | Boss mob | `"ender dragon"` |
| `fakeit_MinecraftBiome` | Minecraft biome | `"forest"` |
| `fakeit_MinecraftWeather` | Minecraft weather | `"rain"` |
| Function | Description | Example Output |
| --------------------------------- | ----------------- | ------------------- |
| `fakeit_MinecraftOre` | Minecraft ore | `"coal"` |
| `fakeit_MinecraftWood` | Minecraft wood | `"oak"` |
| `fakeit_MinecraftArmorTier` | Armor tier | `"iron"` |
| `fakeit_MinecraftArmorPart` | Armor part | `"helmet"` |
| `fakeit_MinecraftWeapon` | Minecraft weapon | `"bow"` |
| `fakeit_MinecraftTool` | Minecraft tool | `"shovel"` |
| `fakeit_MinecraftDye` | Minecraft dye | `"white"` |
| `fakeit_MinecraftFood` | Minecraft food | `"apple"` |
| `fakeit_MinecraftAnimal` | Minecraft animal | `"chicken"` |
| `fakeit_MinecraftVillagerJob` | Villager job | `"farmer"` |
| `fakeit_MinecraftVillagerStation` | Villager station | `"stonecutter"` |
| `fakeit_MinecraftVillagerLevel` | Villager level | `"master"` |
| `fakeit_MinecraftMobPassive` | Passive mob | `"cow"` |
| `fakeit_MinecraftMobNeutral` | Neutral mob | `"bee"` |
| `fakeit_MinecraftMobHostile` | Hostile mob | `"wither skeleton"` |
| `fakeit_MinecraftMobBoss` | Boss mob | `"ender dragon"` |
| `fakeit_MinecraftBiome` | Minecraft biome | `"forest"` |
| `fakeit_MinecraftWeather` | Minecraft weather | `"rain"` |
### Book
@@ -792,26 +826,28 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Error
| Function | Description | Example Output |
| ------------------------ | ----------------- | ---------------------------------- |
| `fakeit_Error` | Random error | `"connection refused"` |
| `fakeit_ErrorDatabase` | Database error | `"database connection failed"` |
| `fakeit_ErrorGRPC` | gRPC error | `"rpc error: code = Unavailable"` |
| `fakeit_ErrorHTTP` | HTTP error | `"HTTP 500 Internal Server Error"` |
| `fakeit_ErrorHTTPClient` | HTTP client error | `"HTTP 404 Not Found"` |
| `fakeit_ErrorHTTPServer` | HTTP server error | `"HTTP 503 Service Unavailable"` |
| `fakeit_ErrorRuntime` | Runtime error | `"panic: runtime error"` |
| Function | Description | Example Output |
| ------------------------ | ----------------- | ----------------------------------------------- |
| `fakeit_Error` | Random error | `"variable assigned before declaration"` |
| `fakeit_ErrorObject` | Error object | `"protocol"` |
| `fakeit_ErrorDatabase` | Database error | `"destination pointer is nil"` |
| `fakeit_ErrorGRPC` | gRPC error | `"connection refused"` |
| `fakeit_ErrorHTTP` | HTTP error | `"wrote more than the declared Content-Length"` |
| `fakeit_ErrorHTTPClient` | HTTP client error | `"expectation failed"` |
| `fakeit_ErrorHTTPServer` | HTTP server error | `"not extended"` |
| `fakeit_ErrorRuntime` | Runtime error | `"expected 2 arguments, got 3"` |
| `fakeit_ErrorValidation` | Validation error | `"missing required field"` |
### School
| Function | Description | Example Output |
| --------------- | ----------- | ---------------------- |
| `fakeit_School` | School name | `"Harvard University"` |
| Function | Description | Example Output |
| --------------- | ----------- | ---------------------------- |
| `fakeit_School` | School name | `"Hillside Private Academy"` |
### Song
| Function | Description | Example Output |
| ------------------- | ----------- | --------------------- |
| `fakeit_SongName` | Song name | `"Bohemian Rhapsody"` |
| `fakeit_SongArtist` | Song artist | `"Queen"` |
| `fakeit_SongGenre` | Song genre | `"Rock"` |
| Function | Description | Example Output |
| ------------------- | ----------- | ------------------------ |
| `fakeit_SongName` | Song name | `"What Was I Made For?"` |
| `fakeit_SongArtist` | Song artist | `"Queen"` |
| `fakeit_SongGenre` | Song genre | `"Rock"` |
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1784796856,
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
"lastModified": 1789006805,
"narHash": "sha256-xB8mKMOx1IA9vTDNLmJZ6n4wCMq/cuWBBOzGCRnqxrU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
"rev": "8ce4ef6cb6f871616146b9fe26d2a5ae594e94fe",
"type": "github"
},
"original": {
+3 -3
View File
@@ -32,18 +32,18 @@
};
});
# nix run github:aykhans/sarin -- <args>
# nix run github:aykhans/sarin/release -- <args>
apps = forAllSystems (pkgs: {
default = {
type = "app";
program = "${self.packages.${pkgs.system}.default}/bin/sarin";
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_26 pkgs.golangci-lint pkgs.go-task ];
packages = [ pkgs.go_1_27 pkgs.golangci-lint pkgs.go-task ];
};
});
+18 -18
View File
@@ -1,53 +1,53 @@
module go.aykhans.me/sarin
go 1.26.5
go 1.27.1
require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.0.9
charm.land/glamour/v2 v2.0.1
charm.land/lipgloss/v2 v2.0.6
github.com/brianvoe/gofakeit/v7 v7.15.0
github.com/brianvoe/gofakeit/v7 v7.17.0
github.com/charmbracelet/x/term v0.2.2
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6
github.com/dop251/goja v0.0.0-20260911104922-fabc3b8078ad
github.com/joho/godotenv v1.5.1
github.com/valyala/fasthttp v1.73.0
github.com/valyala/fasthttp v1.74.0
github.com/yuin/gopher-lua v1.1.2
go.aykhans.me/utils v1.0.7
go.yaml.in/yaml/v4 v4.0.0-rc.6
golang.org/x/net v0.58.0
golang.org/x/net v0.59.0
)
require (
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 // 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/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
github.com/dlclark/regexp2/v2 v2.8.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/molecule-man/go-brrr v1.1.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/yuin/goldmark v1.8.5 // indirect
github.com/yuin/goldmark v1.8.6 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
)
+36 -36
View File
@@ -1,7 +1,7 @@
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.0.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=
@@ -14,26 +14,24 @@ github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/brianvoe/gofakeit/v7 v7.15.0 h1:kGLYAWN8tnmxq2PelKVK6zwpM7kMxdz9SGPH31mFkNs=
github.com/brianvoe/gofakeit/v7 v7.15.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/brianvoe/gofakeit/v7 v7.17.0 h1:usvHa73vmAjC1JmX4dpZAm1eQkR0riXaxvilScRTFb0=
github.com/brianvoe/gofakeit/v7 v7.17.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 h1:/hSCZO1puetbGd2JikHKN0nAcb2mFevzxJElFoduhaw=
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16/go.mod h1:JFvmQPZh3uAu7PpSZU91bcR0BrIy24HhW+0EsW1uGxE=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 h1:Z0hBPQ9hslsfpFRRdMn+4cjnb3LK6FQH58hQkXrXv0A=
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/slice v0.0.0-20260906004030-3986e9119cf9 h1:zW96vcZ3zDetccerbN7sS/sCydOE/l/9S+C94pozsaU=
github.com/charmbracelet/x/exp/slice v0.0.0-20260906004030-3986e9119cf9/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -46,30 +44,32 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6 h1:Oh2rRG1un7tLlC3/NJDzKppZ4CeZGkVFJCUOTRwLpfw=
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg=
github.com/dlclark/regexp2/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7BbdneDfw=
github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260911104922-fabc3b8078ad h1:5gT9hQ9h8CplLZfAGgFMcoLqQS6I9T2sZwQOCXy8/3s=
github.com/dop251/goja v0.0.0-20260911104922-fabc3b8078ad/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY=
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/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/molecule-man/go-brrr v1.1.0 h1:rLBGSXA393y+5ttCBLkW34CHESop5tahrOFxVv/XDEU=
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/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
@@ -80,14 +80,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU=
github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
@@ -98,13 +96,15 @@ go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+1 -1
View File
@@ -23,7 +23,7 @@ Flags:
-h, -help Help for sarin
-v, -version Version for sarin
-s, -show-config bool Show the final config after parsing all sources (default %v)
-f, -config-file string Path to the config file (local file / http URL)
-f, -config-file []string Path to the config file (local file / http URL)
-c, -concurrency uint Number of concurrent requests (default %d)
-r, -requests uint Number of total requests
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
+4 -4
View File
@@ -715,7 +715,7 @@ func validateScriptSource(script string) error {
return types.ErrScriptEmpty
}
// Not a file/URL reference - it's an inline script
// Not a file/URL reference, so it's an inline script
if !strings.HasPrefix(script, "@") {
return nil
}
@@ -725,7 +725,7 @@ func validateScriptSource(script string) error {
return nil
}
// It's a file or URL reference - validate the source
// It's a file or URL reference, so validate the source
source := script[1:] // Remove the @ prefix
if source == "" {
@@ -744,7 +744,7 @@ func validateScriptSource(script string) error {
return nil
}
// It's a file path - basic validation (not empty, checked above)
// It's a file path, already checked to be non-empty above
return nil
}
@@ -822,7 +822,7 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m printConfigModel) View() tea.View {
// AltScreen and MouseMode were program options in bubbletea v1; in v2 they
// 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)
+1 -1
View File
@@ -193,7 +193,7 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F
func ValidateTemplates(config *Config) []types.FieldValidationError {
// Create template function map using the same functions as sarin package
// Use nil for fileCache during validation - templates are only parsed, not executed
// fileCache is nil because templates are only parsed here, not executed
randSource := sarin.NewDefaultRandSource()
funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
+116 -90
View File
@@ -12,13 +12,13 @@ import (
"time"
"github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpproxy"
"go.aykhans.me/sarin/internal/types"
utilsSlice "go.aykhans.me/utils/slice"
"golang.org/x/net/proxy"
)
type HostClientGenerator func() *fasthttp.HostClient
// clientIndexGenerator returns the index of the host client (and proxy) to use next.
type clientIndexGenerator func() int
func safeUintToInt(u uint) int {
if u > math.MaxInt {
@@ -77,8 +77,9 @@ func NewHostClients(
}
client := &fasthttp.HostClient{
MaxConns: safeUintToInt(maxConns),
IsTLS: isTLS,
MaxConns: safeUintToInt(maxConns),
IsTLS: isTLS,
DialDualStack: true,
TLSConfig: &tls.Config{
InsecureSkipVerify: skipVerify, //nolint:gosec
},
@@ -94,40 +95,45 @@ func NewHostClients(
return []*fasthttp.HostClient{client}, nil
}
// NewProxyDialFunc creates a dial function for the given proxy URL.
// NewProxyDialFunc creates a dial function for the given proxy URL with a fixed timeout.
// It can return the following errors:
// - types.ProxyUnsupportedSchemeError
// - types.ErrProxyNoContextDialer
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
var (
dialer fasthttp.DialFunc
err error
)
dial, err := NewProxyDialFuncWithTimeout(ctx, proxyURL)
if err != nil {
return nil, err
}
return func(addr string) (net.Conn, error) {
return dial(addr, timeout)
}, nil
}
// NewProxyDialFuncWithTimeout creates a dial function for the given proxy URL that takes the timeout per call.
// It can return the following errors:
// - types.ProxyUnsupportedSchemeError
// - types.ErrProxyNoContextDialer
func NewProxyDialFuncWithTimeout(ctx context.Context, proxyURL *url.URL) (fasthttp.DialFuncWithTimeout, error) {
switch proxyURL.Scheme {
case "socks5":
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, true)
if err != nil {
return nil, err
}
return fasthttpSocksDialer(ctx, proxyURL, true)
case "socks5h":
dialer, err = fasthttpSocksDialerDualStackTimeout(ctx, proxyURL, timeout, false)
if err != nil {
return nil, err
}
return fasthttpSocksDialer(ctx, proxyURL, false)
case "http":
dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxyURL.String(), timeout)
return fasthttpConnectDialer(proxyURL, false), nil
case "https":
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
return fasthttpConnectDialer(proxyURL, true), nil
default:
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
}
return dialer, nil
}
// The returned dial function can return the following errors:
// fasthttpSocksDialer creates a SOCKS5 dial function that takes the timeout per call.
// It can return the following errors:
// - types.ErrProxyNoContextDialer
// - types.ProxyDialError
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
func fasthttpSocksDialer(ctx context.Context, proxyURL *url.URL, resolveLocally bool) (fasthttp.DialFuncWithTimeout, error) {
netDialer := &net.Dialer{}
// Parse auth from proxy URL if present
@@ -149,66 +155,92 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
proxyStr := proxyURL.String()
// Assert to ContextDialer for timeout support
// Timeouts need DialContext, which proxy.SOCKS5 always provides with a net.Dialer.
contextDialer, ok := socksDialer.(proxy.ContextDialer)
if !ok {
// Fallback without timeout (should not happen with net.Dialer)
return func(addr string) (net.Conn, error) {
conn, err := socksDialer.Dial("tcp", addr)
if err != nil {
return nil, types.NewProxyDialError(proxyStr, err)
}
return conn, nil
}, nil
return nil, types.ErrProxyNoContextDialer
}
// Return dial function that uses context with timeout
return func(addr string) (net.Conn, error) {
return func(addr string, timeout time.Duration) (net.Conn, error) {
deadline := time.Now().Add(timeout)
addrs := []string{addr}
if resolveLocally {
host, port, err := net.SplitHostPort(addr)
var err error
addrs, err = resolveIPv4First(ctx, addr, timeout)
if err != nil {
return nil, types.NewProxyDialError(proxyStr, err)
}
}
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
dnsCancel()
if err != nil {
return nil, types.NewProxyDialError(proxyStr, err)
}
if len(ips) == 0 {
return nil, types.NewProxyDialError(proxyStr, types.NewProxyResolveError(host))
// Try every resolved address, so one unreachable family does not fail the dial.
var lastErr error
for _, candidate := range addrs {
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
}
// Use the first resolved IP
addr = net.JoinHostPort(ips[0].String(), port)
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
conn, err := contextDialer.DialContext(dialCtx, "tcp", candidate)
dialCancel()
if err == nil {
return conn, nil
}
lastErr = err
}
// Use remaining time for dial
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
}
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
defer dialCancel()
conn, err := contextDialer.DialContext(dialCtx, "tcp", addr)
if err != nil {
return nil, types.NewProxyDialError(proxyStr, err)
}
return conn, nil
return nil, types.NewProxyDialError(proxyStr, lastErr)
}, nil
}
// resolveIPv4First resolves the host of addr into candidate addresses, IPv4 ones first.
// It can return the following errors:
// - types.ProxyResolveError
func resolveIPv4First(ctx context.Context, addr string, timeout time.Duration) ([]string, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
dnsCancel()
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, types.NewProxyResolveError(host)
}
addrs := make([]string, 0, len(ips))
for _, ip := range ips {
if ip.To4() != nil {
addrs = append(addrs, net.JoinHostPort(ip.String(), port))
}
}
for _, ip := range ips {
if ip.To4() == nil {
addrs = append(addrs, net.JoinHostPort(ip.String(), port))
}
}
return addrs, nil
}
// fasthttpConnectDialer creates a dial function that tunnels through an HTTP proxy with CONNECT,
// reaching the proxy over TLS when useTLS is set.
//
// The returned dial function can return the following errors:
// - types.ProxyDialError
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
func fasthttpConnectDialer(proxyURL *url.URL, useTLS bool) fasthttp.DialFuncWithTimeout {
defaultPort := "80"
if useTLS {
defaultPort = "443"
}
proxyAddr := proxyURL.Host
if proxyURL.Port() == "" {
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), "443")
proxyAddr = net.JoinHostPort(proxyURL.Hostname(), defaultPort)
}
// Build Proxy-Authorization header if auth is present
@@ -222,7 +254,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
proxyStr := proxyURL.String()
return func(addr string) (net.Conn, error) {
return func(addr string, timeout time.Duration) (net.Conn, error) {
// Establish TCP connection to proxy with timeout
start := time.Now()
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
@@ -242,13 +274,15 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
return nil, types.NewProxyDialError(proxyStr, err)
}
// Upgrade to TLS
tlsConn := tls.Client(conn, &tls.Config{
ServerName: proxyURL.Hostname(),
})
if err := tlsConn.Handshake(); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err)
if useTLS {
tlsConn := tls.Client(conn, &tls.Config{
ServerName: proxyURL.Hostname(),
})
if err := tlsConn.Handshake(); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err)
}
conn = tlsConn
}
// Build and send CONNECT request
@@ -262,35 +296,35 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
connectReq.Header.Set("Proxy-Authorization", proxyAuth)
}
if err := connectReq.Write(tlsConn); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
if err := connectReq.Write(conn); err != nil {
conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err)
}
// Read response using buffered reader, but return wrapped connection
// to preserve any buffered data
bufReader := bufio.NewReader(tlsConn)
bufReader := bufio.NewReader(conn)
resp, err := http.ReadResponse(bufReader, connectReq)
if err != nil {
tlsConn.Close() //nolint:errcheck,gosec
conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err)
}
resp.Body.Close() //nolint:errcheck,gosec
if resp.StatusCode != http.StatusOK {
tlsConn.Close() //nolint:errcheck,gosec
conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
}
// Clear deadline for the tunneled connection
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
if err := conn.SetDeadline(time.Time{}); err != nil {
conn.Close() //nolint:errcheck,gosec
return nil, types.NewProxyDialError(proxyStr, err)
}
// Return wrapped connection that uses the buffered reader
// to avoid losing any data that was read ahead
return &bufferedConn{Conn: tlsConn, reader: bufReader}, nil
return &bufferedConn{Conn: conn, reader: bufReader}, nil
}
}
@@ -306,18 +340,10 @@ func (c *bufferedConn) Read(b []byte) (int, error) {
return c.reader.Read(b)
}
func NewHostClientGenerator(clients ...*fasthttp.HostClient) HostClientGenerator {
switch len(clients) {
case 0:
hostClient := &fasthttp.HostClient{}
return func() *fasthttp.HostClient {
return hostClient
}
case 1:
return func() *fasthttp.HostClient {
return clients[0]
}
default:
return utilsSlice.RandomCycle(nil, clients...)
func newClientIndexGenerator(count int) clientIndexGenerator {
indexes := make([]int, count)
for i := range indexes {
indexes[i] = i
}
return utilsSlice.RandomCycle(nil, indexes...)
}
+1 -2
View File
@@ -58,8 +58,7 @@ func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
file := &CachedFile{Content: content, Filename: filename}
// LoadOrStore handles race condition - if another goroutine
// cached it first, we get theirs (no duplicate storage)
// If another goroutine cached it first, LoadOrStore returns theirs
actual, _ := fc.cache.LoadOrStore(source, file)
return actual.(*CachedFile), nil
}
+17 -12
View File
@@ -191,18 +191,7 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
}
if len(reqData.Cookies) > 0 {
var sb strings.Builder
for k, values := range reqData.Cookies {
for _, v := range values {
if sb.Len() > 0 {
sb.WriteString("; ")
}
sb.WriteString(k)
sb.WriteByte('=')
sb.WriteString(v)
}
}
req.Header.Add("Cookie", sb.String())
req.Header.Add("Cookie", cookieHeaderValue(reqData.Cookies))
}
if scheme == "https" {
@@ -210,6 +199,22 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
}
}
// cookieHeaderValue joins cookies into a single Cookie header value ("a=1; b=2").
func cookieHeaderValue(cookies map[string][]string) string {
var sb strings.Builder
for k, values := range cookies {
for _, v := range values {
if sb.Len() > 0 {
sb.WriteString("; ")
}
sb.WriteString(k)
sb.WriteByte('=')
sb.WriteString(v)
}
}
return sb.String()
}
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot)
+45 -56
View File
@@ -199,10 +199,11 @@ type sarin struct {
logError bool
logFile string
hostClients []*fasthttp.HostClient
responses *SarinResponseData
fileCache *FileCache
scriptChain *script.Chain
hostClients []*fasthttp.HostClient
scriptHTTPClients []*scriptHTTPClient
responses *SarinResponseData
fileCache *FileCache
scriptChain *script.Chain
}
// NewSarin creates a new sarin instance for load testing.
@@ -248,7 +249,12 @@ func NewSarin(
}
}
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
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)
if err != nil {
return nil, err
}
@@ -266,28 +272,37 @@ func NewSarin(
scriptChain := script.NewChain(luaSources, jsSources)
var scriptHTTPClients []*scriptHTTPClient
if !scriptChain.IsEmpty() {
scriptHTTPClients, err = newScriptHTTPClients(ctx, proxyURLs, workers)
if err != nil {
return nil, err
}
}
srn := &sarin{
workers: workers,
requestURL: requestURL,
methods: methods,
params: params,
headers: headers,
cookies: cookies,
bodies: bodies,
totalRequests: totalRequests,
totalDuration: totalDuration,
timeout: timeout,
showProgress: showProgress,
skipCertVerify: skipCertVerify,
values: values,
collectStats: collectStats,
dryRun: dryRun,
logInfo: logInfo,
logError: logError,
logFile: logFile,
hostClients: hostClients,
fileCache: NewFileCache(time.Second * 10),
scriptChain: scriptChain,
workers: workers,
requestURL: requestURL,
methods: methods,
params: params,
headers: headers,
cookies: cookies,
bodies: bodies,
totalRequests: totalRequests,
totalDuration: totalDuration,
timeout: timeout,
showProgress: showProgress,
skipCertVerify: skipCertVerify,
values: values,
collectStats: collectStats,
dryRun: dryRun,
logInfo: logInfo,
logError: logError,
logFile: logFile,
hostClients: hostClients,
scriptHTTPClients: scriptHTTPClients,
fileCache: NewFileCache(time.Second * 10),
scriptChain: scriptChain,
}
if collectStats {
@@ -364,7 +379,7 @@ func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
}
// Start workers
s.startWorkers(&workersWG, jobsCh, s.hostClients, &counter, sendLog, sendRespLog)
s.startWorkers(&workersWG, jobsCh, &counter, sendLog, sendRespLog)
if runTUI {
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
@@ -394,7 +409,7 @@ func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
}
// newWriterLog builds the loggers that write formatted lines to w (a log file or
// stderr). sendLog stays general (it filters by each log's level); sendRespLog
// stderr). sendLog stays general (it filters by each log's level), while sendRespLog
// only ever emits info, so its decision is baked once into a no-op when off.
func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
// log.Logger serializes writes with its own mutex, so concurrent workers
@@ -432,36 +447,10 @@ func (s sarin) newChannelLog(ch chan<- runtimeLog) (runtimeLogger, respLogger) {
return sendLog, sendRespLog
}
// newHostClients initializes HTTP clients for the given configuration.
// It can return the following errors:
// - types.ProxyDialError
func newHostClients(
ctx context.Context,
timeout time.Duration,
proxies types.Proxies,
workers uint,
requestURL *url.URL,
skipCertVerify bool,
) ([]*fasthttp.HostClient, error) {
proxiesRaw := make([]url.URL, len(proxies))
for i, proxy := range proxies {
proxiesRaw[i] = url.URL(proxy)
}
return NewHostClients(
ctx,
timeout,
proxiesRaw,
workers,
requestURL,
skipCertVerify,
)
}
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
for range max(s.workers, 1) {
wg.Go(func() {
s.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendLog, sendRespLog)
s.Worker(jobs, counter, sendLog, sendRespLog)
})
}
}
+155
View File
@@ -0,0 +1,155 @@
package sarin
import (
"context"
"crypto/tls"
"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 {
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) ([]*scriptHTTPClient, error) {
if len(proxies) == 0 {
return []*scriptHTTPClient{newScriptHTTPClient(fasthttp.DialDualStackTimeout, scriptHTTPDefaultTimeout, maxConns)}, 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(dial, scriptHTTPDefaultTimeout, maxConns))
}
return clients, nil
}
func newScriptHTTPClient(dial fasthttp.DialFuncWithTimeout, defaultTimeout time.Duration, maxConns uint) *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),
TLSConfig: &tls.Config{
InsecureSkipVerify: insecure, //nolint:gosec
},
DisableHeaderNamesNormalizing: true,
DisablePathNormalizing: true,
NoDefaultUserAgentHeader: true,
}
}
return &scriptHTTPClient{
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) {
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 {
return nil, types.NewScriptHTTPRequestError(r.Method, r.URL, err)
}
return &script.HTTPResponse{
Status: resp.StatusCode(),
Headers: collectRespHeaders(resp),
Body: string(resp.Body()),
}, nil
}
+55 -21
View File
@@ -97,7 +97,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
return string(data), nil
},
// json_Object builds a JSON object from interleaved key-value pairs and returns it
// as a JSON string. Keys must be strings; values may be any JSON-encodable type.
// as a JSON string. Keys must be strings, values may be any JSON-encodable type.
// Usage: {{ json_Object "name" "Alice" "age" 30 }}
"json_Object": func(pairs ...any) (string, error) {
if len(pairs)%2 != 0 {
@@ -207,18 +207,20 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
// Fakeit / Person
// "fakeit_Person": fakeit.Person() *PersonInfo,
"fakeit_Name": fakeit.Name,
"fakeit_NamePrefix": fakeit.NamePrefix,
"fakeit_NameSuffix": fakeit.NameSuffix,
"fakeit_FirstName": fakeit.FirstName,
"fakeit_MiddleName": fakeit.MiddleName,
"fakeit_LastName": fakeit.LastName,
"fakeit_Gender": fakeit.Gender,
"fakeit_Age": fakeit.Age,
"fakeit_Ethnicity": fakeit.Ethnicity,
"fakeit_SSN": fakeit.SSN,
"fakeit_EIN": fakeit.EIN,
"fakeit_Hobby": fakeit.Hobby,
"fakeit_Name": fakeit.Name,
"fakeit_NamePrefix": fakeit.NamePrefix,
"fakeit_NameSuffix": fakeit.NameSuffix,
"fakeit_FirstName": fakeit.FirstName,
"fakeit_MiddleName": fakeit.MiddleName,
"fakeit_LastName": fakeit.LastName,
"fakeit_Gender": fakeit.Gender,
"fakeit_Age": fakeit.Age,
"fakeit_Ethnicity": fakeit.Ethnicity,
"fakeit_SSN": fakeit.SSN,
"fakeit_EIN": fakeit.EIN,
"fakeit_Hobby": fakeit.Hobby,
"fakeit_SocialMedia": fakeit.SocialMedia,
"fakeit_Bio": fakeit.Bio,
// "fakeit_Contact": fakeit.Contact() *ContactInfo,
"fakeit_Email": fakeit.Email,
"fakeit_Phone": fakeit.Phone,
@@ -232,6 +234,9 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
// "fakeit_Generate": fakeit.Generate(value string) string,
"fakeit_Regex": fakeit.Regex,
// Fakeit / Database
// "fakeit_SQL": fakeit.SQL(so *SQLOptions) (string, error),
// Fakeit / Auth
"fakeit_Username": fakeit.Username,
"fakeit_Password": fakeit.Password,
@@ -269,6 +274,15 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
return value
},
// Fakeit / Airline
"fakeit_AirlineAircraftType": fakeit.AirlineAircraftType,
"fakeit_AirlineAirplane": fakeit.AirlineAirplane,
"fakeit_AirlineAirport": fakeit.AirlineAirport,
"fakeit_AirlineAirportIATA": fakeit.AirlineAirportIATA,
"fakeit_AirlineFlightNumber": fakeit.AirlineFlightNumber,
"fakeit_AirlineRecordLocator": fakeit.AirlineRecordLocator,
"fakeit_AirlineSeat": fakeit.AirlineSeat,
// Fakeit / Game
"fakeit_Gamertag": fakeit.Gamertag,
// "fakeit_Dice": fakeit.Dice(numDice uint, sides []uint) []uint,
@@ -302,12 +316,16 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_NounCollectiveThing": fakeit.NounCollectiveThing,
"fakeit_NounCountable": fakeit.NounCountable,
"fakeit_NounUncountable": fakeit.NounUncountable,
"fakeit_NounProper": fakeit.NounProper,
"fakeit_NounDeterminer": fakeit.NounDeterminer,
// Verbs
"fakeit_Verb": fakeit.Verb,
"fakeit_VerbAction": fakeit.VerbAction,
"fakeit_VerbLinking": fakeit.VerbLinking,
"fakeit_VerbHelping": fakeit.VerbHelping,
"fakeit_Verb": fakeit.Verb,
"fakeit_VerbAction": fakeit.VerbAction,
"fakeit_VerbLinking": fakeit.VerbLinking,
"fakeit_VerbHelping": fakeit.VerbHelping,
"fakeit_VerbTransitive": fakeit.VerbTransitive,
"fakeit_VerbIntransitive": fakeit.VerbIntransitive,
// Adverbs
"fakeit_Adverb": fakeit.Adverb,
@@ -344,6 +362,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_PronounDemonstrative": fakeit.PronounDemonstrative,
"fakeit_PronounInterrogative": fakeit.PronounInterrogative,
"fakeit_PronounRelative": fakeit.PronounRelative,
"fakeit_PronounIndefinite": fakeit.PronounIndefinite,
// Connectives
"fakeit_Connective": fakeit.Connective,
@@ -355,7 +374,8 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_ConnectiveExamplify": fakeit.ConnectiveExamplify,
// Words
"fakeit_Word": fakeit.Word,
"fakeit_Word": fakeit.Word,
"fakeit_Interjection": fakeit.Interjection,
// Text
"fakeit_Sentence": fakeit.Sentence,
@@ -366,6 +386,11 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_Question": fakeit.Question,
"fakeit_Quote": fakeit.Quote,
"fakeit_Phrase": fakeit.Phrase,
"fakeit_PhraseNoun": fakeit.PhraseNoun,
"fakeit_PhraseVerb": fakeit.PhraseVerb,
"fakeit_PhraseAdverb": fakeit.PhraseAdverb,
"fakeit_PhrasePreposition": fakeit.PhrasePreposition,
"fakeit_Comment": fakeit.Comment,
// Fakeit / Foods
"fakeit_Fruit": fakeit.Fruit,
@@ -375,6 +400,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_Dinner": fakeit.Dinner,
"fakeit_Snack": fakeit.Snack,
"fakeit_Dessert": fakeit.Dessert,
"fakeit_Drink": fakeit.Drink,
// Fakeit / Misc
"fakeit_Bool": fakeit.Bool,
@@ -387,6 +413,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_Color": fakeit.Color,
"fakeit_HexColor": fakeit.HexColor,
"fakeit_RGBColor": fakeit.RGBColor,
"fakeit_HSLColor": fakeit.HSLColor,
"fakeit_SafeColor": fakeit.SafeColor,
"fakeit_NiceColors": fakeit.NiceColors,
@@ -530,6 +557,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
// Fakeit / Language
"fakeit_Language": fakeit.Language,
"fakeit_LanguageAbbreviation": fakeit.LanguageAbbreviation,
"fakeit_LanguageBCP": fakeit.LanguageBCP,
"fakeit_ProgrammingLanguage": fakeit.ProgrammingLanguage,
// Fakeit / Number
@@ -540,25 +568,29 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_Int16": fakeit.Int16,
"fakeit_Int32": fakeit.Int32,
"fakeit_Int64": fakeit.Int64,
"fakeit_IntRange": fakeit.IntRange,
"fakeit_Uint": fakeit.Uint,
"fakeit_UintN": fakeit.UintN,
"fakeit_Uint8": fakeit.Uint8,
"fakeit_Uint16": fakeit.Uint16,
"fakeit_Uint32": fakeit.Uint32,
"fakeit_Uint64": fakeit.Uint64,
"fakeit_UintRange": fakeit.UintRange,
"fakeit_Float32": fakeit.Float32,
"fakeit_Float32Range": fakeit.Float32Range,
"fakeit_Float64": fakeit.Float64,
"fakeit_Float64Range": fakeit.Float64Range,
// "fakeit_ShuffleInts": fakeit.ShuffleInts,
"fakeit_RandomInt": fakeit.RandomInt,
"fakeit_HexUint": fakeit.HexUint,
"fakeit_RandomInt": fakeit.RandomInt,
"fakeit_RandomUint": fakeit.RandomUint,
"fakeit_HexUint": fakeit.HexUint,
// Fakeit / String
"fakeit_Digit": fakeit.Digit,
"fakeit_DigitN": fakeit.DigitN,
"fakeit_Letter": fakeit.Letter,
"fakeit_LetterN": fakeit.LetterN,
"fakeit_Vowel": fakeit.Vowel,
"fakeit_Lexify": fakeit.Lexify,
"fakeit_Numerify": fakeit.Numerify,
// "fakeit_ShuffleStrings": fakeit.ShuffleStrings,
@@ -602,12 +634,14 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
// Fakeit / Error
"fakeit_Error": func() string { return fakeit.Error().Error() },
"fakeit_ErrorObject": func() string { return fakeit.ErrorObject().Error() },
"fakeit_ErrorDatabase": func() string { return fakeit.ErrorDatabase().Error() },
"fakeit_ErrorGRPC": func() string { return fakeit.ErrorGRPC().Error() },
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
"fakeit_ErrorValidation": func() string { return fakeit.ErrorValidation().Error() },
// Fakeit / School
"fakeit_School": fakeit.School,
@@ -717,7 +751,7 @@ func NewDefaultBodyTemplateFuncMap(
switch {
case strings.HasPrefix(val, "@@"):
// Escaped @ - send as literal string without first @
// Escaped @: send the string without the first @
if err := writer.WriteField(key, val[1:]); err != nil {
return "", err
}
+46 -18
View File
@@ -31,7 +31,6 @@ func statusCodeToString(code int) string {
func (s sarin) Worker(
jobs <-chan struct{},
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
sendRespLog respLogger,
@@ -53,6 +52,9 @@ func (s sarin) Worker(
defer scriptTransformer.Close()
}
nextClientIndex := newClientIndexGenerator(len(s.hostClients))
bindScriptProxy := s.newScriptProxyBinder(scriptTransformer)
requestGenerator, isDynamic := NewRequestGenerator(
s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer,
)
@@ -60,34 +62,45 @@ func (s sarin) Worker(
if s.dryRun {
switch {
case s.collectStats && isDynamic:
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
case s.collectStats && !isDynamic:
s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
case !s.collectStats && isDynamic:
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog)
default:
s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
}
} else {
switch {
case s.collectStats && isDynamic:
s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
case s.collectStats && !isDynamic:
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
case !s.collectStats && isDynamic:
s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, nextClientIndex, bindScriptProxy, counter, sendLog, sendRespLog)
default:
s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, nextClientIndex, counter, sendLog, sendRespLog)
}
}
}
// newScriptProxyBinder returns a func that sends the scripts' http.* calls through proxy i.
func (s sarin) newScriptProxyBinder(scriptTransformer *script.Transformer) func(i int) {
if scriptTransformer == nil || scriptTransformer.IsEmpty() {
return func(int) {}
}
return func(i int) {
scriptTransformer.SetHTTPDoer(s.scriptHTTPClients[i])
}
}
func (s sarin) workerStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64,
sendLog runtimeLogger,
sendRespLog respLogger,
@@ -95,6 +108,9 @@ func (s sarin) workerStatsWithDynamic(
for range jobs {
req.Reset()
// Bound before generation so scripts' http.* calls use the main request's proxy.
clientIndex := nextClientIndex()
bindScriptProxy(clientIndex)
if err := requestGenerator(req); err != nil {
s.responses.Add(err.Error(), 0)
sendLog(runtimeLogLevelError, err.Error())
@@ -103,7 +119,7 @@ func (s sarin) workerStatsWithDynamic(
}
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
respDuration := time.Since(startTime)
if err != nil {
@@ -121,13 +137,13 @@ func (s sarin) workerStatsWithStatic(
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
nextClientIndex clientIndexGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
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
for range jobs {
s.responses.Add(err.Error(), 0)
sendLog(runtimeLogLevelError, err.Error())
@@ -138,7 +154,7 @@ func (s sarin) workerStatsWithStatic(
for range jobs {
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
respDuration := time.Since(startTime)
if err != nil {
s.responses.Add(err.Error(), respDuration)
@@ -155,20 +171,24 @@ func (s sarin) workerNoStatsWithDynamic(
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
for range jobs {
req.Reset()
// Bound before generation so scripts' http.* calls use the main request's proxy.
clientIndex := nextClientIndex()
bindScriptProxy(clientIndex)
if err := requestGenerator(req); err != nil {
sendLog(runtimeLogLevelError, err.Error())
counter.Add(1)
continue
}
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
err := s.hostClients[clientIndex].DoTimeout(req, resp, s.timeout)
if err == nil {
sendRespLog(time.Since(startTime), resp)
}
@@ -181,7 +201,7 @@ func (s sarin) workerNoStatsWithStatic(
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
nextClientIndex clientIndexGenerator,
counter *atomic.Uint64,
sendLog runtimeLogger,
sendRespLog respLogger,
@@ -189,7 +209,7 @@ func (s sarin) workerNoStatsWithStatic(
if err := requestGenerator(req); err != nil {
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 {
counter.Add(1)
}
@@ -198,7 +218,7 @@ func (s sarin) workerNoStatsWithStatic(
for range jobs {
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
err := s.hostClients[nextClientIndex()].DoTimeout(req, resp, s.timeout)
if err == nil {
sendRespLog(time.Since(startTime), resp)
}
@@ -210,11 +230,15 @@ func (s sarin) workerDryRunStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64,
sendLog runtimeLogger,
) {
for range jobs {
req.Reset()
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
bindScriptProxy(nextClientIndex())
startTime := time.Now()
if err := requestGenerator(req); err != nil {
s.responses.Add(err.Error(), time.Since(startTime))
@@ -235,7 +259,7 @@ func (s sarin) workerDryRunStatsWithStatic(
sendLog runtimeLogger,
) {
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
for range jobs {
s.responses.Add(err.Error(), 0)
sendLog(runtimeLogLevelError, err.Error())
@@ -254,11 +278,15 @@ func (s sarin) workerDryRunNoStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
nextClientIndex clientIndexGenerator,
bindScriptProxy func(int),
counter *atomic.Uint64,
sendLog runtimeLogger,
) {
for range jobs {
req.Reset()
// Nothing is sent, this only binds a proxy for the scripts' http.* calls.
bindScriptProxy(nextClientIndex())
if err := requestGenerator(req); err != nil {
sendLog(runtimeLogLevelError, err.Error())
}
+10
View File
@@ -91,6 +91,16 @@ func (t *Transformer) Transform(req *RequestData) error {
return nil
}
// SetHTTPDoer sets the doer that the scripts' http.* calls are sent through.
func (t *Transformer) SetHTTPDoer(doer HTTPDoer) {
for _, engine := range t.luaEngines {
engine.SetHTTPDoer(doer)
}
for _, engine := range t.jsEngines {
engine.SetHTTPDoer(doer)
}
}
// Close releases all engine resources.
func (t *Transformer) Close() {
for _, engine := range t.luaEngines {
+133
View File
@@ -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
}
+42 -7
View File
@@ -11,6 +11,10 @@ import (
type JsEngine struct {
runtime *goja.Runtime
transform goja.Callable
jsonParse goja.Callable
// errorConstructor is the built-in Error, captured before the script can replace it.
errorConstructor goja.Value
http httpBridge
}
// NewJsEngine creates a new JavaScript script engine with the given script content.
@@ -29,11 +33,17 @@ type JsEngine struct {
// - types.ScriptExecutionError
func NewJsEngine(scriptContent string) (*JsEngine, error) {
vm := goja.New()
engine := &JsEngine{runtime: vm}
// Register the globals before running the script so its functions can use them
if err := engine.registerHTTP(); err != nil {
return nil, types.NewScriptExecutionError("JavaScript", err)
}
// Execute the script to define the transform function
_, err := vm.RunString(scriptContent)
if err != nil {
return nil, types.NewScriptExecutionError("JavaScript", err)
return nil, types.NewScriptExecutionError("JavaScript", engine.exceptionError(err))
}
// Get the transform function
@@ -47,23 +57,30 @@ func NewJsEngine(scriptContent string) (*JsEngine, error) {
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
}
return &JsEngine{
runtime: vm,
transform: transform,
}, nil
engine.transform = transform
return engine, nil
}
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
func (e *JsEngine) SetHTTPDoer(doer HTTPDoer) {
e.http.doer = doer
}
// Transform executes the JavaScript transform function with the given request data.
// It can return the following errors:
// - types.ScriptExecutionError
func (e *JsEngine) Transform(req *RequestData) error {
e.http.active = true
defer func() { e.http.active = false }()
// Convert RequestData to JavaScript object
reqObj := e.requestDataToObject(req)
// Call transform(req)
result, err := e.transform(goja.Undefined(), reqObj)
if err != nil {
return types.NewScriptExecutionError("JavaScript", err)
return types.NewScriptExecutionError("JavaScript", e.exceptionError(err))
}
// Update RequestData from the returned object
@@ -79,6 +96,8 @@ func (e *JsEngine) Close() {
// goja doesn't have an explicit close method, but we can help GC
e.runtime = nil
e.transform = nil
e.jsonParse = nil
e.errorConstructor = nil
}
// requestDataToObject converts RequestData to a goja Value (JavaScript object).
@@ -190,9 +209,25 @@ func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string
}
result[key] = values
} else {
// Single value - wrap in slice
// Single value, wrap it in a slice
result[key] = []string{v.String()}
}
}
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)
}
+210
View File
@@ -0,0 +1,210 @@
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
})
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
}
}
+29 -33
View File
@@ -11,6 +11,7 @@ import (
type LuaEngine struct {
state *lua.LState
transform *lua.LFunction
http httpBridge
}
// NewLuaEngine creates a new Lua script engine with the given script content.
@@ -29,6 +30,11 @@ type LuaEngine struct {
// - types.ScriptExecutionError
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
L := lua.NewState()
engine := &LuaEngine{state: L}
// Register the globals before running the script so its functions can use them
engine.registerHTTP()
registerLuaJSON(L)
// Execute the script to define the transform function
if err := L.DoString(scriptContent); err != nil {
@@ -42,17 +48,23 @@ func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
L.Close()
return nil, types.ErrScriptTransformMissing
}
engine.transform = transform.(*lua.LFunction)
return &LuaEngine{
state: L,
transform: transform.(*lua.LFunction),
}, nil
return engine, nil
}
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
func (e *LuaEngine) SetHTTPDoer(doer HTTPDoer) {
e.http.doer = doer
}
// Transform executes the Lua transform function with the given request data.
// It can return the following errors:
// - types.ScriptExecutionError
func (e *LuaEngine) Transform(req *RequestData) error {
e.http.active = true
defer func() { e.http.active = false }()
// Convert RequestData to Lua table
reqTable := e.requestDataToTable(req)
@@ -93,39 +105,23 @@ func (e *LuaEngine) requestDataToTable(req *RequestData) *lua.LTable {
t.RawSetString("path", lua.LString(req.Path))
t.RawSetString("body", lua.LString(req.Body))
// Headers (map[string][]string -> table of arrays)
headers := L.NewTable()
for k, values := range req.Headers {
arr := L.NewTable()
t.RawSetString("headers", stringSliceMapToTable(L, req.Headers))
t.RawSetString("params", stringSliceMapToTable(L, req.Params))
t.RawSetString("cookies", stringSliceMapToTable(L, req.Cookies))
return t
}
// stringSliceMapToTable converts a Go map[string][]string to a Lua table of arrays.
func stringSliceMapToTable(state *lua.LState, m map[string][]string) *lua.LTable {
t := state.NewTable()
for k, values := range m {
arr := state.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
headers.RawSetString(k, arr)
t.RawSetString(k, arr)
}
t.RawSetString("headers", headers)
// Params (map[string][]string -> table of arrays)
params := L.NewTable()
for k, values := range req.Params {
arr := L.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
params.RawSetString(k, arr)
}
t.RawSetString("params", params)
// Cookies (map[string][]string -> table of arrays)
cookies := L.NewTable()
for k, values := range req.Cookies {
arr := L.NewTable()
for _, v := range values {
arr.Append(lua.LString(v))
}
cookies.RawSetString(k, arr)
}
t.RawSetString("cookies", cookies)
return t
}
+142
View File
@@ -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
}
+148
View File
@@ -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
}
}
+6 -3
View File
@@ -31,6 +31,9 @@ type Engine interface {
// The script should modify the RequestData and return it.
Transform(req *RequestData) error
// SetHTTPDoer sets the doer that the script's http.* calls are sent through.
SetHTTPDoer(doer HTTPDoer)
// Close releases any resources held by the engine.
Close()
}
@@ -69,7 +72,7 @@ func LoadSource(ctx context.Context, source string, engineType EngineType) (*Sou
switch {
case strings.HasPrefix(source, "@@"):
// Escaped @ - it's an inline script starting with literal @
// Escaped @: an inline script starting with a literal @
content = source[1:] // Remove first @, keep the rest
case strings.HasPrefix(source, "@"):
// File or URL reference
@@ -125,7 +128,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
return err
}
// Try to create an engine - this validates syntax and transform function
// Creating an engine validates the syntax and the transform function
var engine Engine
switch engineType {
case EngineTypeLua:
@@ -140,7 +143,7 @@ func ValidateScript(ctx context.Context, source string, engineType EngineType) e
return err
}
// Clean up the engine - we only needed it for validation
// The engine was only needed for validation
engine.Close()
return nil
}
+82
View File
@@ -318,6 +318,8 @@ func (e ConfigFileReadError) Unwrap() error {
// ======================================== Proxy ========================================
var ErrProxyNoContextDialer = errors.New("SOCKS5 dialer does not support timeouts")
type ProxyUnsupportedSchemeError struct {
Scheme string
}
@@ -401,8 +403,88 @@ var (
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
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 {
Source string
Err error
+9 -9
View File
@@ -1,17 +1,18 @@
{ lib
, buildGoModule
, go_1_26
, rev ? "unknown"
, buildDate ? "unknown"
{
lib,
buildGoModule,
go_1_27,
rev ? "unknown",
buildDate ? "unknown",
}:
(buildGoModule.override { go = go_1_26; }) (finalAttrs: {
(buildGoModule.override { go = go_1_27; }) (finalAttrs: {
pname = "sarin";
version = "1.4.2"; # bump per release
version = "1.4.4"; # bump per release
src = lib.cleanSource ../.;
vendorHash = "sha256-hGrS/drQiBOcqwloi15vqOdiTbweo0Oya+fF9SLM3so=";
vendorHash = "sha256-KUxzFDxYbKvKASMze+f7jxfpCQbs01ezeG6a1VboHxw=";
subPackages = [ "cmd/cli" ];
@@ -29,7 +30,6 @@
ldflags+=("-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)'")
'';
# cmd/cli produces a binary named "cli"; rename it to "sarin".
postInstall = ''
mv $out/bin/cli $out/bin/sarin
'';