9 Commits

Author SHA1 Message Date
6a713ef241 Build request data through RequestData struct instead of fasthttp directly
Refactor request generators to populate a script.RequestData intermediate struct, then apply it to fasthttp.Request in one step. This eliminates the round-trip conversion (fasthttp → RequestData → fasthttp) when scripts are enabled. Remove the URL field from RequestData and the now-unused fasthttp conversion functions from chain.go.
2026-02-08 03:52:39 +04:00
6dafc082ed Introduce structured error types and bump Go/linter versions
Replace ad-hoc fmt.Errorf/errors.New calls with typed error structs across config, sarin, and script packages to enable type-based error handling. Add script-specific error handlers in CLI entry point. Fix variable shadowing bug in Worker for scriptTransformer. Bump Go to 1.25.7 and golangci-lint to v2.8.0.
2026-02-08 02:54:54 +04:00
e83eacf380 Merge branch 'main' of https://github.com/aykhans/sarin into feat/add-scripting 2026-02-05 00:20:53 +04:00
c2ba1844ab Merge pull request #168 from aykhans/dependabot/go_modules/github.com/charmbracelet/bubbles-0.21.1
Bump github.com/charmbracelet/bubbles from 0.21.0 to 0.21.1
2026-02-04 22:23:29 +04:00
dependabot[bot]
054e5fd253 Bump github.com/charmbracelet/bubbles from 0.21.0 to 0.21.1
Bumps [github.com/charmbracelet/bubbles](https://github.com/charmbracelet/bubbles) from 0.21.0 to 0.21.1.
- [Release notes](https://github.com/charmbracelet/bubbles/releases)
- [Commits](https://github.com/charmbracelet/bubbles/compare/v0.21.0...v0.21.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-04 00:14:30 +00:00
533ced4b54 add scripting js/lua 2026-01-28 14:21:08 +04:00
c3ea3a34ad Merge pull request #167 from aykhans/add-badges
Add pkg.go.dev, Go Report Card, and license badges
2026-01-26 15:36:48 +04:00
aykhans
c02a079d2a Use vanity URL for Go Report Card badge 2026-01-26 11:35:55 +00:00
aykhans
f78942bfb6 Add pkg.go.dev, Go Report Card, and license badges 2026-01-26 11:32:20 +00:00
24 changed files with 1401 additions and 145 deletions

View File

@@ -16,8 +16,8 @@ jobs:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: 1.25.5
go-version: 1.25.7
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.7.2
version: v2.8.0

View File

@@ -35,7 +35,7 @@ jobs:
run: |
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "GO_VERSION=1.25.5" >> $GITHUB_ENV
echo "GO_VERSION=1.25.7" >> $GITHUB_ENV
- name: Set up Go
if: github.event_name == 'release' || inputs.build_binaries

View File

@@ -1,4 +1,4 @@
ARG GO_VERSION=1.25.5
ARG GO_VERSION=1.25.7
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder

View File

@@ -2,6 +2,10 @@
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
[![Go Reference](https://pkg.go.dev/badge/go.aykhans.me/sarin.svg)](https://pkg.go.dev/go.aykhans.me/sarin)
[![Go Report Card](https://goreportcard.com/badge/go.aykhans.me/sarin)](https://goreportcard.com/report/go.aykhans.me/sarin)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
</div>
![Demo](docs/static/demo.gif)

View File

@@ -3,7 +3,7 @@ version: "3"
vars:
BIN_DIR: ./bin
GOLANGCI_LINT_VERSION: v2.7.2
GOLANGCI_LINT_VERSION: v2.8.0
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
tasks:

View File

@@ -53,6 +53,7 @@ func main() {
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
*combinedConfig.Output != config.ConfigOutputTypeNone,
*combinedConfig.DryRun,
combinedConfig.Lua, combinedConfig.Js,
)
_ = utilsErr.MustHandle(err,
utilsErr.OnType(func(err types.ProxyDialError) error {
@@ -60,6 +61,16 @@ func main() {
os.Exit(1)
return nil
}),
utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error {
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
os.Exit(1)
return nil
}),
utilsErr.OnType(func(err types.ScriptLoadError) error {
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
os.Exit(1)
return nil
}),
)
srn.Start(ctx)

16
go.mod
View File

@@ -1,16 +1,18 @@
module go.aykhans.me/sarin
go 1.25.5
go 1.25.7
require (
github.com/brianvoe/gofakeit/v7 v7.14.0
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbles v0.21.1
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/term v0.2.2
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/joho/godotenv v1.5.1
github.com/valyala/fasthttp v1.69.0
github.com/yuin/gopher-lua v1.1.1
go.aykhans.me/utils v1.0.7
go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/net v0.49.0
@@ -23,15 +25,17 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/ansi v0.11.5 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
github.com/clipperhouse/displaywidth v0.6.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect

36
go.sum
View File

@@ -1,3 +1,5 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
@@ -8,14 +10,14 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0=
github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
@@ -26,28 +28,34 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4=
github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/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=
@@ -95,6 +103,8 @@ github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
@@ -111,5 +121,7 @@ golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -17,20 +17,7 @@ const cliUsageText = `Usage:
sarin [flags]
Simple usage:
sarin -U https://example.com -d 1m
Usage with all flags:
sarin -s -q -z -o json -f ./config.yaml -c 50 -r 100_000 -d 2m30s \
-U https://example.com \
-M POST \
-V "sharedUUID={{ fakeit_UUID }}" \
-B '{"product": "car"}' \
-P "id={{ .Values.sharedUUID }}" \
-H "User-Agent: {{ fakeit_UserAgent }}" -H "Accept: */*" \
-C "token={{ .Values.sharedUUID }}" \
-X "http://proxy.example.com" \
-T 3s \
-I
sarin -U https://example.com -r 1
Flags:
General Config:
@@ -55,7 +42,9 @@ Flags:
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
-V, -values []string List of values for templating (e.g. "key1=value1")
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)`
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)
-lua []string Lua script for request transformation (inline or @file/@url)
-js []string JavaScript script for request transformation (inline or @file/@url)`
var _ IParser = ConfigCLIParser{}
@@ -106,16 +95,18 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
dryRun bool
// Request config
urlInput string
methods = stringSliceArg{}
bodies = stringSliceArg{}
params = stringSliceArg{}
headers = stringSliceArg{}
cookies = stringSliceArg{}
proxies = stringSliceArg{}
values = stringSliceArg{}
timeout time.Duration
insecure bool
urlInput string
methods = stringSliceArg{}
bodies = stringSliceArg{}
params = stringSliceArg{}
headers = stringSliceArg{}
cookies = stringSliceArg{}
proxies = stringSliceArg{}
values = stringSliceArg{}
timeout time.Duration
insecure bool
luaScripts = stringSliceArg{}
jsScripts = stringSliceArg{}
)
{
@@ -177,6 +168,10 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
flagSet.Var(&luaScripts, "lua", "Lua script for request transformation (inline or @file/@url)")
flagSet.Var(&jsScripts, "js", "JavaScript script for request transformation (inline or @file/@url)")
}
// Parse the specific arguments provided to the parser, skipping the program name.
@@ -259,6 +254,10 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
config.Timeout = common.ToPtr(timeout)
case "insecure", "I":
config.Insecure = common.ToPtr(insecure)
case "lua":
config.Lua = append(config.Lua, luaScripts...)
case "js":
config.Js = append(config.Js, jsScripts...)
}
})

View File

@@ -1,6 +1,7 @@
package config
import (
"context"
"errors"
"fmt"
"net/url"
@@ -16,6 +17,7 @@ import (
"github.com/charmbracelet/glamour/styles"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/term"
"go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types"
"go.aykhans.me/sarin/internal/version"
"go.aykhans.me/utils/common"
@@ -87,6 +89,8 @@ type Config struct {
Bodies []string `yaml:"bodies,omitempty"`
Proxies types.Proxies `yaml:"proxies,omitempty"`
Values []string `yaml:"values,omitempty"`
Lua []string `yaml:"lua,omitempty"`
Js []string `yaml:"js,omitempty"`
}
func NewConfig() *Config {
@@ -219,6 +223,8 @@ func (config Config) MarshalYAML() (any, error) {
}
addStringSlice(content, "values", config.Values, false)
addStringSlice(content, "lua", config.Lua, false)
addStringSlice(content, "js", config.Js, false)
return root, nil
}
@@ -323,6 +329,12 @@ func (config *Config) Merge(newConfig *Config) {
if len(newConfig.Values) != 0 {
config.Values = append(config.Values, newConfig.Values...)
}
if len(newConfig.Lua) != 0 {
config.Lua = append(config.Lua, newConfig.Lua...)
}
if len(newConfig.Js) != 0 {
config.Js = append(config.Js, newConfig.Js...)
}
}
func (config *Config) SetDefaults() {
@@ -465,6 +477,44 @@ func (config Config) Validate() error {
}
}
// Create a context with timeout for script validation (loading from URLs)
scriptCtx, scriptCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer scriptCancel()
for i, scriptSrc := range config.Lua {
if err := validateScriptSource(scriptSrc); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(fmt.Sprintf("Lua[%d]", i), scriptSrc, err),
)
continue
}
// Validate script syntax
if err := script.ValidateScript(scriptCtx, scriptSrc, script.EngineTypeLua); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(fmt.Sprintf("Lua[%d]", i), scriptSrc, err),
)
}
}
for i, scriptSrc := range config.Js {
if err := validateScriptSource(scriptSrc); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(fmt.Sprintf("Js[%d]", i), scriptSrc, err),
)
continue
}
// Validate script syntax
if err := script.ValidateScript(scriptCtx, scriptSrc, script.EngineTypeJavaScript); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(fmt.Sprintf("Js[%d]", i), scriptSrc, err),
)
}
}
templateErrors := ValidateTemplates(&config)
validationErrors = append(validationErrors, templateErrors...)
@@ -582,6 +632,57 @@ func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error)
return fileConfig, nil
}
// validateScriptSource validates a script source string.
// Scripts can be:
// - Inline script: any string not starting with "@"
// - Escaped "@": strings starting with "@@" (literal "@" at start)
// - File reference: "@/path/to/file" or "@./relative/path"
// - URL reference: "@http://..." or "@https://..."
//
// It can return the following errors:
// - types.ErrScriptEmpty
// - types.ErrScriptSourceEmpty
// - types.ErrScriptURLNoHost
// - types.URLParseError
func validateScriptSource(script string) error {
// Empty script is invalid
if script == "" {
return types.ErrScriptEmpty
}
// Not a file/URL reference - it's an inline script
if !strings.HasPrefix(script, "@") {
return nil
}
// Escaped @ - it's an inline script starting with literal @
if strings.HasPrefix(script, "@@") {
return nil
}
// It's a file or URL reference - validate the source
source := script[1:] // Remove the @ prefix
if source == "" {
return types.ErrScriptSourceEmpty
}
// Check if it's a http(s) URL
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
parsedURL, err := url.Parse(source)
if err != nil {
return types.NewURLParseError(source, err)
}
if parsedURL.Host == "" {
return types.ErrScriptURLNoHost
}
return nil
}
// It's a file path - basic validation (not empty, checked above)
return nil
}
func printParseErrors(parserName string, errors ...types.FieldParseError) {
for _, fieldErr := range errors {
if fieldErr.Value == "" {

View File

@@ -216,6 +216,14 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
config.Values = []string{values}
}
if lua := parser.getEnv("LUA"); lua != "" {
config.Lua = []string{lua}
}
if js := parser.getEnv("JS"); js != "" {
config.Js = []string{js}
}
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}

View File

@@ -49,6 +49,10 @@ func (parser ConfigFileParser) Parse() (*Config, error) {
}
// fetchFile retrieves file contents from a local path or HTTP/HTTPS URL.
// It can return the following errors:
// - types.FileReadError
// - types.HTTPFetchError
// - types.HTTPStatusError
func fetchFile(ctx context.Context, src string) ([]byte, error) {
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
return fetchHTTP(ctx, src)
@@ -57,25 +61,28 @@ func fetchFile(ctx context.Context, src string) ([]byte, error) {
}
// fetchHTTP downloads file contents from an HTTP/HTTPS URL.
// It can return the following errors:
// - types.HTTPFetchError
// - types.HTTPStatusError
func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
return nil, types.NewHTTPFetchError(url, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch file: %w", err)
return nil, types.NewHTTPFetchError(url, err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch file: HTTP %d %s", resp.StatusCode, resp.Status)
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
return nil, types.NewHTTPFetchError(url, err)
}
return data, nil
@@ -83,19 +90,21 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
// fetchLocal reads file contents from the local filesystem.
// It resolves relative paths from the current working directory.
// It can return the following errors:
// - types.FileReadError
func fetchLocal(src string) ([]byte, error) {
path := src
if !filepath.IsAbs(src) {
pwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get working directory: %w", err)
return nil, types.NewFileReadError(src, err)
}
path = filepath.Join(pwd, src)
}
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
return nil, types.NewFileReadError(path, err)
}
return data, nil
@@ -202,6 +211,8 @@ type configYAML struct {
Bodies stringOrSliceField `yaml:"body"`
Proxies stringOrSliceField `yaml:"proxy"`
Values stringOrSliceField `yaml:"values"`
Lua stringOrSliceField `yaml:"lua"`
Js stringOrSliceField `yaml:"js"`
}
// ParseYAML parses YAML config file arguments into a Config object.
@@ -246,6 +257,8 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
}
config.Bodies = append(config.Bodies, parsedData.Bodies...)
config.Values = append(config.Values, parsedData.Values...)
config.Lua = append(config.Lua, parsedData.Lua...)
config.Js = append(config.Js, parsedData.Js...)
if len(parsedData.ConfigFiles) > 0 {
for _, configFile := range parsedData.ConfigFiles {

View File

@@ -8,6 +8,8 @@ import (
"go.aykhans.me/sarin/internal/types"
)
// It can return the following errors:
// - types.TemplateParseError
func validateTemplateString(value string, funcMap template.FuncMap) error {
if value == "" {
return nil
@@ -15,7 +17,7 @@ func validateTemplateString(value string, funcMap template.FuncMap) error {
_, err := template.New("").Funcs(funcMap).Parse(value)
if err != nil {
return fmt.Errorf("template parse error: %w", err)
return types.NewTemplateParseError(err)
}
return nil

View File

@@ -5,7 +5,6 @@ import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"math"
"net"
"net/http"
@@ -95,6 +94,9 @@ func NewHostClients(
return []*fasthttp.HostClient{client}, nil
}
// NewProxyDialFunc creates a dial function for the given proxy URL.
// It can return the following errors:
// - types.ProxyUnsupportedSchemeError
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
var (
dialer fasthttp.DialFunc
@@ -117,16 +119,14 @@ func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Durat
case "https":
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
default:
return nil, errors.New("unsupported proxy scheme")
}
if dialer == nil {
return nil, errors.New("internal error: proxy dialer is nil")
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
}
return dialer, nil
}
// The returned dial function can return the following errors:
// - types.ProxyDialError
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
netDialer := &net.Dialer{}
@@ -147,12 +147,18 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
return nil, err
}
proxyStr := proxyURL.String()
// Assert to ContextDialer for timeout support
contextDialer, ok := socksDialer.(proxy.ContextDialer)
if !ok {
// Fallback without timeout (should not happen with net.Dialer)
return func(addr string) (net.Conn, error) {
return socksDialer.Dial("tcp", addr)
conn, err := socksDialer.Dial("tcp", addr)
if err != nil {
return nil, types.NewProxyDialError(proxyStr, err)
}
return conn, nil
}, nil
}
@@ -163,7 +169,7 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
if resolveLocally {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
return nil, types.NewProxyDialError(proxyStr, err)
}
// Cap DNS resolution to half the timeout to reserve time for dial
@@ -171,10 +177,10 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
dnsCancel()
if err != nil {
return nil, err
return nil, types.NewProxyDialError(proxyStr, err)
}
if len(ips) == 0 {
return nil, errors.New("no IP addresses found for host: " + host)
return nil, types.NewProxyDialError(proxyStr, types.NewProxyResolveError(host))
}
// Use the first resolved IP
@@ -184,16 +190,22 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
// Use remaining time for dial
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, context.DeadlineExceeded
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
}
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
defer dialCancel()
return contextDialer.DialContext(dialCtx, "tcp", addr)
conn, err := contextDialer.DialContext(dialCtx, "tcp", addr)
if err != nil {
return nil, types.NewProxyDialError(proxyStr, err)
}
return conn, nil
}, nil
}
// The returned dial function can return the following errors:
// - types.ProxyDialError
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
proxyAddr := proxyURL.Host
if proxyURL.Port() == "" {
@@ -209,24 +221,26 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
proxyAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
}
proxyStr := proxyURL.String()
return func(addr string) (net.Conn, error) {
// Establish TCP connection to proxy with timeout
start := time.Now()
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
if err != nil {
return nil, err
return nil, types.NewProxyDialError(proxyStr, err)
}
remaining := timeout - time.Since(start)
if remaining <= 0 {
conn.Close() //nolint:errcheck,gosec
return nil, context.DeadlineExceeded
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
}
// Set deadline for the TLS handshake and CONNECT request
if err := conn.SetDeadline(time.Now().Add(remaining)); err != nil {
conn.Close() //nolint:errcheck,gosec
return nil, err
return nil, types.NewProxyDialError(proxyStr, err)
}
// Upgrade to TLS
@@ -235,7 +249,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
})
if err := tlsConn.Handshake(); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, err
return nil, types.NewProxyDialError(proxyStr, err)
}
// Build and send CONNECT request
@@ -251,7 +265,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
if err := connectReq.Write(tlsConn); err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, err
return nil, types.NewProxyDialError(proxyStr, err)
}
// Read response using buffered reader, but return wrapped connection
@@ -260,19 +274,19 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
resp, err := http.ReadResponse(bufReader, connectReq)
if err != nil {
tlsConn.Close() //nolint:errcheck,gosec
return nil, err
return nil, types.NewProxyDialError(proxyStr, err)
}
resp.Body.Close() //nolint:errcheck,gosec
if resp.StatusCode != http.StatusOK {
tlsConn.Close() //nolint:errcheck,gosec
return nil, errors.New("proxy CONNECT failed: " + resp.Status)
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
return nil, err
return nil, types.NewProxyDialError(proxyStr, err)
}
// Return wrapped connection that uses the buffered reader

View File

@@ -1,7 +1,6 @@
package sarin
import (
"fmt"
"io"
"net/http"
"os"
@@ -10,6 +9,8 @@ import (
"strings"
"sync"
"time"
"go.aykhans.me/sarin/internal/types"
)
// CachedFile holds the cached content and metadata of a file.
@@ -31,6 +32,10 @@ func NewFileCache(requestTimeout time.Duration) *FileCache {
// GetOrLoad retrieves a file from cache or loads it using the provided source.
// The source can be a local file path or an HTTP/HTTPS URL.
// It can return the following errors:
// - types.FileReadError
// - types.HTTPFetchError
// - types.HTTPStatusError
func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
if val, ok := fc.cache.Load(source); ok {
return val.(*CachedFile), nil
@@ -59,14 +64,21 @@ func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
return actual.(*CachedFile), nil
}
// readLocalFile reads a file from the local filesystem and returns its content and filename.
// It can return the following errors:
// - types.FileReadError
func (fc *FileCache) readLocalFile(filePath string) ([]byte, string, error) {
content, err := os.ReadFile(filePath) //nolint:gosec
if err != nil {
return nil, "", fmt.Errorf("failed to read file %s: %w", filePath, err)
return nil, "", types.NewFileReadError(filePath, err)
}
return content, filepath.Base(filePath), nil
}
// fetchURL downloads file contents from an HTTP/HTTPS URL.
// It can return the following errors:
// - types.HTTPFetchError
// - types.HTTPStatusError
func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
client := &http.Client{
Timeout: fc.requestTimeout,
@@ -74,17 +86,17 @@ func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
resp, err := client.Get(url)
if err != nil {
return nil, "", fmt.Errorf("failed to fetch URL %s: %w", url, err)
return nil, "", types.NewHTTPFetchError(url, err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("failed to fetch URL %s: HTTP %d", url, resp.StatusCode)
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("failed to read response body from %s: %w", url, err)
return nil, "", types.NewHTTPFetchError(url, err)
}
// Extract filename from URL path

View File

@@ -2,7 +2,6 @@ package sarin
import (
"bytes"
"fmt"
"maps"
"math/rand/v2"
"net/url"
@@ -11,13 +10,14 @@ import (
"github.com/joho/godotenv"
"github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types"
utilsSlice "go.aykhans.me/utils/slice"
)
type RequestGenerator func(*fasthttp.Request) error
type RequestGeneratorWithData func(*fasthttp.Request, any) error
type requestDataGenerator func(*script.RequestData, any) error
type valuesData struct {
Values map[string]string
@@ -26,6 +26,9 @@ type valuesData struct {
// NewRequestGenerator creates a new RequestGenerator function that generates HTTP requests
// with the specified configuration. The returned RequestGenerator is NOT safe for concurrent
// use by multiple goroutines.
//
// Note: Scripts must be validated before calling this function (e.g., in NewSarin).
// The caller is responsible for managing the scriptTransformer lifecycle.
func NewRequestGenerator(
methods []string,
requestURL *url.URL,
@@ -35,6 +38,7 @@ func NewRequestGenerator(
bodies []string,
values []string,
fileCache *FileCache,
scriptTransformer *script.Transformer,
) (RequestGenerator, bool) {
randSource := NewDefaultRandSource()
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
@@ -53,13 +57,24 @@ func NewRequestGenerator(
valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)
hasScripts := scriptTransformer != nil && !scriptTransformer.IsEmpty()
host := requestURL.Host
scheme := requestURL.Scheme
reqData := &script.RequestData{
Headers: make(map[string][]string),
Params: make(map[string][]string),
Cookies: make(map[string][]string),
}
var (
data valuesData
path string
err error
)
return func(req *fasthttp.Request) error {
req.Header.SetHost(requestURL.Host)
resetRequestData(reqData)
data, err = valuesGenerator()
if err != nil {
@@ -70,87 +85,135 @@ func NewRequestGenerator(
if err != nil {
return err
}
req.SetRequestURI(path)
reqData.Path = path
if err = methodGenerator(req, data); err != nil {
if err = methodGenerator(reqData, data); err != nil {
return err
}
bodyTemplateFuncMapData.ClearFormDataContenType()
if err = bodyGenerator(req, data); err != nil {
if err = bodyGenerator(reqData, data); err != nil {
return err
}
if err = headersGenerator(req, data); err != nil {
if err = headersGenerator(reqData, data); err != nil {
return err
}
if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
req.Header.Add("Content-Type", bodyTemplateFuncMapData.GetFormDataContenType())
reqData.Headers["Content-Type"] = append(reqData.Headers["Content-Type"], bodyTemplateFuncMapData.GetFormDataContenType())
}
if err = paramsGenerator(req, data); err != nil {
if err = paramsGenerator(reqData, data); err != nil {
return err
}
if err = cookiesGenerator(req, data); err != nil {
if err = cookiesGenerator(reqData, data); err != nil {
return err
}
if requestURL.Scheme == "https" {
req.URI().SetScheme("https")
if hasScripts {
if err = scriptTransformer.Transform(reqData); err != nil {
return err
}
}
applyRequestDataToFastHTTP(reqData, req, host, scheme)
return nil
}, isPathGeneratorDynamic ||
isMethodGeneratorDynamic ||
isParamsGeneratorDynamic ||
isHeadersGeneratorDynamic ||
isCookiesGeneratorDynamic ||
isBodyGeneratorDynamic
isBodyGeneratorDynamic ||
hasScripts
}
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
func resetRequestData(reqData *script.RequestData) {
reqData.Method = ""
reqData.Path = ""
reqData.Body = ""
clear(reqData.Headers)
clear(reqData.Params)
clear(reqData.Cookies)
}
func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) {
req.Header.SetHost(host)
req.SetRequestURI(reqData.Path)
req.Header.SetMethod(reqData.Method)
req.SetBody([]byte(reqData.Body))
for k, values := range reqData.Headers {
for _, v := range values {
req.Header.Add(k, v)
}
}
for k, values := range reqData.Params {
for _, v := range values {
req.URI().QueryArgs().Add(k, v)
}
}
if len(reqData.Cookies) > 0 {
cookieStrings := make([]string, 0, len(reqData.Cookies))
for k, values := range reqData.Cookies {
for _, v := range values {
cookieStrings = append(cookieStrings, k+"="+v)
}
}
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
}
if scheme == "https" {
req.URI().SetScheme("https")
}
}
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, templateFunctions)
var (
method string
err error
)
return func(req *fasthttp.Request, data any) error {
return func(reqData *script.RequestData, data any) error {
method, err = methodGenerator()(data)
if err != nil {
return err
}
req.Header.SetMethod(method)
reqData.Method = method
return nil
}, isDynamic
}
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, templateFunctions)
var (
body string
err error
)
return func(req *fasthttp.Request, data any) error {
return func(reqData *script.RequestData, data any) error {
body, err = bodyGenerator()(data)
if err != nil {
return err
}
req.SetBody([]byte(body))
reqData.Body = body
return nil
}, isDynamic
}
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, params, templateFunctions)
var (
key, value string
err error
)
return func(req *fasthttp.Request, data any) error {
return func(reqData *script.RequestData, data any) error {
for _, gen := range generators {
key, err = gen.Key(data)
if err != nil {
@@ -162,20 +225,20 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateF
return err
}
req.URI().QueryArgs().Add(key, value)
reqData.Params[key] = append(reqData.Params[key], value)
}
return nil
}, isDynamic
}
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, headers, templateFunctions)
var (
key, value string
err error
)
return func(req *fasthttp.Request, data any) error {
return func(reqData *script.RequestData, data any) error {
for _, gen := range generators {
key, err = gen.Key(data)
if err != nil {
@@ -187,41 +250,33 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templa
return err
}
req.Header.Add(key, value)
reqData.Headers[key] = append(reqData.Headers[key], value)
}
return nil
}, isDynamic
}
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, templateFunctions)
var (
key, value string
err error
)
if len(generators) > 0 {
return func(req *fasthttp.Request, data any) error {
cookieStrings := make([]string, 0, len(generators))
for _, gen := range generators {
key, err = gen.Key(data)
if err != nil {
return err
}
value, err = gen.Value()(data)
if err != nil {
return err
}
cookieStrings = append(cookieStrings, key+"="+value)
return func(reqData *script.RequestData, data any) error {
for _, gen := range generators {
key, err = gen.Key(data)
if err != nil {
return err
}
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
return nil
}, isDynamic
}
return func(req *fasthttp.Request, data any) error {
value, err = gen.Value()(data)
if err != nil {
return err
}
reqData.Cookies[key] = append(reqData.Cookies[key], value)
}
return nil
}, isDynamic
}
@@ -243,12 +298,12 @@ func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap)
for _, generator := range generators {
rendered, err = generator(nil)
if err != nil {
return valuesData{}, fmt.Errorf("values rendering: %w", err)
return valuesData{}, types.NewTemplateRenderError(err)
}
data, err = godotenv.Unmarshal(rendered)
if err != nil {
return valuesData{}, fmt.Errorf("values rendering: %w", err)
return valuesData{}, types.NewTemplateRenderError(err)
}
maps.Copy(result, data)
@@ -265,7 +320,7 @@ func createTemplateFunc(value string, templateFunctions template.FuncMap) (func(
return func(data any) (string, error) {
var buf bytes.Buffer
if err = tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("template rendering: %w", err)
return "", types.NewTemplateRenderError(err)
}
return buf.String(), nil
}, true

View File

@@ -14,6 +14,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/valyala/fasthttp"
"go.aykhans.me/sarin/internal/script"
"go.aykhans.me/sarin/internal/types"
)
@@ -52,11 +53,14 @@ type sarin struct {
hostClients []*fasthttp.HostClient
responses *SarinResponseData
fileCache *FileCache
scriptChain *script.Chain
}
// NewSarin creates a new sarin instance for load testing.
// It can return the following errors:
// - types.ProxyDialError
// - types.ProxyDialError
// - types.ErrScriptEmpty
// - types.ScriptLoadError
func NewSarin(
ctx context.Context,
methods []string,
@@ -75,6 +79,8 @@ func NewSarin(
values []string,
collectStats bool,
dryRun bool,
luaScripts []string,
jsScripts []string,
) (*sarin, error) {
if workers == 0 {
workers = 1
@@ -85,6 +91,19 @@ func NewSarin(
return nil, err
}
// Load script sources
luaSources, err := script.LoadSources(ctx, luaScripts, script.EngineTypeLua)
if err != nil {
return nil, err
}
jsSources, err := script.LoadSources(ctx, jsScripts, script.EngineTypeJavaScript)
if err != nil {
return nil, err
}
scriptChain := script.NewChain(luaSources, jsSources)
srn := &sarin{
workers: workers,
requestURL: requestURL,
@@ -103,6 +122,7 @@ func NewSarin(
dryRun: dryRun,
hostClients: hostClients,
fileCache: NewFileCache(time.Second * 10),
scriptChain: scriptChain,
}
if collectStats {
@@ -193,7 +213,21 @@ func (q sarin) Worker(
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
requestGenerator, isDynamic := NewRequestGenerator(q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache)
// Create script transformer for this worker (engines are not thread-safe)
// Scripts are pre-validated in NewSarin, so this should not fail
var scriptTransformer *script.Transformer
if !q.scriptChain.IsEmpty() {
var err error
scriptTransformer, err = q.scriptChain.NewTransformer()
if err != nil {
panic(err)
}
defer scriptTransformer.Close()
}
requestGenerator, isDynamic := NewRequestGenerator(
q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache, scriptTransformer,
)
if q.dryRun {
switch {

View File

@@ -3,7 +3,6 @@ package sarin
import (
"bytes"
"encoding/base64"
"errors"
"math/rand/v2"
"mime/multipart"
"strings"
@@ -12,6 +11,7 @@ import (
"time"
"github.com/brianvoe/gofakeit/v7"
"go.aykhans.me/sarin/internal/types"
)
func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) template.FuncMap {
@@ -90,7 +90,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
// {{ file_Base64 "https://example.com/image.png" }}
"file_Base64": func(source string) (string, error) {
if fileCache == nil {
return "", errors.New("file cache is not initialized")
return "", types.ErrFileCacheNotInitialized
}
cached, err := fileCache.GetOrLoad(source)
if err != nil {
@@ -582,7 +582,7 @@ func NewDefaultBodyTemplateFuncMap(
// {{ body_FormData "name" "John" "avatar" "@/path/to/photo.jpg" "doc" "@https://example.com/file.pdf" }}
funcMap["body_FormData"] = func(pairs ...string) (string, error) {
if len(pairs)%2 != 0 {
return "", errors.New("body_FormData requires an even number of arguments (key-value pairs)")
return "", types.ErrFormDataOddArgs
}
var multipartData bytes.Buffer
@@ -602,7 +602,7 @@ func NewDefaultBodyTemplateFuncMap(
case strings.HasPrefix(val, "@"):
// File (local path or remote URL)
if fileCache == nil {
return "", errors.New("file cache is not initialized")
return "", types.ErrFileCacheNotInitialized
}
source := val[1:]
cached, err := fileCache.GetOrLoad(source)

107
internal/script/chain.go Normal file
View File

@@ -0,0 +1,107 @@
package script
import (
"go.aykhans.me/sarin/internal/types"
)
// Chain holds the loaded script sources and can create engine instances.
// The sources are loaded once, but engines are created per-worker since they're not thread-safe.
type Chain struct {
luaSources []*Source
jsSources []*Source
}
// NewChain creates a new script chain from loaded sources.
// Lua scripts run first, then JavaScript scripts, in the order provided.
func NewChain(luaSources, jsSources []*Source) *Chain {
return &Chain{
luaSources: luaSources,
jsSources: jsSources,
}
}
// IsEmpty returns true if there are no scripts to execute.
func (c *Chain) IsEmpty() bool {
return len(c.luaSources) == 0 && len(c.jsSources) == 0
}
// Transformer holds instantiated script engines for a single worker.
// It is NOT safe for concurrent use.
type Transformer struct {
luaEngines []*LuaEngine
jsEngines []*JsEngine
}
// NewTransformer creates engine instances from the chain's sources.
// Call this once per worker goroutine.
// It can return the following errors:
// - types.ScriptChainError
func (c *Chain) NewTransformer() (*Transformer, error) {
if c.IsEmpty() {
return &Transformer{}, nil
}
t := &Transformer{
luaEngines: make([]*LuaEngine, 0, len(c.luaSources)),
jsEngines: make([]*JsEngine, 0, len(c.jsSources)),
}
// Create Lua engines
for i, src := range c.luaSources {
engine, err := NewLuaEngine(src.Content)
if err != nil {
t.Close() // Clean up already created engines
return nil, types.NewScriptChainError("lua", i, err)
}
t.luaEngines = append(t.luaEngines, engine)
}
// Create JS engines
for i, src := range c.jsSources {
engine, err := NewJsEngine(src.Content)
if err != nil {
t.Close() // Clean up already created engines
return nil, types.NewScriptChainError("js", i, err)
}
t.jsEngines = append(t.jsEngines, engine)
}
return t, nil
}
// Transform applies all scripts to the request data.
// Lua scripts run first, then JavaScript scripts.
// It can return the following errors:
// - types.ScriptChainError
func (t *Transformer) Transform(req *RequestData) error {
// Run Lua scripts
for i, engine := range t.luaEngines {
if err := engine.Transform(req); err != nil {
return types.NewScriptChainError("lua", i, err)
}
}
// Run JS scripts
for i, engine := range t.jsEngines {
if err := engine.Transform(req); err != nil {
return types.NewScriptChainError("js", i, err)
}
}
return nil
}
// Close releases all engine resources.
func (t *Transformer) Close() {
for _, engine := range t.luaEngines {
engine.Close()
}
for _, engine := range t.jsEngines {
engine.Close()
}
}
// IsEmpty returns true if there are no engines.
func (t *Transformer) IsEmpty() bool {
return len(t.luaEngines) == 0 && len(t.jsEngines) == 0
}

198
internal/script/js.go Normal file
View File

@@ -0,0 +1,198 @@
package script
import (
"errors"
"github.com/dop251/goja"
"go.aykhans.me/sarin/internal/types"
)
// JsEngine implements the Engine interface using goja (JavaScript).
type JsEngine struct {
runtime *goja.Runtime
transform goja.Callable
}
// NewJsEngine creates a new JavaScript script engine with the given script content.
// The script must define a global `transform` function that takes a request object
// and returns the modified request object.
//
// Example JavaScript script:
//
// function transform(req) {
// req.headers["X-Custom"] = ["value"];
// return req;
// }
//
// It can return the following errors:
// - types.ErrScriptTransformMissing
// - types.ScriptExecutionError
func NewJsEngine(scriptContent string) (*JsEngine, error) {
vm := goja.New()
// Execute the script to define the transform function
_, err := vm.RunString(scriptContent)
if err != nil {
return nil, types.NewScriptExecutionError("JavaScript", err)
}
// Get the transform function
transformVal := vm.Get("transform")
if transformVal == nil || goja.IsUndefined(transformVal) || goja.IsNull(transformVal) {
return nil, types.ErrScriptTransformMissing
}
transform, ok := goja.AssertFunction(transformVal)
if !ok {
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
}
return &JsEngine{
runtime: vm,
transform: transform,
}, nil
}
// 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 {
// 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)
}
// Update RequestData from the returned object
if err := e.objectToRequestData(result, req); err != nil {
return types.NewScriptExecutionError("JavaScript", err)
}
return nil
}
// Close releases the JavaScript runtime resources.
func (e *JsEngine) Close() {
// goja doesn't have an explicit close method, but we can help GC
e.runtime = nil
e.transform = nil
}
// requestDataToObject converts RequestData to a goja Value (JavaScript object).
func (e *JsEngine) requestDataToObject(req *RequestData) goja.Value {
obj := e.runtime.NewObject()
_ = obj.Set("method", req.Method)
_ = obj.Set("path", req.Path)
_ = obj.Set("body", req.Body)
// Headers (map[string][]string -> object of arrays)
headers := e.runtime.NewObject()
for k, values := range req.Headers {
_ = headers.Set(k, e.stringSliceToArray(values))
}
_ = obj.Set("headers", headers)
// Params (map[string][]string -> object of arrays)
params := e.runtime.NewObject()
for k, values := range req.Params {
_ = params.Set(k, e.stringSliceToArray(values))
}
_ = obj.Set("params", params)
// Cookies (map[string][]string -> object of arrays)
cookies := e.runtime.NewObject()
for k, values := range req.Cookies {
_ = cookies.Set(k, e.stringSliceToArray(values))
}
_ = obj.Set("cookies", cookies)
return obj
}
// objectToRequestData updates RequestData from a JavaScript object.
func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error {
if val == nil || goja.IsUndefined(val) || goja.IsNull(val) {
return types.ErrScriptTransformReturnObject
}
obj := val.ToObject(e.runtime)
if obj == nil {
return types.ErrScriptTransformReturnObject
}
// Method
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) {
req.Method = v.String()
}
// Path
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) {
req.Path = v.String()
}
// Body
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) {
req.Body = v.String()
}
// Headers
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Headers = e.objectToStringSliceMap(v.ToObject(e.runtime))
}
// Params
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Params = e.objectToStringSliceMap(v.ToObject(e.runtime))
}
// Cookies
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
req.Cookies = e.objectToStringSliceMap(v.ToObject(e.runtime))
}
return nil
}
// stringSliceToArray converts a Go []string to a JavaScript array.
func (e *JsEngine) stringSliceToArray(values []string) *goja.Object {
ifaces := make([]any, len(values))
for i, v := range values {
ifaces[i] = v
}
return e.runtime.NewArray(ifaces...)
}
// objectToStringSliceMap converts a JavaScript object to a Go map[string][]string.
// Supports both single string values and array values.
func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string {
if obj == nil {
return make(map[string][]string)
}
result := make(map[string][]string)
for _, key := range obj.Keys() {
v := obj.Get(key)
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
continue
}
// Check if it's an array
if arr, ok := v.Export().([]any); ok {
var values []string
for _, item := range arr {
if s, ok := item.(string); ok {
values = append(values, s)
}
}
result[key] = values
} else {
// Single value - wrap in slice
result[key] = []string{v.String()}
}
}
return result
}

191
internal/script/lua.go Normal file
View File

@@ -0,0 +1,191 @@
package script
import (
"fmt"
lua "github.com/yuin/gopher-lua"
"go.aykhans.me/sarin/internal/types"
)
// LuaEngine implements the Engine interface using gopher-lua.
type LuaEngine struct {
state *lua.LState
transform *lua.LFunction
}
// NewLuaEngine creates a new Lua script engine with the given script content.
// The script must define a global `transform` function that takes a request table
// and returns the modified request table.
//
// Example Lua script:
//
// function transform(req)
// req.headers["X-Custom"] = {"value"}
// return req
// end
//
// It can return the following errors:
// - types.ErrScriptTransformMissing
// - types.ScriptExecutionError
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
L := lua.NewState()
// Execute the script to define the transform function
if err := L.DoString(scriptContent); err != nil {
L.Close()
return nil, types.NewScriptExecutionError("Lua", err)
}
// Get the transform function
transform := L.GetGlobal("transform")
if transform.Type() != lua.LTFunction {
L.Close()
return nil, types.ErrScriptTransformMissing
}
return &LuaEngine{
state: L,
transform: transform.(*lua.LFunction),
}, nil
}
// 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 {
// Convert RequestData to Lua table
reqTable := e.requestDataToTable(req)
// Call transform(req)
e.state.Push(e.transform)
e.state.Push(reqTable)
if err := e.state.PCall(1, 1, nil); err != nil {
return types.NewScriptExecutionError("Lua", err)
}
// Get the result
result := e.state.Get(-1)
e.state.Pop(1)
if result.Type() != lua.LTTable {
return types.NewScriptExecutionError("Lua", fmt.Errorf("transform function must return a table, got %s", result.Type()))
}
// Update RequestData from the returned table
e.tableToRequestData(result.(*lua.LTable), req)
return nil
}
// Close releases the Lua state resources.
func (e *LuaEngine) Close() {
if e.state != nil {
e.state.Close()
}
}
// requestDataToTable converts RequestData to a Lua table.
func (e *LuaEngine) requestDataToTable(req *RequestData) *lua.LTable {
L := e.state
t := L.NewTable()
t.RawSetString("method", lua.LString(req.Method))
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()
for _, v := range values {
arr.Append(lua.LString(v))
}
headers.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
}
// tableToRequestData updates RequestData from a Lua table.
func (e *LuaEngine) tableToRequestData(t *lua.LTable, req *RequestData) {
// Method
if v := t.RawGetString("method"); v.Type() == lua.LTString {
req.Method = string(v.(lua.LString))
}
// Path
if v := t.RawGetString("path"); v.Type() == lua.LTString {
req.Path = string(v.(lua.LString))
}
// Body
if v := t.RawGetString("body"); v.Type() == lua.LTString {
req.Body = string(v.(lua.LString))
}
// Headers
if v := t.RawGetString("headers"); v.Type() == lua.LTTable {
req.Headers = e.tableToStringSliceMap(v.(*lua.LTable))
}
// Params
if v := t.RawGetString("params"); v.Type() == lua.LTTable {
req.Params = e.tableToStringSliceMap(v.(*lua.LTable))
}
// Cookies
if v := t.RawGetString("cookies"); v.Type() == lua.LTTable {
req.Cookies = e.tableToStringSliceMap(v.(*lua.LTable))
}
}
// tableToStringSliceMap converts a Lua table to a Go map[string][]string.
// Supports both single string values and array values.
func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
result := make(map[string][]string)
t.ForEach(func(k, v lua.LValue) {
if k.Type() != lua.LTString {
return
}
key := string(k.(lua.LString))
switch v.Type() {
case lua.LTString:
// Single string value
result[key] = []string{string(v.(lua.LString))}
case lua.LTTable:
// Array of strings
var values []string
v.(*lua.LTable).ForEach(func(_, item lua.LValue) {
if item.Type() == lua.LTString {
values = append(values, string(item.(lua.LString)))
}
})
result[key] = values
}
})
return result
}

213
internal/script/script.go Normal file
View File

@@ -0,0 +1,213 @@
package script
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
)
// RequestData represents the request data passed to scripts for transformation.
// Scripts can modify any field and the changes will be applied to the actual request.
// Headers, Params, and Cookies use []string values to support multiple values per key.
type RequestData struct {
Method string `json:"method"`
Path string `json:"path"`
Headers map[string][]string `json:"headers"`
Params map[string][]string `json:"params"`
Cookies map[string][]string `json:"cookies"`
Body string `json:"body"`
}
// Engine defines the interface for script engines (Lua, JavaScript).
// Each engine must be able to transform request data using a user-provided script.
type Engine interface {
// Transform executes the script's transform function with the given request data.
// The script should modify the RequestData and return it.
Transform(req *RequestData) error
// Close releases any resources held by the engine.
Close()
}
// EngineType represents the type of script engine.
type EngineType string
const (
EngineTypeLua EngineType = "lua"
EngineTypeJavaScript EngineType = "js"
)
// Source represents a loaded script source.
type Source struct {
Content string
EngineType EngineType
}
// LoadSource loads a script from the given source string.
// The source can be:
// - Inline script: any string not starting with "@"
// - Escaped "@": strings starting with "@@" (literal "@" at start, returns string without first @)
// - File reference: "@/path/to/file" or "@./relative/path"
// - URL reference: "@http://..." or "@https://..."
//
// It can return the following errors:
// - types.ErrScriptEmpty
// - types.ScriptLoadError
func LoadSource(ctx context.Context, source string, engineType EngineType) (*Source, error) {
if source == "" {
return nil, types.ErrScriptEmpty
}
var content string
var err error
switch {
case strings.HasPrefix(source, "@@"):
// Escaped @ - it's an inline script starting with literal @
content = source[1:] // Remove first @, keep the rest
case strings.HasPrefix(source, "@"):
// File or URL reference
ref := source[1:]
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
content, err = fetchURL(ctx, ref)
} else {
content, err = readFile(ref)
}
if err != nil {
return nil, types.NewScriptLoadError(ref, err)
}
default:
// Inline script
content = source
}
return &Source{
Content: content,
EngineType: engineType,
}, nil
}
// LoadSources loads multiple script sources.
// It can return the following errors:
// - types.ErrScriptEmpty
// - types.ScriptLoadError
func LoadSources(ctx context.Context, sources []string, engineType EngineType) ([]*Source, error) {
loaded := make([]*Source, 0, len(sources))
for _, src := range sources {
source, err := LoadSource(ctx, src, engineType)
if err != nil {
return nil, err
}
loaded = append(loaded, source)
}
return loaded, nil
}
// ValidateScript validates a script source by loading it and checking syntax.
// It loads the script (from file/URL/inline), parses it, and verifies
// that a 'transform' function is defined.
// It can return the following errors:
// - types.ErrScriptEmpty
// - types.ErrScriptTransformMissing
// - types.ScriptLoadError
// - types.ScriptExecutionError
// - types.ScriptUnknownEngineError
func ValidateScript(ctx context.Context, source string, engineType EngineType) error {
// Load the script source
src, err := LoadSource(ctx, source, engineType)
if err != nil {
return err
}
// Try to create an engine - this validates syntax and transform function
var engine Engine
switch engineType {
case EngineTypeLua:
engine, err = NewLuaEngine(src.Content)
case EngineTypeJavaScript:
engine, err = NewJsEngine(src.Content)
default:
return types.NewScriptUnknownEngineError(string(engineType))
}
if err != nil {
return err
}
// Clean up the engine - we only needed it for validation
engine.Close()
return nil
}
// ValidateScripts validates multiple script sources.
// It can return the following errors:
// - types.ErrScriptEmpty
// - types.ErrScriptTransformMissing
// - types.ScriptLoadError
// - types.ScriptExecutionError
// - types.ScriptUnknownEngineError
func ValidateScripts(ctx context.Context, sources []string, engineType EngineType) error {
for _, src := range sources {
if err := ValidateScript(ctx, src, engineType); err != nil {
return err
}
}
return nil
}
// fetchURL downloads content from an HTTP/HTTPS URL.
// It can return the following errors:
// - types.HTTPFetchError
// - types.HTTPStatusError
func fetchURL(ctx context.Context, url string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", types.NewHTTPFetchError(url, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", types.NewHTTPFetchError(url, err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", types.NewHTTPFetchError(url, err)
}
return string(data), nil
}
// readFile reads content from a local file.
// It can return the following errors:
// - types.FileReadError
func readFile(path string) (string, error) {
if !filepath.IsAbs(path) {
pwd, err := os.Getwd()
if err != nil {
return "", types.NewFileReadError(path, err)
}
path = filepath.Join(pwd, path)
}
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return "", types.NewFileReadError(path, err)
}
return string(data), nil
}

View File

@@ -6,16 +6,12 @@ import (
"strings"
)
var (
// General
ErrNoError = errors.New("no error (internal)")
// CLI
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
)
// ======================================== General ========================================
var (
ErrNoError = errors.New("no error (internal)")
)
type FieldParseError struct {
Field string
Value string
@@ -131,8 +127,147 @@ func (e UnmarshalError) Unwrap() error {
return e.error
}
// ======================================== General I/O ========================================
type FileReadError struct {
Path string
Err error
}
func NewFileReadError(path string, err error) FileReadError {
if err == nil {
err = ErrNoError
}
return FileReadError{path, err}
}
func (e FileReadError) Error() string {
return fmt.Sprintf("failed to read file %s: %v", e.Path, e.Err)
}
func (e FileReadError) Unwrap() error {
return e.Err
}
type HTTPFetchError struct {
URL string
Err error
}
func NewHTTPFetchError(url string, err error) HTTPFetchError {
if err == nil {
err = ErrNoError
}
return HTTPFetchError{url, err}
}
func (e HTTPFetchError) Error() string {
return fmt.Sprintf("failed to fetch %s: %v", e.URL, e.Err)
}
func (e HTTPFetchError) Unwrap() error {
return e.Err
}
type HTTPStatusError struct {
URL string
StatusCode int
Status string
}
func NewHTTPStatusError(url string, statusCode int, status string) HTTPStatusError {
return HTTPStatusError{url, statusCode, status}
}
func (e HTTPStatusError) Error() string {
return fmt.Sprintf("HTTP %d %s (url: %s)", e.StatusCode, e.Status, e.URL)
}
type URLParseError struct {
URL string
Err error
}
func NewURLParseError(url string, err error) URLParseError {
if err == nil {
err = ErrNoError
}
return URLParseError{url, err}
}
func (e URLParseError) Error() string {
return fmt.Sprintf("invalid URL %q: %v", e.URL, e.Err)
}
func (e URLParseError) Unwrap() error {
return e.Err
}
// ======================================== Template ========================================
var (
ErrFileCacheNotInitialized = errors.New("file cache is not initialized")
ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)")
)
type TemplateParseError struct {
Err error
}
func NewTemplateParseError(err error) TemplateParseError {
if err == nil {
err = ErrNoError
}
return TemplateParseError{err}
}
func (e TemplateParseError) Error() string {
return "template parse error: " + e.Err.Error()
}
func (e TemplateParseError) Unwrap() error {
return e.Err
}
type TemplateRenderError struct {
Err error
}
func NewTemplateRenderError(err error) TemplateRenderError {
if err == nil {
err = ErrNoError
}
return TemplateRenderError{err}
}
func (e TemplateRenderError) Error() string {
return "template rendering: " + e.Err.Error()
}
func (e TemplateRenderError) Unwrap() error {
return e.Err
}
// ======================================== YAML ========================================
type YAMLFormatError struct {
Detail string
}
func NewYAMLFormatError(detail string) YAMLFormatError {
return YAMLFormatError{detail}
}
func (e YAMLFormatError) Error() string {
return e.Detail
}
// ======================================== CLI ========================================
var (
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
)
type CLIUnexpectedArgsError struct {
Args []string
}
@@ -168,6 +303,61 @@ func (e ConfigFileReadError) Unwrap() error {
// ======================================== Proxy ========================================
type ProxyUnsupportedSchemeError struct {
Scheme string
}
func NewProxyUnsupportedSchemeError(scheme string) ProxyUnsupportedSchemeError {
return ProxyUnsupportedSchemeError{scheme}
}
func (e ProxyUnsupportedSchemeError) Error() string {
return "unsupported proxy scheme: " + e.Scheme
}
type ProxyParseError struct {
Err error
}
func NewProxyParseError(err error) ProxyParseError {
if err == nil {
err = ErrNoError
}
return ProxyParseError{err}
}
func (e ProxyParseError) Error() string {
return "failed to parse proxy URL: " + e.Err.Error()
}
func (e ProxyParseError) Unwrap() error {
return e.Err
}
type ProxyConnectError struct {
Status string
}
func NewProxyConnectError(status string) ProxyConnectError {
return ProxyConnectError{status}
}
func (e ProxyConnectError) Error() string {
return "proxy CONNECT failed: " + e.Status
}
type ProxyResolveError struct {
Host string
}
func NewProxyResolveError(host string) ProxyResolveError {
return ProxyResolveError{host}
}
func (e ProxyResolveError) Error() string {
return "no IP addresses found for host: " + e.Host
}
type ProxyDialError struct {
Proxy string
Err error
@@ -187,3 +377,86 @@ func (e ProxyDialError) Error() string {
func (e ProxyDialError) Unwrap() error {
return e.Err
}
// ======================================== Script ========================================
var (
ErrScriptEmpty = errors.New("script cannot be empty")
ErrScriptSourceEmpty = errors.New("script source cannot be empty after @")
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
ErrScriptURLNoHost = errors.New("script URL must have a host")
)
type ScriptLoadError struct {
Source string
Err error
}
func NewScriptLoadError(source string, err error) ScriptLoadError {
if err == nil {
err = ErrNoError
}
return ScriptLoadError{source, err}
}
func (e ScriptLoadError) Error() string {
return fmt.Sprintf("failed to load script from %q: %v", e.Source, e.Err)
}
func (e ScriptLoadError) Unwrap() error {
return e.Err
}
type ScriptExecutionError struct {
EngineType string
Err error
}
func NewScriptExecutionError(engineType string, err error) ScriptExecutionError {
if err == nil {
err = ErrNoError
}
return ScriptExecutionError{engineType, err}
}
func (e ScriptExecutionError) Error() string {
return fmt.Sprintf("%s script error: %v", e.EngineType, e.Err)
}
func (e ScriptExecutionError) Unwrap() error {
return e.Err
}
type ScriptChainError struct {
EngineType string
Index int
Err error
}
func NewScriptChainError(engineType string, index int, err error) ScriptChainError {
if err == nil {
err = ErrNoError
}
return ScriptChainError{engineType, index, err}
}
func (e ScriptChainError) Error() string {
return fmt.Sprintf("%s script[%d]: %v", e.EngineType, e.Index, e.Err)
}
func (e ScriptChainError) Unwrap() error {
return e.Err
}
type ScriptUnknownEngineError struct {
EngineType string
}
func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError {
return ScriptUnknownEngineError{engineType}
}
func (e ScriptUnknownEngineError) Error() string {
return "unknown engine type: " + e.EngineType
}

View File

@@ -1,7 +1,6 @@
package types
import (
"fmt"
"net/url"
)
@@ -17,6 +16,9 @@ func (proxies *Proxies) Append(proxy ...Proxy) {
*proxies = append(*proxies, proxy...)
}
// Parse parses a raw proxy string and appends it to the list.
// It can return the following errors:
// - ProxyParseError
func (proxies *Proxies) Parse(rawValue string) error {
parsedProxy, err := ParseProxy(rawValue)
if err != nil {
@@ -27,10 +29,13 @@ func (proxies *Proxies) Parse(rawValue string) error {
return nil
}
// ParseProxy parses a raw proxy URL string into a Proxy.
// It can return the following errors:
// - ProxyParseError
func ParseProxy(rawValue string) (*Proxy, error) {
urlParsed, err := url.Parse(rawValue)
if err != nil {
return nil, fmt.Errorf("failed to parse proxy URL: %w", err)
return nil, NewProxyParseError(err)
}
proxyParsed := Proxy(*urlParsed)